diff --git a/.github/actions/install-build-deps/action.yml b/.github/actions/install-build-deps/action.yml new file mode 100644 index 0000000000..e055089cdd --- /dev/null +++ b/.github/actions/install-build-deps/action.yml @@ -0,0 +1,20 @@ +name: Install build dependencies +description: Install the native build dependencies required to build IronRDP (Linux ALSA headers, Windows NASM). + +runs: + using: composite + steps: + - name: Install devel packages + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + sudo apt-get update -qq + sudo apt-get -y install libasound2-dev + + - name: Install NASM + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: | + choco install nasm + $Env:PATH += ";$Env:ProgramFiles\NASM" + echo "PATH=$Env:PATH" >> $Env:GITHUB_ENV diff --git a/.github/skills/release-prep/SKILL.md b/.github/skills/release-prep/SKILL.md new file mode 100644 index 0000000000..c52b1fd670 --- /dev/null +++ b/.github/skills/release-prep/SKILL.md @@ -0,0 +1,76 @@ +--- +name: release-prep +description: Reviews and finalizes a release-plz "chore(release): prepare for publishing" PR for the IronRDP workspace. Use this skill whenever the user asks to review, fix, prepare, or finalize a release PR; mentions release-plz, version bumps, CHANGELOG cleanup, or "publishing crates"; or references a PR titled "chore(release): prepare for publishing". Invoke proactively any time work involves auditing or editing CHANGELOG.md / Cargo.toml version fields across multiple crates ahead of publishing to crates.io. +--- + +# IronRDP release-plz PR review & finalization + +release-plz does most of the mechanical work (computing bumps, generating changelog entries from conventional commits, updating dependency version requirements, refreshing `Cargo.lock`). This skill covers the **human-in-the-loop pass** that release-plz cannot do automatically: verifying bumps against the public-dependency rule, rewriting per-crate changelog entries, and deduplicating commits that collapse into a single net change. + +All fixes go on the **release-plz PR branch** directly. Do not rewrite history on `main` for cosmetic changelog reasons; if real source/doc changes are needed, land them on `main` first and let release-plz regenerate the PR. + +## The public-dependency propagation rule + +A dependency line in `Cargo.toml` annotated with `# public` means that the dependency's types appear in this crate's *public* API surface. + +- **Breaking change in a `# public` dependency ⇒ breaking change in the depending crate** (minor bump pre-1.0, major bump post-1.0). The downstream changelog must reflect that it is breaking, even if the downstream's own code changes were trivial recompiles. +- A *patch-level* bump in a `# public` dependency does **not** force any bump in downstream crates: workspace `Cargo.toml` files use `"0.7"`-style major.minor requirements only, so Cargo's resolver picks the new patch up automatically. +- A dependency *without* the `# public` marker is internal. A breaking change in it is just a patch for the depending crate (assuming the public API is unchanged). + +Enumerate public-dep edges with `grep -n '# public' crates/*/Cargo.toml`. The dep's bump kind is visible in the release-plz PR body's "New release" summary. + +For each crate in the release, cross-check the proposed bump against (a) its own commits' conventional-commit signals (`feat!`, `BREAKING CHANGE` footer, etc.) and (b) the propagation rule above. When the bump is wrong, fix it on the PR branch: edit `version = "..."` in that crate's `Cargo.toml`, cascade to the version requirement in any consumer crate's `Cargo.toml`, and update the version-header line at the top of the corresponding `CHANGELOG.md` (`## [[X.Y.Z](...)] - YYYY-MM-DD`, including the `vOLD...vNEW` compare URL). + +## Changelog quality pass + +release-plz pulls each commit's subject + body into the changelog of *every* crate touched by that commit. Many IronRDP commits span multiple crates with very different semantics on each side — e.g. a breaking change in `ironrdp-pdu` that requires a mechanical call-site update in `ironrdp-connector`. The PDU-flavored description does not belong in the connector's changelog. + +**For each duplicated entry:** + +1. **Default:** rewrite to describe what *this* crate actually changed, inferred from the diff for this crate in the originating commit. Use `git show -- crates//` to see only this crate's slice. +2. **Fallback:** if the change here is genuinely just "recompile against new dep" with nothing more specific to say, replace the entry with a short note such as `Update dependency`. There *is* a real code change (release-plz wouldn't have inserted an entry otherwise — at minimum the dep version requirement moved), so the changelog should still acknowledge it; just don't parrot the upstream description. +3. Preserve the conventional-commit category mapping (Features / Bug Fixes / Documentation / etc.) defined in `cliff.toml`, but recategorize if the rewritten entry fits a different bucket better. +4. If the rewrite changes a non-breaking entry into a breaking one (or vice versa) because of the public-dep rule, add or remove the `[**breaking**]` prefix accordingly, and revert the breaking bump which was wrongly applied (minor bump pre-1.0, major bump post-1.0). + +### Deduplication across the release window + +Multiple commits in the release window often collapse into a single net change. Spend real effort on this — a clean changelog is one of the highest-leverage outputs of this pass. Common patterns: + +- **Revert before release:** both entries should be dropped (or, if the revert was partial, replaced with a single entry describing the final state). +- **Iteration on an unreleased feature:** initial commit introduces feature X, follow-ups tweak its API or behaviour, all within the same release window. Collapse into a single entry describing X *as it actually ships*. +- **Bug fix on an unreleased feature:** if the bug only ever existed in the unreleased code, fold the fix into the feature entry. +- **Mechanical follow-ups** (review feedback, clippy fixes on a feature PR): keep only the feature entry. + +When merging entries, preserve every relevant `[#NNNN]`/commit-SHA link so the historical trail stays navigable. The headline text should describe the *final* outcome, not the journey. + +### Format fidelity + +`CHANGELOG.md` content is generated by git-cliff using the template in `cliff.toml`. When hand-editing, preserve the generated format exactly: + +- **Section order** is controlled by `` prefixes in `commit_parsers` (Security, Features, Improvements, Revert, Bug Fixes, Performance, Documentation, Build, Please Sort). Do not reorder sections, even if the result looks unbalanced. +- **Section headings** keep the `` prefix verbatim (e.g. `### Features`). Don't strip it. +- **Spacing** must match git-cliff's output: one blank line between version headers, sections, and entries; two-space hanging indent for entry bodies. Don't collapse or add blank lines. +- **Entry shape** is `- {breaking?}{Message} ({commit_link})` followed, when there's a body, by a blank line and the indented body. Keep `[**breaking**]` exactly as rendered. +- **Links** are reference-style URLs to issues/PRs (`[#1234](...)`) and commits (`[abcdef0123](...)`). When merging or rewriting entries, keep these links intact rather than rewriting them by hand. +- **Category placement** for rewritten entries should still match the `commit_parsers` mapping in `cliff.toml`. If a rewrite changes the appropriate category, move the entry into the correct existing section rather than inventing a new one. + +## Scope of edits + +Only these files should be touched on the release-plz PR branch: + +- `crates//CHANGELOG.md` — content rewrites, drops, breaking-tag fixes, version-header bumps. +- `crates//Cargo.toml` — `version = "..."` corrections and cascading version-requirement updates in consumer crates. +- `Cargo.lock` — regenerated automatically by `cargo check` after any `Cargo.toml` version edit (see procedure step 6); never edited by hand. + +Do **not** touch source code, READMEs, MSRV, `rust-toolchain.toml`, or CI files here. Those belong on `main`. + +## Procedure + +1. Inspect the PR's changes from the working tree: `git --no-pager diff origin/main -- '**/CHANGELOG.md' '**/Cargo.toml'` for the substantive deltas, and read the PR body for the "New release" bump table (use `gh pr view` only if the body isn't already provided in context). +2. Read the PR body's "New release" bump table. +3. Enumerate `# public` dep edges and cross-check every proposed bump against the propagation rule and the originating commits' conventional-commit signals. Note mismatches. +4. For each crate's `CHANGELOG.md` section in the diff, classify entries as *own change*, *public-dep ripple*, or *internal-dep ripple / unrelated*. Rewrite or replace per the rules above. Then deduplicate across the release window. +5. Apply any `Cargo.toml` version corrections from step 3, cascading to consumer crates' version requirements and the corresponding changelog version headers. +6. If you edited any `Cargo.toml` version, run `cargo check --workspace` (without `--locked`, so `Cargo.lock` can be regenerated) and commit the resulting lock delta. Then run `cargo xtask check locks -v`, which verifies no lock file is left uncommitted. +7. Push the fixups with conventional-commit-shaped messages (typically `chore(release): ...`) so they don't pollute the next release window. +8. Summarize the changes and any judgement calls — especially dropped entries and escalated bumps — for the human reviewer. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c21857bb87..2e3bae92be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: branches: - master pull_request: - types: [ opened, synchronize, reopened ] + types: [opened, synchronize, reopened] workflow_dispatch: env: @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Check formatting run: cargo xtask check fmt -v @@ -40,10 +40,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Binary cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ./.cargo/local_root/bin key: ${{ runner.os }}-bin-${{ github.job }}-${{ hashFiles('xtask/src/bin_version.rs') }} @@ -56,12 +56,12 @@ jobs: checks: name: Checks [${{ matrix.os }}] + needs: [formatting] runs-on: ${{ matrix.runner }} - needs: formatting strategy: fail-fast: false matrix: - os: [ windows, linux, macos ] + os: [windows, linux, macos] include: - os: windows runner: windows-latest @@ -72,26 +72,16 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - - name: Install devel packages - if: runner.os == 'Linux' - run: | - sudo apt-get -y install libasound2-dev - - - name: Install NASM - if: runner.os == 'Windows' - shell: pwsh - run: | - choco install nasm - $Env:PATH += ";$Env:ProgramFiles\NASM" - echo "PATH=$Env:PATH" >> $Env:GITHUB_ENV + - name: Install build dependencies + uses: ./.github/actions/install-build-deps - name: Rust cache uses: Swatinem/rust-cache@v2.7.3 - name: Binary cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ./.cargo/local_root/bin key: ${{ runner.os }}-bin-${{ github.job }}-${{ hashFiles('xtask/src/bin_version.rs') }} @@ -107,6 +97,9 @@ jobs: - name: Lints run: cargo xtask check lints -v + - name: Dependencies + run: cargo xtask check dependencies -v + - name: WASM (prepare) run: cargo xtask wasm install -v @@ -118,11 +111,11 @@ jobs: fuzz: name: Fuzzing + needs: [formatting] runs-on: ubuntu-latest - needs: formatting steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Rust cache uses: Swatinem/rust-cache@v2.7.3 @@ -130,7 +123,7 @@ jobs: workspaces: fuzz -> target - name: Binary cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ./.cargo/local_root/bin key: ${{ runner.os }}-bin-${{ github.job }}-${{ hashFiles('xtask/src/bin_version.rs') }} @@ -147,17 +140,17 @@ jobs: web: name: Web Client + needs: [formatting] runs-on: ubuntu-latest - needs: formatting steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Rust cache uses: Swatinem/rust-cache@v2.7.3 - name: Binary cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ./.cargo/local_root/bin key: ${{ runner.os }}-bin-${{ github.job }}-${{ hashFiles('xtask/src/bin_version.rs') }} @@ -173,17 +166,17 @@ jobs: ffi: name: FFI + needs: [formatting] runs-on: ubuntu-latest - needs: formatting steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Rust cache uses: Swatinem/rust-cache@v2.7.3 - name: Binary cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ./.cargo/local_root/bin key: ${{ runner.os }}-bin-${{ github.job }}-${{ hashFiles('xtask/src/bin_version.rs') }} @@ -200,22 +193,68 @@ jobs: - name: Build .NET projects run: cd ./ffi/dotnet && dotnet build + feature-matrix-setup: + name: feature matrix setup + needs: [formatting] + runs-on: ubuntu-latest + outputs: + feature-matrix: ${{ steps.setup-matrix.outputs.feature-matrix }} + + steps: + - uses: actions/checkout@v6 + + - name: Rust cache + uses: Swatinem/rust-cache@v2.7.3 + + - name: Setup matrix + id: setup-matrix + run: | + MATRIX="$(cargo xtask check features --list --format github-matrix)" + echo "feature-matrix=$MATRIX" >> "$GITHUB_OUTPUT" + + feature-matrix: + name: feature matrix [${{ matrix.case }}] + needs: [formatting, feature-matrix-setup] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.feature-matrix-setup.outputs.feature-matrix) }} + + steps: + - uses: actions/checkout@v6 + + - name: Install build dependencies + uses: ./.github/actions/install-build-deps + + - name: Rust cache + uses: Swatinem/rust-cache@v2.7.3 + + - name: Binary cache + uses: actions/cache@v5 + with: + path: ./.cargo/local_root/bin + key: ${{ runner.os }}-bin-${{ github.job }}-${{ hashFiles('xtask/src/bin_version.rs') }} + + - name: Prepare + run: cargo xtask check install -v + + - name: Run feature-matrix case + run: cargo xtask check features --case '${{ matrix.case }}' -v + + - name: Lock files + run: cargo xtask check locks -v + success: name: Success - runs-on: ubuntu-latest if: ${{ always() }} - needs: - - formatting - - typos - - checks - - fuzz - - web - - ffi + needs: [formatting, typos, checks, fuzz, web, ffi, feature-matrix-setup, feature-matrix] + runs-on: ubuntu-latest steps: - name: Check success - shell: pwsh run: | $results = '${{ toJSON(needs.*.result) }}' | ConvertFrom-Json $succeeded = $($results | Where { $_ -Ne "success" }).Count -Eq 0 exit $(if ($succeeded) { 0 } else { 1 }) + shell: pwsh diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index 4e830be09b..0000000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Coverage - -on: - push: - branches: - - master - pull_request: - types: [ opened, synchronize, reopened ] - workflow_dispatch: - -env: - CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse - -jobs: - coverage: - name: Coverage Report - runs-on: ubuntu-latest - - # Running the coverage job is only supported on the official repo itself, not on forks - # (because $GITHUB_TOKEN only have read permissions when run on a fork) - # We would need something like Codecov integration to handle forks properly - # https://github.com/taiki-e/cargo-llvm-cov#continuous-integration - if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' - - steps: - - uses: actions/checkout@v4 - - - name: Rust cache - uses: Swatinem/rust-cache@v2.7.3 - - - name: Prepare runner - run: cargo xtask cov install -v - - - name: Generate PR report - if: github.event.number != '' - env: - GH_TOKEN: ${{ github.token }} - run: cargo xtask cov report-gh --repo "${{ github.repository }}" --pr "${{ github.event.number }}" -v - - - name: Configure Git Identity - if: github.ref == 'refs/heads/master' - run: | - git config --local user.name "github-actions[bot]" - git config --local user.email "github-actions[bot]@users.noreply.github.com" - - - name: Update coverage data - if: github.ref == 'refs/heads/master' - env: - GH_TOKEN: ${{ secrets.DEVOLUTIONSBOT_TOKEN }} - run: cargo xtask cov update -v diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 71d6762cc0..f95fa4e360 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -21,33 +21,52 @@ jobs: AZURE_STORAGE_KEY: ${{ secrets.CORPUS_AZURE_STORAGE_KEY }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download fuzzing corpus run: cargo xtask fuzz corpus-fetch -v - name: Save corpus - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: | ./fuzz/corpus ./fuzz/artifacts key: fuzz-corpus-${{ github.run_id }} + fuzz-setup: + name: Fuzz matrix setup + needs: [corpus-download] + runs-on: ubuntu-latest + outputs: + fuzz-matrix: ${{ steps.setup-matrix.outputs.fuzz-matrix }} + + steps: + - uses: actions/checkout@v6 + + - name: Rust cache + uses: Swatinem/rust-cache@v2.7.3 + + - name: Setup matrix + id: setup-matrix + run: | + MATRIX="$(cargo xtask fuzz list --format github-matrix)" + echo "fuzz-matrix=$MATRIX" >> "$GITHUB_OUTPUT" + fuzz: name: Fuzzing ${{ matrix.target }} + needs: [corpus-download, fuzz-setup] runs-on: ubuntu-latest - needs: corpus-download strategy: fail-fast: false matrix: - target: [ pdu_decoding, rle_decompression, bitmap_stream, cliprdr_format, channel_processing ] + include: ${{ fromJson(needs.fuzz-setup.outputs.fuzz-matrix) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download corpus - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: fail-on-cache-miss: true path: | @@ -66,7 +85,7 @@ jobs: workspaces: fuzz -> target - name: Binary cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ./.cargo/local_root/bin key: ${{ runner.os }}-bin-${{ github.job }}-${{ hashFiles('xtask/src/bin_version.rs') }} @@ -98,7 +117,7 @@ jobs: - name: Upload minified corpus if: ${{ always() && !cancelled() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: retention-days: 7 name: minified-corpus-${{ matrix.target }} @@ -108,13 +127,13 @@ jobs: corpus-merge: name: Corpus merge artifacts - runs-on: ubuntu-latest - needs: fuzz if: ${{ always() && !cancelled() }} + needs: [fuzz] + runs-on: ubuntu-latest steps: - name: Merge Artifacts - uses: actions/upload-artifact/merge@v4 + uses: actions/upload-artifact/merge@v7 with: name: minified-corpus pattern: minified-corpus-* @@ -122,17 +141,17 @@ jobs: corpus-upload: name: Upload corpus - runs-on: ubuntu-latest - needs: corpus-merge if: ${{ always() && !cancelled() }} + needs: [corpus-merge] + runs-on: ubuntu-latest env: AZURE_STORAGE_KEY: ${{ secrets.CORPUS_AZURE_STORAGE_KEY }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download updated corpus - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: minified-corpus path: ./fuzz/ @@ -156,13 +175,13 @@ jobs: notify: name: Notify failure - runs-on: ubuntu-latest if: ${{ always() && contains(needs.*.result, 'failure') && github.event_name == 'schedule' }} - needs: - - fuzz + needs: [fuzz] + runs-on: ubuntu-latest env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_ARCHITECTURE }} SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK + steps: - name: Send slack notification id: slack diff --git a/.gitignore b/.gitignore index 5156a02b8f..39f5287459 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,6 @@ # Log files *.log -# Coverage -/docs/coverage - # Editor/IDE files *~ /tags diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..23ac207cfb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,200 @@ +# AI Agent Guidelines & Repository Manual + +**Role:** You are an expert Senior Rust Systems Engineer and Technical Lead. +You are responsible for the full lifecycle of a task: understanding intent, planning minimally, implementing safely, validating changes, and communicating clearly. + +## Auto-Pilot Workflow + +1. **Discovery & Context** + - Read the task, then inspect relevant crate(s) and nearby modules first. + - Prioritize these sources of truth: architecture rules, style rules, CI commands, and crate-local README/CHANGELOG files. + - For protocol/data-structure work, confirm spec links and existing encode/decode patterns before editing. + +2. **Plan** + - Make a short plan for non-trivial changes; keep scope tight to the user request. + - Identify affected workspace members (`crates/*`, `xtask`, `ffi`, `benches`, `fuzz`, `web-client`) and API boundaries. + - Prefer root-cause fixes over local workarounds. + +3. **Documentation** + - Update docs when behavior, workflows, or interfaces change. + - Keep crate docs and examples aligned with implementation. + - Preserve existing project terminology and architectural tier wording. + +4. **Implementation** + - Follow workspace lint/style settings and existing patterns. + - Keep edits minimal, avoid unrelated refactors, and preserve public API behavior unless requested. + - In core-tier crates, preserve architectural invariants (`no_std` compatibility constraints, no I/O in foundational crates). + +5. **Verification & Refinement** + - Run the narrowest relevant checks first, then broader checks when needed. + - Preferred checks: + - `cargo xtask check fmt -v` + - `cargo xtask check lints -v` + - `cargo xtask check tests -v` + - `cargo xtask check locks -v` + - For web/ffi/fuzz-specific edits, run the corresponding `xtask` commands. + +6. **Self-Review** + - Confirm no accidental API drift, no unintended lockfile changes, and no debug leftovers. + - Ensure error/log message formatting follows repository conventions. + - Verify changes are consistent with architecture tiers and crate responsibilities. + +## Documentation & Knowledge Base + +You are expected to read and follow these sources of truth when relevant: + +- **Repository overview:** `README.md` +- **Architecture & tiers/invariants:** `ARCHITECTURE.md` +- **Coding/style conventions:** `STYLE.md` +- **Task automation details:** `xtask/README.md` +- **Workspace/build configuration:** `Cargo.toml`, `rust-toolchain.toml`, `clippy.toml`, `rustfmt.toml` +- **Cargo aliases & WASM flags:** `.cargo/config.toml` (defines `cargo xtask` alias and WASM `rustflags`) +- **Typo checker config:** `typos.toml` +- **CI behavior:** `.github/workflows/ci.yml` +- **Changelog / release config:** `cliff.toml`, `release-plz.toml` +- **Crate-level specifics:** `crates/*/README.md` and `crates/*/CHANGELOG.md` +- **FFI details:** `ffi/README.md` +- **Web client details:** `web-client/README.md` + +### Microsoft Open Specifications (Agent Skill) + +For protocol-level work, the [windows-protocols](hhttps://skills.sh/awakecoding/openspecs/windows-protocols) agent skill provides a local markdown corpus of Microsoft Open Specifications (`MS-RDP*` and related docs). +When referencing these specs, check if the skill is installed and suggest installing it if not: + +``` +npx skills add https://github.com/awakecoding/openspecs --skill windows-protocols -g +``` + +See [skills.sh](https://skills.sh) for more on the `npx skills` command. + +## Project Structure & Architecture + +- **`crates/`**: Main Rust workspace crates (core, extra, internal, community tiers). +- **`crates/ironrdp-*`**: Protocol, session, channel, client, server, and support crates. +- **`ironrdp/`**: Meta crate and examples. +- **`xtask/`**: Project automation entrypoint (`cargo xtask ...`). +- **`fuzz/`**: Fuzz targets/corpus for robustness testing. +- **`ffi/`**: Native library + .NET bindings and examples. +- **`web-client/`**: Browser/web-component/Svelte client artifacts. +- **`benches/`**: Benchmarks and perf-related code. + +When changing architecture-sensitive crates, preserve tier boundaries and invariants from `ARCHITECTURE.md`. + +### Notable Crates Not Yet in ARCHITECTURE.md + +These crates exist on disk but are not documented in `ARCHITECTURE.md`. Be aware of them when working on related subsystems: + +- `ironrdp-ainput` — alternative input channel +- `ironrdp-bulk` — bulk compression +- `ironrdp-cliprdr-format` — clipboard format definitions +- `ironrdp-displaycontrol` — display control channel +- `ironrdp-dvc-com-plugin` — DVC COM plugin +- `ironrdp-dvc-pipe-proxy` — DVC pipe proxy +- `ironrdp-egfx` — extended graphics pipeline channel +- `ironrdp-rdpdr-native` — native RDPDR backend +- `ironrdp-rdpsnd-native` — native RDPSND backend +- `ironrdp-bench` — benchmarking harness +- `iron-remote-desktop` (under `crates/`) — remote desktop abstractions + +### Workspace Exclusions + +These crates are excluded from the workspace (`# FIXME: fix compilation`) and **do not currently compile**: + +- `crates/ironrdp-client-glutin` +- `crates/ironrdp-glutin-renderer` +- `crates/ironrdp-replay-client` + +Do not modify them unless specifically working on fixing their compilation. + +## Development Environment + +### Core Commands +- **Bootstrap tools:** `cargo xtask bootstrap -v` +- **Formatting check:** `cargo xtask check fmt -v` +- **Lint check:** `cargo xtask check lints -v` +- **Test compile only:** `cargo xtask check tests --no-run -v` +- **Run tests:** `cargo xtask check tests -v` +- **Typo check:** `cargo xtask check typos -v` +- **Lockfile check:** `cargo xtask check locks -v` +- **Full CI-equivalent sweep:** `cargo xtask ci -v` + +### Specialized Commands +- **WASM checks:** `cargo xtask wasm install -v` and `cargo xtask wasm check -v` +- **Web checks/build/run:** `cargo xtask web install -v`, `cargo xtask web check -v`, `cargo xtask web build -v`, `cargo xtask web run -v` +- **Fuzzing:** `cargo xtask fuzz install -v`, `cargo xtask fuzz run -v` +- **FFI:** `cargo xtask ffi install -v`, `cargo xtask ffi build -v`, `cargo xtask ffi bindings -v` +- **FFI .NET build:** `cd ./ffi/dotnet && dotnet build` (also run in CI, not an xtask command) + +## Coding Standards (The "Gold Standard") + +- **Language:** Rust (Edition 2024; toolchain pinned via `rust-toolchain.toml`) +- **Toolchain baseline:** Rust `1.89.0` +- **Formatter:** `rustfmt` (workspace config in `rustfmt.toml`) +- **Lints:** Strict workspace lint policy (`[workspace.lints.rust]` and `[workspace.lints.clippy]` in root `Cargo.toml`) +- **Error handling:** Prefer explicit, composable error messages following `STYLE.md` +- **Testing:** Use existing Rust tests + property tests/fuzzing patterns when relevant + +### Key Style Conventions (from `STYLE.md`) + +- **Error messages:** lowercase, no trailing punctuation, use `crate_name::Result` (e.g., `anyhow::Result`) not bare `Result`. +- **Log messages:** capitalize first letter, no trailing period, use structured tracing fields (`info!(%server_addr, "Looked up server address")`). +- **Size constants:** annotate each addend with an inline comment naming the field (e.g., `1 /* Version */ + 2 /* Length */`). +- **Invariants:** define with `INVARIANT:` prefix in comments; state positively; prefer `<`/`<=` over `>`/`>=`. +- **Doc comments:** link to spec sections using reference-style links. +- **Avoid monomorphization:** use `&dyn` inner functions for large generic code; avoid `AsRef` polymorphism. +- **No single-use helper functions:** use blocks instead; put nested helpers at end of enclosing function. + +### Dependency Policies + +- **Do not use `[workspace.dependencies]`** for anything that is not workspace-internal (see comment in root `Cargo.toml`). This is required for `release-plz` to correctly detect dependency updates. +- **`num-derive` and `num-traits` are being phased out.** Do not introduce new usage of these crates. + +### Critical Anti-Patterns +- Adding blocking I/O in core-tier foundational crates. +- Breaking `no_std`/feature-gating expectations of foundational crates. +- Introducing unnecessary dependencies or proc-macro-heavy dependencies in low-level crates. +- Using `unwrap`/panic-oriented code in production paths without strong justification. +- Mixing unrelated refactors with feature/bugfix changes. +- Ignoring existing encode/decode and protocol-structure conventions. + +## Web Client Design Philosophy + +The web client stack has a strict layering rule that must be respected when modifying or reviewing +code under `web-client/` or `crates/iron-remote-desktop/`. + +### `iron-remote-desktop` is protocol-agnostic + +`iron-remote-desktop` (the NPM package and Svelte web component) knows nothing about any specific +remote protocol. It defines only features that are meaningful for **any** remote desktop backend: +keyboard/mouse input, canvas rendering and resize, clipboard text/binary, connection lifecycle, +and cursor style. + +### The API surface rule (Architectural Invariant) + +The core rule is: a method belongs in the base API (`UserInteraction` / `Session` / `SessionBuilder`) +if the web component itself needs to call it for transparent behaviour, or if the feature is universal +across all remote protocol backends. Protocol-specific concepts must go through the extension mechanism instead. + +## Domain Specifics & Non-Negotiables + +- **Protocol correctness first:** preserve wire compatibility and established encode/decode semantics. +- **Security-first posture:** treat parsing and state transitions as hostile-input surfaces. +- **Spec-traceable docs:** when adding protocol entities, include concise doc comments with spec references where appropriate. +- **Performance awareness:** avoid avoidable allocations/copies in hot paths and keep compile-time costs reasonable in foundational crates. +- **Boundary discipline:** respect crate API boundaries and keep internal-only logic in internal-tier crates. + +## CI/CD & Deployment + +CI runs via GitHub Actions (`.github/workflows/ci.yml`). +The expectation is that `cargo xtask ci -v` locally is equivalent to a full CI run. +All commands in the Core and Specialized Commands sections above are what CI executes (each preceded by its `install` step where applicable, and `cargo xtask check locks -v` is run in multiple jobs). + +Additional workflows exist for releases (`release-crates.yml`), npm (`npm-publish.yml`), NuGet (`nuget-publish.yml`), and fuzzing. +Do not alter release automation unless explicitly requested. + +### Workspace & Change Scope Rules + +- Workspace members are declared in root `Cargo.toml`. Secondary ecosystems exist in `web-client/` (Node/npm) and `ffi/dotnet/` (.NET). +- Keep crate-local changes crate-local when possible. +- Use targeted commands during iteration (e.g., `cargo test -p `), then run relevant `xtask` checks. +- Treat lockfile and cross-crate dependency updates as intentional, reviewable changes. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e28decaf78..c5f27e34d5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -168,7 +168,15 @@ NOTE: it’s not yet clear if this crate is an API Boundary or an implementation #### [`crates/ironrdp-client`](./crates/ironrdp-client) -Portable RDP client without GPU acceleration. +Reusable client engine library: holds the `Config`/`ConfigBuilder`, the `RdpClient` runtime, +input/output event types, and the WebSocket transport. Consumed by `ironrdp-viewer` and any +other embedder (e.g. a headless agent). + +#### [`crates/ironrdp-viewer`](./crates/ironrdp-viewer) + +Portable RDP client binary without GPU acceleration. A thin wrapper around `ironrdp-client` +that adds the winit/softbuffer GUI event loop, the clap CLI, the inquire prompts and the +`.rdp` file / PropertySet plumbing. #### [`crates/ironrdp-web`](./crates/ironrdp-web) @@ -178,13 +186,44 @@ This crate is an **API Boundary** (WASM module). #### [`web-client/iron-remote-desktop`](./web-client/iron-remote-desktop) -Core frontend UI used by `iron-svelte-client` as a Web Component. +Protocol-agnostic web component and NPM package for remote desktop sessions. +Used by `iron-svelte-client` as a Web Component. This crate is an **API Boundary**. +**Architectural Invariant**: `iron-remote-desktop` must remain completely agnostic of any specific +remote protocol (RDP, VNC, or otherwise). It defines the universal surface for features that are +meaningful regardless of the underlying protocol: keyboard and mouse input, canvas rendering, +clipboard text/binary transfer, resize, connection lifecycle, and cursor style. + +A method belongs in the base API if **either** of the following is true: + +1. **The web component itself needs to call it** to implement transparent, protocol-independent + behaviour (e.g., `supportsUnicodeKeyboardShortcuts()` is called internally by the component + to adapt keyboard handling, without consumer involvement). +2. **The feature is universal** — every reasonable remote protocol backend would implement it + in a meaningful way (e.g., resize, clipboard text/binary, cursor style). + +If neither applies, particularly if the method exposes protocol wire concepts such as PDU +fields, lock IDs, stream IDs, or protocol-specific flags, it belongs in the backend and must +be delivered via the extension mechanism. + +The extension system works as follows: +- `Extension` is typed as `unknown`; intentionally opaque at this layer. +- Backends define their own concrete `Extension` types and factory functions. +- The consumer calls `userInteraction.configBuilder().withExtension(ext)` or + `userInteraction.invokeExtension(ext)` at runtime. +- `iron-remote-desktop` passes the value through to the backend without inspection. +- The backend (e.g., `iron-remote-desktop-rdp`) interprets it meaningfully. + +See [`web-client/iron-remote-desktop-rdp`](./web-client/iron-remote-desktop-rdp) for a concrete +example: RDP-specific capabilities such as `preConnectionBlob`, `displayControl`, `kdcProxyUrl`, +and `enableCredssp` are all delivered as backend extensions, not as base API surface. + #### [`web-client/iron-remote-desktop-rdp`](./web-client/iron-remote-desktop-rdp) -Implementation of the TypeScript interfaces exposed by WebAssembly bindings from `ironrdp-web` and used by `iron-svelte-client`. +RDP backend for `iron-remote-desktop`. Implements `RemoteDesktopModule` using the WASM bindings +from `ironrdp-web`, and exposes RDP-specific Extension factory functions. This crate is an **API Boundary**. @@ -299,6 +338,30 @@ The expectation is that, if `cargo xtask ci` passes locally, the CI will be gree **Architecture Invariant**: `cargo xtask ci` and CI workflow must be logically equivalents. It must be the case that a successful `cargo xtask ci` run implies a successful CI workflow run and vice versa. +### MSRV policy + +IronRDP libraries follow a conservative MSRV (Minimum Supported Rust Version) policy. + +**Definition**: The MSRV is the oldest of: + +- the latest stable Rust release that is at least 6 months old; +- the Rust version packaged by the [latest Fedora stable release](https://packages.fedoraproject.org/pkgs/rust/rust/); or +- the Rust version available in [Debian stable-backports](https://packages.debian.org/search?suite=all&arch=any&searchon=names&keywords=rust). + +**Toolchain and CI**: The Rust toolchain pinned in `rust-toolchain.toml` is both the project toolchain and the MSRV validated by CI. +The `rust-version` key in each published crate's `Cargo.toml` is kept in sync with this toolchain version. +The workspace is compiled once using the pinned toolchain to keep CI efficient; there are no separate CI jobs dedicated to validating older Rust versions. + +**Architecture Invariant**: The MSRV is not validated by a separately configured toolchain or a dedicated CI job. +The pinned toolchain in `rust-toolchain.toml` serves as the single source of truth. + +**Bumping the MSRV**: The MSRV may be bumped when: + +- a dependency requires a newer version of Rust; or +- a newer Rust feature offers a clear maintenance, correctness, or performance benefit. + +MSRV bumps must be documented in the release notes and must not occur in patch releases. + ### Testing #### Test at the boundaries (test features, not code) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ad8dcfb98f..ee30a1449d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "ab_glyph" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e074464580a518d16a7126262fffaaa47af89d4099d4cb403f8ed938ba12ee7d" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" dependencies = [ "ab_glyph_rasterizer", "owned_ttf_parser", @@ -14,17 +14,19 @@ dependencies = [ [[package]] name = "ab_glyph_rasterizer" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2187590a23ab1e3df8681afdf0987c48504d80291f002fcdb651f0ef5e25169" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] -name = "addr2line" -version = "0.24.2" +name = "addchain" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "2e33f6a175ec6a9e0aca777567f9ff7c3deefc255660df887e7fa3585e9801d8" dependencies = [ - "gimli", + "num-bigint 0.3.3", + "num-integer", + "num-traits", ] [[package]] @@ -35,30 +37,30 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aead" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common", - "generic-array", + "crypto-common 0.2.2", + "inout", ] [[package]] name = "aes" -version = "0.8.4" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ - "cfg-if", "cipher", - "cpufeatures", + "cpubits", + "cpufeatures 0.3.0", ] [[package]] name = "aes-gcm" -version = "0.10.3" +version = "0.11.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +checksum = "da8c919c118108f144adecad74b425b804ad075580d605d9b33c2d6d1c62a2f8" dependencies = [ "aead", "aes", @@ -70,11 +72,12 @@ dependencies = [ [[package]] name = "aes-kw" -version = "0.2.1" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" +checksum = "41ac571010bd60765c56085a4f1d412012a9be2663b1a2f2b19b49318653fd0d" dependencies = [ "aes", + "const-oid 0.10.2", ] [[package]] @@ -84,7 +87,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -92,30 +95,39 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "alsa" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags 2.9.3", + "bitflags 2.13.0", "cfg-if", "libc", ] [[package]] name = "alsa-sys" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" dependencies = [ "libc", "pkg-config", @@ -123,23 +135,21 @@ dependencies = [ [[package]] name = "android-activity" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.9.3", + "bitflags 2.13.0", "cc", - "cesu8", - "jni", - "jni-sys", + "jni 0.22.4", "libc", "log", "ndk", "ndk-context", "ndk-sys", "num_enum", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] @@ -148,12 +158,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -171,9 +175,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.19" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -186,44 +190,44 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arbitrary" @@ -248,9 +252,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "as-raw-xcb-connection" @@ -260,9 +264,9 @@ checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" [[package]] name = "asn1-rs" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -270,7 +274,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.16", + "thiserror 2.0.18", ] [[package]] @@ -281,7 +285,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -293,7 +297,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -323,7 +327,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -334,37 +338,35 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] -name = "atomic-waker" -version = "1.1.2" +name = "atomic-polyfill" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] [[package]] -name = "audiopus_sys" -version = "0.2.2" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" -dependencies = [ - "cmake", - "log", - "pkg-config", -] +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.13.3" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c953fe1ba023e6b7730c0d4b031d06f267f23a46167dcbd40316644b10a17ba" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "zeroize", @@ -372,37 +374,22 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.30.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbfd150b5dbdb988bcc8fb1fe787eb6b7ee6180ca24da683b61ea5405f3d43ff" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ - "bindgen", "cc", "cmake", "dunce", "fs_extra", -] - -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", + "pkg-config", ] [[package]] name = "base16ct" -version = "0.2.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" [[package]] name = "base64" @@ -412,9 +399,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "benches" @@ -430,29 +417,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "bindgen" -version = "0.69.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" -dependencies = [ - "bitflags 2.9.3", - "cexpr", - "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex", - "syn", - "which", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -470,9 +434,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bit_field" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitflags" @@ -482,15 +446,18 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.3" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "arbitrary", +] [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -507,13 +474,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" -version = "0.3.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -525,6 +501,15 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + [[package]] name = "bmp" version = "0.5.0" @@ -536,28 +521,28 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.23.2" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.1" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f154e572231cb6ba2bd1176980827e3d5dc04cc183a75dea38109fbdd672d29" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -574,15 +559,15 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.10.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytesize" -version = "2.0.1" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3c8f83209414aacf0eeae3cf730b18d6981697fba62f200fcfb92b9f082acba" +checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e" [[package]] name = "calloop" @@ -590,7 +575,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "log", "polling", "rustix 0.38.44", @@ -618,19 +603,20 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cbc" -version = "0.1.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ "cipher", ] [[package]] name = "cc" -version = "1.2.30" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -642,20 +628,11 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -663,13 +640,23 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", @@ -706,30 +693,20 @@ dependencies = [ [[package]] name = "cipher" -version = "0.4.4" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common", + "block-buffer 0.12.1", + "crypto-common 0.2.2", "inout", ] -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "clap" -version = "4.5.45" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -737,9 +714,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.44" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -749,36 +726,42 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.45" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "clap_lex" -version = "0.7.5" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" @@ -815,6 +798,21 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -849,20 +847,7 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types 0.5.0", - "libc", -] - -[[package]] -name = "core-graphics" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" -dependencies = [ - "bitflags 2.9.3", - "core-foundation 0.10.1", - "core-graphics-types 0.2.0", + "core-graphics-types", "foreign-types 0.5.0", "libc", ] @@ -878,24 +863,13 @@ dependencies = [ "libc", ] -[[package]] -name = "core-graphics-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" -dependencies = [ - "bitflags 2.9.3", - "core-foundation 0.10.1", - "libc", -] - [[package]] name = "coreaudio-rs" -version = "0.13.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.0", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -905,14 +879,14 @@ dependencies = [ [[package]] name = "cpal" -version = "0.16.0" +version = "0.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" dependencies = [ "alsa", "coreaudio-rs", "dasp_sample", - "jni", + "jni 0.21.1", "js-sys", "libc", "mach2", @@ -920,15 +894,25 @@ dependencies = [ "ndk-context", "num-derive", "num-traits", + "objc2 0.6.4", "objc2-audio-toolbox", + "objc2-avf-audio", "objc2-core-audio", "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.54.0", + "windows", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -938,6 +922,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -949,18 +942,20 @@ dependencies = [ [[package]] name = "criterion" -version = "0.7.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ + "alloca", "anes", "cast", "ciborium", "clap", "criterion-plot", - "itertools 0.13.0", + "itertools", "num-traits", "oorandom", + "page_size", "plotters", "rayon", "regex", @@ -972,19 +967,25 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.6.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", - "itertools 0.13.0", + "itertools", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -992,30 +993,32 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" 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 = "crossterm" -version = "0.25.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.0", "crossterm_winapi", - "libc", - "mio 0.8.11", + "derive_more", + "document-features", + "mio", "parking_lot", + "rustix 1.1.4", "signal-hook", "signal-hook-mio", "winapi", @@ -1037,35 +1040,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] -name = "crypto" -version = "0.5.1" +name = "crypto-bigint" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf1e6e5492f8f0830c37f301f6349e0dac8b2466e4fe89eef90e9eef906cd046" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ - "crypto-common", + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "serdect", + "subtle", + "zeroize", ] [[package]] -name = "crypto-bigint" -version = "0.5.5" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", + "typenum", ] [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "generic-array", - "rand_core 0.6.4", - "typenum", + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1079,20 +1088,65 @@ dependencies = [ ] [[package]] -name = "ctor-lite" -version = "0.1.0" +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core 0.10.1", +] + +[[package]] +name = "cryptoki" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff765b99fc49f3116c9a908484486a2b92fd73c48da45c3a69716471c6cc56c6" +dependencies = [ + "bitflags 2.13.0", + "cryptoki-sys", + "libloading", + "log", + "secrecy", +] + +[[package]] +name = "cryptoki-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fd850498411e4057f1cba79e6e2bc7cbe960544c1046ab46d4685c403a1121" +dependencies = [ + "libloading", +] + +[[package]] +name = "ctor" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f791803201ab277ace03903de1594460708d2d54df6053f2d9e82f592b19e3b" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] [[package]] name = "ctr" -version = "0.9.2" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -1101,14 +1155,14 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] name = "curve25519-dalek" -version = "4.1.3" +version = "5.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +checksum = "c906a87e53a36ff795d72e06e8162a83c5436e3ea89e942a9cb9fc083f0a384f" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "curve25519-dalek-derive", - "digest", + "digest 0.11.3", "fiat-crypto", "rustc_version", "subtle", @@ -1123,7 +1177,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1134,9 +1188,9 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "der" @@ -1144,16 +1198,27 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der_derive", "flagset", - "pem-rfc7468", + "pem-rfc7468 0.7.0", "zeroize", ] [[package]] -name = "der-parser" -version = "10.0.0" +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ @@ -1172,34 +1237,53 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ - "powerfmt", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] -name = "derive_arbitrary" -version = "1.4.1" +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", - "syn", + "rustc_version", + "syn 2.0.118", ] [[package]] name = "des" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" dependencies = [ "cipher", ] @@ -1216,22 +1300,31 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", ] [[package]] name = "diplomat" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31672b3ebc3c7866c3c98726f7a9a5ac8f13962e77d3c8225f6be49a7b8c5f2" +source = "git+https://github.com/CBenoit/diplomat?rev=6dc806e80162b6b39509a04a2835744236cd2396#6dc806e80162b6b39509a04a2835744236cd2396" dependencies = [ "diplomat_core", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1243,8 +1336,7 @@ checksum = "f7b0f23d549a46540e26e5490cd44c64ced0d762959f1ffdec6ab0399634cf3c" [[package]] name = "diplomat_core" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfaa5e13e8b8735d2338f2836c06cd8643902ab87dda1dd07dbb351998ddc127" +source = "git+https://github.com/CBenoit/diplomat?rev=6dc806e80162b6b39509a04a2835744236cd2396#6dc806e80162b6b39509a04a2835744236cd2396" dependencies = [ "lazy_static", "proc-macro2", @@ -1252,7 +1344,7 @@ dependencies = [ "serde", "smallvec", "strck_ident", - "syn", + "syn 2.0.118", ] [[package]] @@ -1263,40 +1355,49 @@ 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.9.3", - "objc2 0.6.1", + "bitflags 2.13.0", + "objc2 0.6.4", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "dissimilar" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8975ffdaa0ef3661bfe02dbdcc06c9f829dfafe6a3c474de366a8d5e44276921" +checksum = "aeda16ab4059c5fd2a83f2b9c9e9c981327b18aa8e3b313f7e6563799d4f093e" [[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", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1311,25 +1412,26 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "drm" -version = "0.12.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98888c4bbd601524c11a7ed63f814b8825f420514f78e96f752c437ae9cbb5d1" +checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "bytemuck", "drm-ffi", "drm-fourcc", + "libc", "rustix 0.38.44", ] [[package]] name = "drm-ffi" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97c98727e48b7ccb4f4aea8cfe881e5b07f702d17b7875991881b41af7278d53" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" dependencies = [ "drm-sys", - "rustix 0.38.44", + "rustix 1.1.4", ] [[package]] @@ -1340,14 +1442,20 @@ checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" [[package]] name = "drm-sys" -version = "0.7.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd39dde40b6e196c2e8763f23d119ddb1a8714534bf7d77fa97a65b0feda3986" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" dependencies = [ "libc", - "linux-raw-sys 0.6.5", + "linux-raw-sys 0.9.4", ] +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + [[package]] name = "dunce" version = "1.0.5" @@ -1356,71 +1464,71 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "dyn-clone" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "ecdsa" -version = "0.16.9" +version = "0.17.0-rc.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +checksum = "b7c72d1455753a703ad4b90ed2a759f2bc4562024a303176439cf6e593b5ade4" dependencies = [ - "der", - "digest", + "der 0.8.1", + "digest 0.11.3", "elliptic-curve", "rfc6979", "signature", - "spki", + "spki 0.8.0", + "zeroize", ] [[package]] name = "ed25519" -version = "2.2.3" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8", "signature", ] [[package]] name = "ed25519-dalek" -version = "2.2.0" +version = "3.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +checksum = "1685663e23882cd8517dcbcb1c23a6ebff4433c22dfb681d760219b62cd1b849" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core 0.6.4", - "serde", - "sha2", + "rand_core 0.10.1", + "sha2 0.11.0", "subtle", "zeroize", ] [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "elliptic-curve" -version = "0.13.8" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "crypto-common 0.2.2", + "digest 0.11.3", "ff", - "generic-array", "group", "hkdf", - "pem-rfc7468", + "hybrid-array", + "pem-rfc7468 1.0.0", "pkcs8", - "rand_core 0.6.4", + "rand_core 0.10.1", "sec1", "subtle", "zeroize", @@ -1428,28 +1536,16 @@ dependencies = [ [[package]] name = "embed-resource" -version = "3.0.5" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6d81016d6c977deefb2ef8d8290da019e27cc26167e102185da528e6c0ab38" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" dependencies = [ "cc", "memchr", "rustc_version", "toml", "vswhom", - "winreg 0.55.0", -] - -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", + "winreg", ] [[package]] @@ -1460,12 +1556,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1480,9 +1576,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fdeflate" @@ -1495,11 +1591,11 @@ dependencies = [ [[package]] name = "ff" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "rand_core 0.6.4", + "rand_core 0.10.1", "subtle", ] @@ -1507,23 +1603,32 @@ dependencies = [ name = "ffi" version = "0.0.0" dependencies = [ + "anyhow", "diplomat", "diplomat-runtime", "embed-resource", "ironrdp", "ironrdp-cliprdr-native", "ironrdp-core", + "ironrdp-dvc-pipe-proxy", + "ironrdp-rdcleanpath", "sspi", - "thiserror 2.0.16", + "thiserror 2.0.18", "tracing", "tracing-subscriber", ] [[package]] name = "fiat-crypto" -version = "0.2.9" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flagset" @@ -1533,11 +1638,12 @@ checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" [[package]] name = "flate2" -version = "1.1.2" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", + "libz-sys", "miniz_oxide", ] @@ -1574,7 +1680,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1612,9 +1718,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1627,9 +1733,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1637,15 +1743,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1654,44 +1760,44 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -1701,7 +1807,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1714,15 +1819,6 @@ dependencies = [ "thread_local", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -1731,24 +1827,23 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] name = "gethostname" -version = "0.4.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "libc", - "windows-targets 0.48.5", + "rustix 1.1.4", + "windows-link", ] [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1759,45 +1854,52 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "r-efi 5.3.0", + "wasip2", "wasm-bindgen", ] [[package]] -name = "ghash" -version = "0.5.1" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ - "opaque-debug", - "polyval", + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] -name = "gimli" -version = "0.31.1" +name = "ghash" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", +] [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "gloo-net" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +checksum = "a6420f887c48417e9e86c6cf61274eb231830cccc100e49613f7952e269a1fe1" dependencies = [ "futures-channel", "futures-core", @@ -1807,7 +1909,7 @@ dependencies = [ "http", "js-sys", "pin-project", - "thiserror 1.0.69", + "thiserror 2.0.18", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -1815,9 +1917,9 @@ dependencies = [ [[package]] name = "gloo-timers" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" dependencies = [ "futures-channel", "futures-core", @@ -1827,9 +1929,9 @@ dependencies = [ [[package]] name = "gloo-utils" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +checksum = "4202275d95a142fa209a1e35e91c250a710c5600731372cd3464a39ed01573d6" dependencies = [ "js-sys", "wasm-bindgen", @@ -1838,20 +1940,20 @@ dependencies = [ [[package]] name = "group" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ "ff", - "rand_core 0.6.4", + "rand_core 0.10.1", "subtle", ] [[package]] name = "h2" -version = "0.4.11" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -1868,19 +1970,42 @@ dependencies = [ [[package]] name = "half" -version = "2.6.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", ] [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "spin", + "stable_deref_trait", +] [[package]] name = "heck" @@ -1900,86 +2025,31 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hickory-proto" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", - "idna", - "ipnet", - "once_cell", - "rand 0.8.5", - "thiserror 1.0.69", - "tinyvec", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "hickory-resolver" -version = "0.24.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-proto", - "ipconfig", - "lru-cache", - "once_cell", - "parking_lot", - "rand 0.8.5", - "resolv-conf", - "smallvec", - "thiserror 1.0.69", - "tokio", - "tracing", -] - [[package]] name = "hkdf" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ "hmac", ] [[package]] name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.11" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "windows-sys 0.59.0", + "digest 0.11.3", ] [[package]] name = "http" -version = "1.3.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -2012,11 +2082,22 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + [[package]] name = "hyper" -version = "1.7.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -2028,7 +2109,6 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -2036,15 +2116,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", @@ -2069,14 +2148,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.16" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -2085,7 +2163,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.0", + "socket2", "system-configuration", "tokio", "tower-service", @@ -2095,9 +2173,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -2105,7 +2183,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -2119,12 +2197,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -2132,9 +2211,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -2145,11 +2224,10 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -2160,42 +2238,38 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", - "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -2216,9 +2290,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", @@ -2226,21 +2300,22 @@ dependencies = [ [[package]] name = "image" -version = "0.25.6" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "moxcms", "num-traits", "png", ] [[package]] name = "indexmap" -version = "2.10.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown", @@ -2248,73 +2323,37 @@ dependencies = [ [[package]] name = "inout" -version = "0.1.4" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ "block-padding", - "generic-array", + "hybrid-array", ] [[package]] name = "inquire" -version = "0.7.5" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "crossterm", "dyn-clone", "fuzzy-matcher", - "fxhash", - "newline-converter", - "once_cell", "unicode-segmentation", "unicode-width", ] [[package]] -name = "io-uring" -version = "0.7.9" +name = "ipnet" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" -dependencies = [ - "bitflags 2.9.3", - "cfg-if", - "libc", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] -name = "ipconfig" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" -dependencies = [ - "socket2 0.5.10", - "widestring", - "windows-sys 0.48.0", - "winreg 0.50.0", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "iron-remote-desktop" -version = "0.4.0" +name = "iron-remote-desktop" +version = "0.7.1" dependencies = [ "console_error_panic_hook", "tracing", @@ -2326,30 +2365,33 @@ dependencies = [ [[package]] name = "ironrdp" -version = "0.11.0" +version = "0.17.0" dependencies = [ "anyhow", "async-trait", "image", "ironrdp-acceptor", "ironrdp-blocking", + "ironrdp-client", "ironrdp-cliprdr", "ironrdp-cliprdr-native", "ironrdp-connector", "ironrdp-core", "ironrdp-displaycontrol", "ironrdp-dvc", + "ironrdp-echo", "ironrdp-graphics", "ironrdp-input", + "ironrdp-mstsgu", "ironrdp-pdu", "ironrdp-rdpdr", "ironrdp-rdpsnd", "ironrdp-server", "ironrdp-session", "ironrdp-svc", - "opus", + "opus2", "pico-args", - "rand 0.9.2", + "rand 0.9.4", "sspi", "tokio-rustls", "tracing", @@ -2359,7 +2401,7 @@ dependencies = [ [[package]] name = "ironrdp-acceptor" -version = "0.6.0" +version = "0.10.0" dependencies = [ "ironrdp-async", "ironrdp-connector", @@ -2369,11 +2411,32 @@ dependencies = [ "tracing", ] +[[package]] +name = "ironrdp-agent" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "ironrdp-cfg", + "ironrdp-client", + "ironrdp-core", + "ironrdp-input", + "ironrdp-pdu", + "ironrdp-propertyset", + "ironrdp-rdpfile", + "libc", + "png", + "tokio", + "tracing", + "tracing-subscriber", + "whoami", +] + [[package]] name = "ironrdp-ainput" -version = "0.3.0" +version = "0.8.0" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "ironrdp-core", "ironrdp-dvc", "num-derive", @@ -2382,7 +2445,7 @@ dependencies = [ [[package]] name = "ironrdp-async" -version = "0.6.0" +version = "0.10.0" dependencies = [ "bytes", "ironrdp-connector", @@ -2403,7 +2466,7 @@ dependencies = [ [[package]] name = "ironrdp-blocking" -version = "0.6.0" +version = "0.10.0" dependencies = [ "bytes", "ironrdp-connector", @@ -2412,6 +2475,13 @@ dependencies = [ "tracing", ] +[[package]] +name = "ironrdp-bulk" +version = "0.1.1" +dependencies = [ + "criterion", +] + [[package]] name = "ironrdp-cfg" version = "0.1.0" @@ -2424,54 +2494,52 @@ name = "ironrdp-client" version = "0.1.0" dependencies = [ "anyhow", - "clap", "futures-util", - "inquire", - "ironrdp", "ironrdp-cfg", + "ironrdp-cliprdr", "ironrdp-cliprdr-native", + "ironrdp-connector", "ironrdp-core", + "ironrdp-displaycontrol", + "ironrdp-dvc", + "ironrdp-dvc-com-plugin", "ironrdp-dvc-pipe-proxy", + "ironrdp-echo", + "ironrdp-graphics", "ironrdp-mstsgu", + "ironrdp-pdu", "ironrdp-propertyset", "ironrdp-rdcleanpath", - "ironrdp-rdpfile", + "ironrdp-rdpdr", + "ironrdp-rdpsnd", "ironrdp-rdpsnd-native", + "ironrdp-session", + "ironrdp-svc", "ironrdp-tls", "ironrdp-tokio", - "proc-exit", - "raw-window-handle", - "semver", "smallvec", - "softbuffer", - "tap", "tokio", "tokio-tungstenite", - "tokio-util", "tracing", - "tracing-subscriber", "url", - "uuid", - "whoami", - "windows 0.61.3", - "winit", "x509-cert", ] [[package]] name = "ironrdp-cliprdr" -version = "0.3.0" +version = "0.7.0" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", "tracing", + "visibility", ] [[package]] name = "ironrdp-cliprdr-format" -version = "0.1.3" +version = "0.2.0" dependencies = [ "ironrdp-core", "png", @@ -2479,19 +2547,18 @@ dependencies = [ [[package]] name = "ironrdp-cliprdr-native" -version = "0.3.0" +version = "0.7.0" dependencies = [ "ironrdp-cliprdr", "ironrdp-core", "tracing", - "windows 0.61.3", + "windows", ] [[package]] name = "ironrdp-connector" -version = "0.6.0" +version = "0.10.0" dependencies = [ - "arbitrary", "ironrdp-core", "ironrdp-error", "ironrdp-pdu", @@ -2499,7 +2566,7 @@ dependencies = [ "picky", "picky-asn1-der", "picky-asn1-x509", - "rand 0.9.2", + "rand 0.9.4", "sspi", "tracing", "url", @@ -2507,14 +2574,14 @@ dependencies = [ [[package]] name = "ironrdp-core" -version = "0.1.5" +version = "0.2.1" dependencies = [ "ironrdp-error", ] [[package]] name = "ironrdp-displaycontrol" -version = "0.3.0" +version = "0.8.0" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -2525,18 +2592,30 @@ dependencies = [ [[package]] name = "ironrdp-dvc" -version = "0.3.1" +version = "0.8.0" dependencies = [ "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", - "slab", "tracing", ] +[[package]] +name = "ironrdp-dvc-com-plugin" +version = "0.1.3" +dependencies = [ + "ironrdp-core", + "ironrdp-dvc", + "ironrdp-pdu", + "ironrdp-svc", + "tracing", + "windows", + "windows-core", +] + [[package]] name = "ironrdp-dvc-pipe-proxy" -version = "0.1.0" +version = "0.5.0" dependencies = [ "async-trait", "ironrdp-core", @@ -2547,15 +2626,39 @@ dependencies = [ "tracing", ] +[[package]] +name = "ironrdp-echo" +version = "0.4.0" +dependencies = [ + "ironrdp-core", + "ironrdp-dvc", + "ironrdp-pdu", + "tracing", +] + +[[package]] +name = "ironrdp-egfx" +version = "0.3.0" +dependencies = [ + "arbitrary", + "bit_field", + "bitflags 2.13.0", + "ironrdp-core", + "ironrdp-dvc", + "ironrdp-graphics", + "ironrdp-pdu", + "openh264", + "tracing", +] + [[package]] name = "ironrdp-error" -version = "0.1.3" +version = "0.2.0" [[package]] name = "ironrdp-futures" -version = "0.4.0" +version = "0.8.0" dependencies = [ - "bytes", "futures-util", "ironrdp-async", ] @@ -2565,10 +2668,12 @@ name = "ironrdp-fuzzing" version = "0.0.0" dependencies = [ "arbitrary", + "ironrdp-bulk", "ironrdp-cliprdr", "ironrdp-cliprdr-format", "ironrdp-core", "ironrdp-displaycontrol", + "ironrdp-egfx", "ironrdp-graphics", "ironrdp-pdu", "ironrdp-rdpdr", @@ -2578,10 +2683,10 @@ dependencies = [ [[package]] name = "ironrdp-graphics" -version = "0.4.1" +version = "0.9.0" dependencies = [ "bit_field", - "bitflags 2.9.3", + "bitflags 2.13.0", "bitvec", "bmp", "bytemuck", @@ -2589,7 +2694,6 @@ dependencies = [ "expect-test", "ironrdp-core", "ironrdp-pdu", - "lazy_static", "num-derive", "num-traits", "yuv", @@ -2597,7 +2701,7 @@ dependencies = [ [[package]] name = "ironrdp-input" -version = "0.3.0" +version = "0.7.0" dependencies = [ "bitvec", "ironrdp-pdu", @@ -2609,7 +2713,7 @@ name = "ironrdp-mstsgu" version = "0.0.1" dependencies = [ "base64", - "bitflags 2.9.3", + "bitflags 2.13.0", "futures-util", "http-body-util", "hyper", @@ -2624,27 +2728,33 @@ dependencies = [ "uuid", ] +[[package]] +name = "ironrdp-nscodec" +version = "0.2.0" +dependencies = [ + "ironrdp-graphics", +] + [[package]] name = "ironrdp-pdu" -version = "0.5.0" +version = "0.9.0" dependencies = [ + "arbitrary", "bit_field", - "bitflags 2.9.3", + "bitflags 2.13.0", "byteorder", "der-parser", "expect-test", "ironrdp-core", "ironrdp-error", - "lazy_static", - "md-5", - "num-bigint", + "md-5 0.10.6", + "num-bigint 0.4.8", "num-derive", "num-integer", "num-traits", - "pkcs1", - "sha1", + "pkcs1 0.7.5", + "sha1 0.10.7", "tap", - "thiserror 2.0.16", "x509-cert", ] @@ -2655,22 +2765,19 @@ version = "0.0.0" [[package]] name = "ironrdp-propertyset" version = "0.1.0" -dependencies = [ - "tracing", -] [[package]] name = "ironrdp-rdcleanpath" -version = "0.1.3" +version = "0.2.2" dependencies = [ - "der", + "der 0.7.10", ] [[package]] name = "ironrdp-rdpdr" -version = "0.3.0" +version = "0.7.0" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "ironrdp-core", "ironrdp-error", "ironrdp-pdu", @@ -2680,7 +2787,7 @@ dependencies = [ [[package]] name = "ironrdp-rdpdr-native" -version = "0.3.0" +version = "0.7.0" dependencies = [ "ironrdp-core", "ironrdp-pdu", @@ -2690,6 +2797,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "ironrdp-rdpeusb" +version = "0.1.0" +dependencies = [ + "ironrdp-core", + "ironrdp-dvc", + "ironrdp-pdu", + "ironrdp-str", +] + [[package]] name = "ironrdp-rdpfile" version = "0.1.0" @@ -2699,31 +2816,33 @@ dependencies = [ [[package]] name = "ironrdp-rdpsnd" -version = "0.5.0" +version = "0.9.0" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", "tracing", + "visibility", ] [[package]] name = "ironrdp-rdpsnd-native" -version = "0.3.1" +version = "0.7.0" dependencies = [ "anyhow", "bytemuck", "cpal", + "ironrdp-error", "ironrdp-rdpsnd", - "opus", + "opus2", "tracing", "tracing-subscriber", ] [[package]] name = "ironrdp-server" -version = "0.7.0" +version = "0.13.0" dependencies = [ "anyhow", "async-trait", @@ -2735,7 +2854,10 @@ dependencies = [ "ironrdp-core", "ironrdp-displaycontrol", "ironrdp-dvc", + "ironrdp-echo", + "ironrdp-egfx", "ironrdp-graphics", + "ironrdp-nscodec", "ironrdp-pdu", "ironrdp-rdpsnd", "ironrdp-svc", @@ -2753,9 +2875,9 @@ dependencies = [ [[package]] name = "ironrdp-session" -version = "0.5.0" +version = "0.11.0" dependencies = [ - "ironrdp-connector", + "ironrdp-bulk", "ironrdp-core", "ironrdp-displaycontrol", "ironrdp-dvc", @@ -2772,11 +2894,19 @@ dependencies = [ name = "ironrdp-session-generators" version = "0.0.0" +[[package]] +name = "ironrdp-str" +version = "0.1.1" +dependencies = [ + "bytemuck", + "ironrdp-core", +] + [[package]] name = "ironrdp-svc" -version = "0.4.1" +version = "0.8.0" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "ironrdp-core", "ironrdp-pdu", ] @@ -2787,29 +2917,40 @@ version = "0.0.0" dependencies = [ "anyhow", "array-concat", + "async-trait", "expect-test", "hex", + "ironrdp-acceptor", + "ironrdp-cfg", "ironrdp-cliprdr", "ironrdp-cliprdr-format", "ironrdp-connector", "ironrdp-core", "ironrdp-displaycontrol", "ironrdp-dvc", + "ironrdp-echo", + "ironrdp-egfx", "ironrdp-fuzzing", "ironrdp-graphics", "ironrdp-input", "ironrdp-pdu", "ironrdp-propertyset", "ironrdp-rdcleanpath", + "ironrdp-rdpdr", + "ironrdp-rdpeusb", "ironrdp-rdpfile", "ironrdp-rdpsnd", + "ironrdp-server", "ironrdp-session", - "lazy_static", + "ironrdp-str", + "ironrdp-svc", + "openh264", "paste", "png", "pretty_assertions", "proptest", "rstest", + "tokio", "visibility", ] @@ -2820,18 +2961,27 @@ dependencies = [ "anyhow", "async-trait", "ironrdp", + "ironrdp-agent", "ironrdp-async", + "ironrdp-client", + "ironrdp-core", + "ironrdp-dvc", + "ironrdp-dvc-pipe-proxy", + "ironrdp-input", + "ironrdp-propertyset", "ironrdp-tls", "ironrdp-tokio", + "ironrdp-viewer", "semver", "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] name = "ironrdp-tls" -version = "0.1.3" +version = "0.2.2" dependencies = [ "tokio", "tokio-native-tls", @@ -2841,17 +2991,40 @@ dependencies = [ [[package]] name = "ironrdp-tokio" -version = "0.6.0" +version = "0.10.0" dependencies = [ - "bytes", "ironrdp-async", "ironrdp-connector", "reqwest", - "sspi", "tokio", "url", ] +[[package]] +name = "ironrdp-viewer" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "inquire", + "ironrdp", + "ironrdp-cfg", + "ironrdp-propertyset", + "ironrdp-rdpfile", + "proc-exit", + "raw-window-handle", + "semver", + "smallvec", + "softbuffer", + "tap", + "tokio", + "tracing", + "tracing-subscriber", + "url", + "whoami", + "winit", +] + [[package]] name = "ironrdp-web" version = "0.0.0" @@ -2861,8 +3034,9 @@ dependencies = [ "chrono", "futures-channel", "futures-util", - "getrandom 0.2.16", - "getrandom 0.3.3", + "getrandom 0.2.17", + "getrandom 0.3.4", + "getrandom 0.4.3", "gloo-net", "gloo-timers", "iron-remote-desktop", @@ -2870,16 +3044,17 @@ dependencies = [ "ironrdp-cliprdr-format", "ironrdp-core", "ironrdp-futures", + "ironrdp-pdu", "ironrdp-propertyset", "ironrdp-rdcleanpath", "ironrdp-rdpfile", + "ironrdp-svc", "js-sys", "png", "resize", "rgb", "semver", "smallvec", - "softbuffer", "tap", "time", "tracing", @@ -2892,17 +3067,26 @@ dependencies = [ [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] -name = "itertools" -version = "0.12.1" +name = "iso7816" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "cd3c7e91da489667bb054f9cd2f1c60cc2ac4478a899f403d11dbc62189215b0" dependencies = [ - "either", + "heapless", +] + +[[package]] +name = "iso7816-tlv" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7660d28d24a831d690228a275d544654a30f3b167a8e491cf31af5fe5058b546" +dependencies = [ + "untrusted", ] [[package]] @@ -2916,9 +3100,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" @@ -2929,7 +3113,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -2937,38 +3121,92 @@ dependencies = [ ] [[package]] -name = "jni-sys" -version = "0.3.0" +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] [[package]] name = "jobserver" -version = "0.1.33" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] [[package]] name = "keccak" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" dependencies = [ - "cpufeatures", + "cfg-if", + "cpufeatures 0.3.0", ] [[package]] @@ -2976,54 +3214,56 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" [[package]] name = "libc" -version = "0.2.174" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-targets 0.53.2", + "windows-link", ] [[package]] -name = "libm" -version = "0.2.15" +name = "libopus_sys" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b81c32f233fb2507347a93f97b9919493be9db7ec2249ce2c3ed0c89ad8edf60" +dependencies = [ + "cmake", + "log", + "pkg-config", +] [[package]] name = "libredox" -version = "0.1.6" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "libc", - "redox_syscall 0.5.15", + "plain", + "redox_syscall 0.9.0", ] [[package]] -name = "linked-hash-map" -version = "0.5.6" +name = "libz-sys" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] [[package]] name = "linux-raw-sys" @@ -3033,46 +3273,42 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.6.5" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a385b1be4e5c3e362ad2ffa73c392e53f031eaa5b7d648e64cd87f27f6063d7" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" - -[[package]] -name = "lru-cache" -version = "0.1.2" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" -dependencies = [ - "linked-hash-map", -] +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru-slab" @@ -3082,20 +3318,20 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "mach2" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" dependencies = [ "libc", ] [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -3105,7 +3341,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", ] [[package]] @@ -3114,20 +3360,20 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da5ac363534dce5fabf69949225e174fbf111a498bf0ff794c8ea1fba9f3dda" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] name = "memchr" -version = "2.7.5" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.7" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "483758ad303d734cec05e5c12b41d7e93e6a6390c5e9dae6bdeb7c1259012d28" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -3150,32 +3396,40 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.11" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] -name = "mio" -version = "1.0.4" +name = "moxcms" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "num-traits", + "pxfm", +] + +[[package]] +name = "nasm-rs" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706bf8a5e8c8ddb99128c3291d31bd21f4bcde17f0f4c20ec678d85c74faa149" +dependencies = [ + "log", ] [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", @@ -3183,7 +3437,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] @@ -3194,8 +3448,8 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.9.3", - "jni-sys", + "bitflags 2.13.0", + "jni-sys 0.3.1", "log", "ndk-sys", "num_enum", @@ -3215,25 +3469,16 @@ version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", -] - -[[package]] -name = "newline-converter" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" -dependencies = [ - "unicode-segmentation", + "jni-sys 0.3.1", ] [[package]] name = "nix" -version = "0.30.1" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -3251,47 +3496,39 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "overload", - "winapi", + "windows-sys 0.61.2", ] [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "5f6f7833f2cbf2360a6cfd58cd41a53aa7a90bd4c202f5b1c7dd2ed73c57b2c3" dependencies = [ + "autocfg", "num-integer", "num-traits", ] [[package]] -name = "num-bigint-dig" -version = "0.8.4" +name = "num-bigint" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ - "byteorder", - "lazy_static", - "libm", "num-integer", - "num-iter", "num-traits", - "rand 0.8.5", - "serde", - "smallvec", - "zeroize", ] [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -3301,7 +3538,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3313,17 +3550,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -3331,14 +3557,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] name = "num_enum" -version = "0.7.4" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -3346,14 +3571,14 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.4" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3374,9 +3599,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.1" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -3387,29 +3612,39 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.9.3", - "block2", + "bitflags 2.13.0", + "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", "objc2-core-image", "objc2-foundation 0.2.2", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", ] [[package]] name = "objc2-audio-toolbox" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cbe18d879e20a4aea544f8befe38bcf52255eb63d3f23eca2842f3319e4c07" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "libc", - "objc2 0.6.1", + "objc2 0.6.4", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", ] [[package]] @@ -3418,8 +3653,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.9.3", - "block2", + "bitflags 2.13.0", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -3431,31 +3666,32 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] [[package]] name = "objc2-core-audio" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", - "objc2 0.6.1", + "objc2 0.6.4", "objc2-core-audio-types", "objc2-core-foundation", + "objc2-foundation 0.3.2", ] [[package]] name = "objc2-core-audio-types" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f1cc99bb07ad2ddb6527ddf83db6a15271bb036b3eb94b801cd44fdc666ee1" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.9.3", - "objc2 0.6.1", + "bitflags 2.13.0", + "objc2 0.6.4", ] [[package]] @@ -3464,21 +3700,36 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.9.3", - "block2", + "bitflags 2.13.0", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] [[package]] name = "objc2-core-foundation" -version = "0.3.1" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "block2 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "dispatch2", - "objc2 0.6.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", ] [[package]] @@ -3487,7 +3738,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", @@ -3499,7 +3750,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", "objc2-foundation 0.2.2", @@ -3517,8 +3768,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.9.3", - "block2", + "bitflags 2.13.0", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", @@ -3526,11 +3777,26 @@ dependencies = [ [[package]] name = "objc2-foundation" -version = "0.3.1" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "objc2 0.6.1", + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", ] [[package]] @@ -3539,7 +3805,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-app-kit", "objc2-foundation 0.2.2", @@ -3551,8 +3817,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.9.3", - "block2", + "bitflags 2.13.0", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3563,13 +3829,25 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.9.3", - "block2", + "bitflags 2.13.0", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", "objc2-metal", ] +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.0", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-symbols" version = "0.2.2" @@ -3580,14 +3858,23 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.9.3", - "block2", + "bitflags 2.13.0", + "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", @@ -3595,7 +3882,7 @@ dependencies = [ "objc2-core-location", "objc2-foundation 0.2.2", "objc2-link-presentation", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", "objc2-user-notifications", @@ -3607,7 +3894,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -3618,22 +3905,13 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.9.3", - "block2", + "bitflags 2.13.0", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "oid" version = "0.2.1" @@ -3645,15 +3923,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "oorandom" @@ -3662,22 +3940,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] -name = "opaque-debug" -version = "0.3.1" +name = "openh264" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b2b561d2103303e233779545da757ecd35bad82188d619a6d8901f3007e1ff" +dependencies = [ + "openh264-sys2", + "wide", +] + +[[package]] +name = "openh264-sys2" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +checksum = "75a8867e48183bbd9147380227448c065fe456eb30b0ebc68929809c36c30985" +dependencies = [ + "cc", + "libloading", + "nasm-rs", + "sha2 0.10.9", + "walkdir", +] [[package]] name = "openssl" -version = "0.10.73" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -3690,20 +3984,20 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.109" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -3712,82 +4006,89 @@ dependencies = [ ] [[package]] -name = "opus" -version = "0.3.0" +name = "opus2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6526409b274a7e98e55ff59d96aafd38e6cd34d46b7dbbc32ce126dffcd75e8e" +checksum = "49521e33fbf825d2abc8d696506c278cb8469d0c6cc05d3785bef5c5f7ee957b" dependencies = [ - "audiopus_sys", - "libc", + "libopus_sys", ] [[package]] name = "orbclient" -version = "0.3.48" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba0b26cec2e24f08ed8bb31519a9333140a6599b867dac464bb150bdb796fd43" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" dependencies = [ + "libc", "libredox", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "owned_ttf_parser" -version = "0.25.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec719bbf3b2a81c109a4e20b1f129b5566b7dce654bc3872f6a05abf82b2c4" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" dependencies = [ "ttf-parser", ] [[package]] name = "p256" -version = "0.13.2" +version = "0.14.0-rc.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +checksum = "c855a8d2ffd346aa03122626f22e96e3aa75e3bfe64e6bf6cb82f71821ed6ae7" dependencies = [ "ecdsa", "elliptic-curve", + "primefield", "primeorder", - "sha2", + "sha2 0.11.0", ] [[package]] name = "p384" -version = "0.13.1" +version = "0.14.0-rc.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +checksum = "62941b68907ddf996ac20f0debf700c236ccc3d874637731a93c631129ca042f" dependencies = [ "ecdsa", "elliptic-curve", + "fiat-crypto", + "primefield", "primeorder", - "sha2", + "sha2 0.11.0", ] [[package]] name = "p521" -version = "0.13.3" +version = "0.14.0-rc.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +checksum = "0dd6f2fe6e76c8d5e8828e92aafa463777d1e72e70b78acc724214757e92479a" dependencies = [ "base16ct", "ecdsa", "elliptic-curve", + "primefield", "primeorder", - "rand_core 0.6.4", - "sha2", + "sha2 0.11.0", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", ] [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -3795,15 +4096,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.15", + "redox_syscall 0.5.18", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -3814,13 +4115,12 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pbkdf2" -version = "0.12.2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest", + "digest 0.11.3", "hmac", - "sha1", ] [[package]] @@ -3832,6 +4132,15 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3840,23 +4149,28 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "picky" -version = "7.0.0-rc.17" +version = "7.0.0-rc.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33807ce79d4b14a8918e968a8606e5142ddc6aec933acef79de0bd769cae5fb1" +checksum = "c1ae9cd78eb1d61be4790713d28368cf71c844218fcd91542768805423f02666" dependencies = [ "aes", "aes-gcm", "aes-kw", "base64", "cbc", + "crypto-bigint", + "crypto-common 0.2.2", + "ctr", + "curve25519-dalek", "des", - "digest", + "digest 0.11.3", + "ecdsa", "ed25519-dalek", "hex", "hmac", "http", - "md-5", - "num-bigint-dig", + "inout", + "md-5 0.11.0", "p256", "p384", "p521", @@ -3864,16 +4178,21 @@ dependencies = [ "picky-asn1", "picky-asn1-der", "picky-asn1-x509", - "rand 0.8.5", - "rand_core 0.6.4", + "pkcs1 0.8.0-rc.4", + "primeorder", + "rand 0.10.2", + "rand_core 0.10.1", "rc2", "rsa", + "rustcrypto-ff", + "rustcrypto-ff_derive", + "rustcrypto-group", "serde", "serde_json", - "sha1", - "sha2", + "sha1 0.11.0", + "sha2 0.11.0", "sha3", - "thiserror 1.0.69", + "thiserror 2.0.18", "x25519-dalek", "zeroize", ] @@ -3893,9 +4212,9 @@ dependencies = [ [[package]] name = "picky-asn1-der" -version = "0.5.2" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dccb53c26f70c082e008818f524bd45d057069517b047bd0c0ee062d6d7d7f2" +checksum = "d413165e4bf7f808b9a27cbaba657657a2921f0965db833f488c4d4be96dcd2e" dependencies = [ "picky-asn1", "serde", @@ -3904,12 +4223,12 @@ dependencies = [ [[package]] name = "picky-asn1-x509" -version = "0.14.6" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d493f73cf052073ca1fe38666f74c2396987aa6ea660e77dd624cc6c8f60389e" +checksum = "859d4117bd1b1dc5646359ee7243c50c5000c0920ea2d1fb120335a2f4c684b8" dependencies = [ "base64", - "num-bigint-dig", + "crypto-bigint", "oid", "picky-asn1", "picky-asn1-der", @@ -3920,26 +4239,29 @@ dependencies = [ [[package]] name = "picky-krb" -version = "0.11.0" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45ffe5f2122cdda5e9059ab837a65ba1b77729db43fc1500f2fce6b27070eab" +checksum = "2d188f3192356068dbdba54bddbca6fd0f7a09565d3861eeb8efe1ab77ae8e97" dependencies = [ "aes", + "block-padding", "byteorder", "cbc", - "crypto", + "cipher", + "crypto-bigint", "des", "hmac", - "num-bigint-dig", + "inout", "oid", "pbkdf2", "picky-asn1", "picky-asn1-der", "picky-asn1-x509", - "rand 0.8.5", + "rand 0.10.2", + "rand_core 0.10.1", "serde", - "sha1", - "thiserror 1.0.69", + "sha1 0.11.0", + "thiserror 2.0.18", "uuid", ] @@ -3951,29 +4273,29 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[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" @@ -3987,26 +4309,41 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der 0.8.1", + "spki 0.8.0", ] [[package]] name = "pkcs8" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.1", + "spki 0.8.0", ] [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plotters" @@ -4038,11 +4375,11 @@ dependencies = [ [[package]] name = "png" -version = "0.17.16" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", @@ -4051,27 +4388,26 @@ dependencies = [ [[package]] name = "polling" -version = "3.9.0" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee9b2fa7a4517d2c91ff5bc6c297a427a96749d15f98fcdbb22c05571a4d4b7" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.0.8", - "windows-sys 0.60.2", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] name = "polyval" -version = "0.6.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", + "cpubits", + "cpufeatures 0.3.0", "universal-hash", ] @@ -4081,14 +4417,14 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be97d76faf1bfab666e1375477b23fde79eccf0276e9b63b92a39d676a889ba9" dependencies = [ - "rand 0.8.5", + "rand 0.8.6", ] [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -4119,22 +4455,29 @@ dependencies = [ ] [[package]] -name = "prettyplease" -version = "0.2.35" +name = "primefield" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ - "proc-macro2", - "syn", + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", ] [[package]] name = "primeorder" -version = "0.13.6" +version = "0.14.0-rc.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +checksum = "4e56e6d67fdf5744e9e245ae571450fe584b91f5af261d0e40163b618e53a1f6" dependencies = [ "elliptic-curve", + "once_cell", + "primefield", + "serdect", ] [[package]] @@ -4145,42 +4488,47 @@ checksum = "77a5390699eb9ac50677729fda96fb8339d4629f257cc6cfa6eaa673730f8f63" [[package]] name = "proc-macro-crate" -version = "3.3.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "proptest" -version = "1.7.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.9.3", - "lazy_static", + "bitflags 2.13.0", "num-traits", - "rand 0.9.2", + "rand 0.9.4", "rand_chacha 0.9.0", "rand_xorshift", - "regex-syntax 0.8.5", + "regex-syntax", "rusty-fork", "tempfile", "unarray", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "qoicoubeh" version = "0.5.0" @@ -4198,28 +4546,28 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", ] [[package]] name = "quinn" -version = "0.11.8" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.1", + "rustc-hash", "rustls", - "socket2 0.5.10", - "thiserror 2.0.16", + "socket2", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -4227,20 +4575,21 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.12" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", - "getrandom 0.3.3", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.2", + "rand 0.10.2", + "rand_pcg", "ring", - "rustc-hash 2.1.1", + "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.16", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -4248,23 +4597,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.13" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -4275,6 +4624,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" @@ -4283,9 +4638,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4294,12 +4649,23 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -4319,7 +4685,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -4328,16 +4694,31 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "getrandom 0.3.3", + "rand_core 0.10.1", ] [[package]] @@ -4346,7 +4727,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -4357,9 +4738,9 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] name = "rayon" -version = "1.10.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -4367,9 +4748,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -4377,9 +4758,9 @@ dependencies = [ [[package]] name = "rc2" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd" +checksum = "ceda21af1ae61033b63175653a1af86cae399d79cd03ca80ba347eb3a6c4a7fe" dependencies = [ "cipher", ] @@ -4395,56 +4776,50 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.15" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", ] [[package]] -name = "regex" -version = "1.11.1" +name = "redox_syscall" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "bitflags 2.13.0", ] [[package]] -name = "regex-automata" -version = "0.1.10" +name = "regex" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ - "regex-syntax 0.6.29", + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] [[package]] name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.8.5" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" @@ -4454,9 +4829,9 @@ checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] name = "reqwest" -version = "0.12.23" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", @@ -4498,34 +4873,28 @@ dependencies = [ [[package]] name = "resize" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87a103d0b47e783f4579149402f7499397ab25540c7a57b2f70487a5d2d20ef0" +checksum = "71725ecd5e0197b54fe859055b108688472ab6a358f8fbe5cee4a556b1b5bfea" dependencies = [ "rgb", ] -[[package]] -name = "resolv-conf" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95325155c684b1c89f7765e30bc1c42e4a6da51ca513615660cb8a62ef9a88e3" - [[package]] name = "rfc6979" -version = "0.4.0" +version = "0.6.0-pre.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +checksum = "9935425142ac6e252364413291d96c8bc9898d0876a801824c7af4eae397b689" dependencies = [ + "ctutils", "hmac", - "subtle", ] [[package]] name = "rgb" -version = "0.8.52" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" dependencies = [ "bytemuck", ] @@ -4538,7 +4907,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -4546,42 +4915,38 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.8" +version = "0.10.0-rc.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", + "const-oid 0.10.2", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", + "pkcs1 0.8.0-rc.4", "pkcs8", - "rand_core 0.6.4", - "sha1", + "rand_core 0.10.1", "signature", - "spki", - "subtle", + "spki 0.8.0", "zeroize", ] [[package]] name = "rstest" -version = "0.25.0" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" dependencies = [ "futures-timer", "futures-util", "rstest_macros", - "rustc_version", ] [[package]] name = "rstest_macros" -version = "0.25.0" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" dependencies = [ "cfg-if", "glob", @@ -4591,35 +4956,61 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn", + "syn 2.0.118", "unicode-ident", ] [[package]] -name = "rustc-demangle" -version = "0.1.25" +name = "rustc-hash" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] -name = "rustc-hash" -version = "1.1.0" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] -name = "rustc-hash" -version = "2.1.1" +name = "rustcrypto-ff" +version = "0.14.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "fd2a8adb347447693cd2ba0d218c4b66c62da9b0a5672b17b981e4291ec65ff6" +dependencies = [ + "bitvec", + "rand_core 0.10.1", + "rustcrypto-ff_derive", + "subtle", +] [[package]] -name = "rustc_version" -version = "0.4.1" +name = "rustcrypto-ff_derive" +version = "0.14.0-rc.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "4cda22ea03582974ab5687fc131eba2dc78e258e7eef4d7e01bcd0522ed79f66" dependencies = [ - "semver", + "addchain", + "num-bigint 0.3.3", + "num-integer", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rustcrypto-group" +version = "0.14.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "369f9b61aa45933c062c9f6b5c3c50ab710687eca83dd3802653b140b43f85ed" +dependencies = [ + "rand_core 0.10.1", + "rustcrypto-ff", + "subtle", ] [[package]] @@ -4637,7 +5028,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4646,22 +5037,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys 0.9.4", - "windows-sys 0.60.2", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.29" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "log", @@ -4675,14 +5066,14 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.1" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.2.0", + "security-framework", ] [[package]] @@ -4696,9 +5087,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -4706,9 +5097,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.4" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -4718,15 +5109,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", "quick-error", @@ -4736,9 +5127,18 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe_arch" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "1f7caad094bd561859bcd467734a720c3c1f5d1f338995351fefe2190c45efed" +dependencies = [ + "bytemuck", +] [[package]] name = "same-file" @@ -4751,11 +5151,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4785,38 +5185,34 @@ dependencies = [ [[package]] name = "sec1" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct", - "der", - "generic-array", - "pkcs8", + "ctutils", + "der 0.8.1", + "hybrid-array", "subtle", "zeroize", ] [[package]] -name = "security-framework" -version = "2.11.1" +name = "secrecy" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" dependencies = [ - "bitflags 2.9.3", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", + "zeroize", ] [[package]] name = "security-framework" -version = "3.2.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4825,9 +5221,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -4835,58 +5231,70 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.26" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] [[package]] name = "serde_bytes" -version = "0.11.17" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8437fd221bde2d4ca316d61b90e337e9e702b3820b87d63caa9ba6c02bd06d96" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" dependencies = [ "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.141" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] name = "serde_spanned" -version = "1.0.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -4901,15 +5309,36 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -4919,18 +5348,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] name = "sha3" -version = "0.10.8" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ - "digest", + "digest 0.11.3", "keccak", + "sponge-cursor", ] [[package]] @@ -4944,9 +5385,9 @@ dependencies = [ [[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" @@ -4960,51 +5401,68 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", - "mio 0.8.11", + "mio", "signal-hook", ] [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] name = "signature" -version = "2.2.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "digest", - "rand_core 0.6.4", + "digest 0.11.3", + "rand_core 0.10.1", ] [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +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 = "smithay-client-toolkit" @@ -5012,7 +5470,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "calloop", "calloop-wayland-source", "cursor-icon", @@ -5042,53 +5500,43 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.0" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "softbuffer" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ "as-raw-xcb-connection", "bytemuck", - "cfg_aliases", - "core-graphics 0.24.0", "drm", "fastrand", - "foreign-types 0.5.0", "js-sys", - "log", "memmap2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-quartz-core", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "raw-window-handle", - "redox_syscall 0.5.15", - "rustix 0.38.44", + "redox_syscall 0.5.18", + "rustix 1.1.4", "tiny-xlib", + "tracing", "wasm-bindgen", "wayland-backend", "wayland-client", "wayland-sys", "web-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "x11rb", ] @@ -5097,6 +5545,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -5105,62 +5556,90 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.1", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "sspi" -version = "0.16.1" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523f6a99e26c1e6476a424d54bbda5354a01ee7f18b9d93dc48a8fd45ae8189b" +checksum = "15294fb005e36e0b0871d8fc0a4f6aac19f9f5440baee229a9a2b2d7de5ed484" dependencies = [ "async-dnssd", "async-recursion", - "bitflags 2.9.3", + "bitflags 2.13.0", + "bytemuck", "byteorder", "cfg-if", + "crypto-bigint", "crypto-mac", + "cryptoki", + "curve25519-dalek", + "ed25519-dalek", "futures", - "hickory-resolver", + "getrandom 0.3.4", "hmac", - "lazy_static", - "md-5", + "md-5 0.11.0", "md4", - "num-bigint-dig", "num-derive", "num-traits", "oid", + "p256", + "p384", + "p521", "picky", "picky-asn1", "picky-asn1-der", "picky-asn1-x509", "picky-krb", + "pkcs1 0.8.0-rc.4", "portpicker", - "rand 0.8.5", + "primeorder", + "rand 0.10.2", + "rand_core 0.10.1", "reqwest", "rsa", + "rustcrypto-ff", + "rustcrypto-ff_derive", + "rustcrypto-group", "rustls", "rustls-native-certs", "serde", - "serde_derive", - "sha1", - "sha2", + "sha1 0.11.0", + "sha2 0.11.0", "time", "tokio", "tracing", "url", "uuid", - "windows 0.61.3", + "widestring", + "windows", "windows-registry", - "windows-sys 0.60.2", + "winscard", "zeroize", ] [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "strck" @@ -5198,9 +5677,20 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.104" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -5224,16 +5714,16 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5256,15 +5746,15 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.20.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.4.3", "once_cell", - "rustix 1.0.8", - "windows-sys 0.59.0", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -5278,11 +5768,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.16" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.16", + "thiserror-impl 2.0.18", ] [[package]] @@ -5293,18 +5783,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "thiserror-impl" -version = "2.0.16" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -5318,31 +5808,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "js-sys", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -5375,12 +5864,12 @@ dependencies = [ [[package]] name = "tiny-xlib" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0324504befd01cab6e0c994f34b2ffa257849ee019d3fb3b64fb2c858887d89e" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" dependencies = [ "as-raw-xcb-connection", - "ctor-lite", + "ctor", "libloading", "pkg-config", "tracing", @@ -5394,9 +5883,9 @@ checksum = "9ab95735ea2c8fd51154d01e39cf13912a78071c2d89abc49a7ef102a7dd725a" [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -5414,9 +5903,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -5445,38 +5934,35 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "tokio" -version = "1.47.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", - "mio 1.0.4", + "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", - "socket2 0.6.0", + "socket2", "tokio-macros", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -5491,9 +5977,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -5501,9 +5987,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.27.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", @@ -5519,9 +6005,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.16" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -5532,14 +6018,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.2" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", - "serde", + "serde_core", "serde_spanned", - "toml_datetime 0.7.0", + "toml_datetime", "toml_parser", "toml_writer", "winnow", @@ -5547,50 +6033,45 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" - -[[package]] -name = "toml_datetime" -version = "0.7.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ - "serde", + "serde_core", ] [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", - "toml_datetime 0.6.11", + "toml_datetime", + "toml_parser", "winnow", ] [[package]] name = "toml_parser" -version = "1.0.1" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.2" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -5603,20 +6084,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -5633,9 +6114,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -5645,20 +6126,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -5677,14 +6158,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -5721,9 +6202,9 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" [[package]] name = "tungstenite" -version = "0.27.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", @@ -5731,19 +6212,18 @@ dependencies = [ "httparse", "log", "native-tls", - "rand 0.9.2", + "rand 0.9.4", "rustls", "rustls-pki-types", - "sha1", - "thiserror 2.0.16", - "utf-8", + "sha1 0.10.7", + "thiserror 2.0.18", ] [[package]] name = "typenum" -version = "1.18.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unarray" @@ -5753,30 +6233,30 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +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-width" -version = "0.1.14" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "universal-hash" -version = "0.5.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common", - "subtle", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -5787,9 +6267,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -5797,12 +6277,6 @@ dependencies = [ "serde", ] -[[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" @@ -5817,13 +6291,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.0" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.4.3", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -5853,7 +6327,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -5912,63 +6386,59 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] name = "wasite" -version = "0.1.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5976,35 +6446,35 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn", - "wasm-bindgen-backend", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] [[package]] name = "wayland-backend" -version = "0.3.10" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe770181423e5fc79d3e2a7f4410b7799d5aab1de4372853de3c6aa13ca24121" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", - "rustix 0.38.44", + "rustix 1.1.4", "scoped-tls", "smallvec", "wayland-sys", @@ -6012,12 +6482,12 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.10" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978fa7c67b0847dbd6a9f350ca2569174974cd4082737054dbb7fbb79d7d9a61" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags 2.9.3", - "rustix 0.38.44", + "bitflags 2.13.0", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] @@ -6028,29 +6498,29 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "cursor-icon", "wayland-backend", ] [[package]] name = "wayland-cursor" -version = "0.31.10" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65317158dec28d00416cb16705934070aef4f8393353d41126c54264ae0f182" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 0.38.44", + "rustix 1.1.4", "wayland-client", "xcursor", ] [[package]] name = "wayland-protocols" -version = "0.32.8" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "779075454e1e9a521794fed15886323ea0feda3f8b0fc1390f5398141310422a" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -6058,11 +6528,11 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.3.8" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd38cdad69b56ace413c6bcc1fbf5acc5e2ef4af9d5f8f1f9570c0c83eae175" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -6071,11 +6541,11 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.8" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cb6cdc73399c0e06504c437fe3cf886f25568dd5454473d565085b36d6a8bbf" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -6084,9 +6554,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.6" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "896fdafd5d28145fce7958917d69f2fd44469b1d4e861cb5961bcbeebc6d1484" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", "quick-xml", @@ -6095,9 +6565,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.6" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbcebb399c77d5aa9fa5db874806ee7b4eba4e73650948e8f93963f128896615" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", @@ -6107,9 +6577,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -6127,41 +6597,41 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.2" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] [[package]] -name = "which" -version = "4.4.2" +name = "whoami" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", ] [[package]] -name = "whoami" -version = "1.6.1" +name = "wide" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +checksum = "dfdfe6a32973f2d1b268b8895845a8a96cac2f0191e72c27cc929036060dbf89" dependencies = [ - "libredox", - "wasite", - "web-sys", + "bytemuck", + "safe_arch", ] [[package]] name = "widestring" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" [[package]] name = "winapi" @@ -6181,11 +6651,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6196,142 +6666,112 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.54.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" -dependencies = [ - "windows-core 0.54.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core", "windows-future", - "windows-link", "windows-numerics", ] [[package]] name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.54.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core", ] [[package]] name = "windows-core" -version = "0.61.2" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", "windows-link", - "windows-result 0.3.4", + "windows-result", "windows-strings", ] [[package]] name = "windows-future" -version = "0.2.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link", "windows-threading", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "windows-link" -version = "0.1.3" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-numerics" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link", ] [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", - "windows-result 0.3.4", + "windows-result", "windows-strings", ] [[package]] name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-result" -version = "0.3.4" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] @@ -6345,15 +6785,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -6374,11 +6805,11 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-targets 0.53.2", + "windows-link", ] [[package]] @@ -6396,21 +6827,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -6420,34 +6836,18 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" -dependencies = [ - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", -] - [[package]] name = "windows-threading" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ "windows-link", ] @@ -6458,197 +6858,107 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" - [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" - [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" - [[package]] name = "winit" -version = "0.30.12" +version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.9.3", - "block2", + "bitflags 2.13.0", + "block2 0.5.1", "bytemuck", "calloop", "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", - "core-graphics 0.23.2", + "core-graphics", "cursor-icon", "dpi", "js-sys", @@ -6686,23 +6996,13 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.12" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "winreg" version = "0.55.0" @@ -6714,19 +7014,39 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "winscard" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "12dafb3c1468d0a3f5440e21e51614b53d1fdc62c9f82cc861c447906d09c69a" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", + "crypto-bigint", + "flate2", + "iso7816", + "iso7816-tlv", + "num-derive", + "num-traits", + "picky", + "picky-asn1-x509", + "rsa", + "sha1 0.11.0", + "time", + "tracing", + "uuid", + "widestring", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -6750,34 +7070,33 @@ dependencies = [ [[package]] name = "x11rb" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", "libloading", "once_cell", - "rustix 0.38.44", + "rustix 1.1.4", "x11rb-protocol", ] [[package]] name = "x11rb-protocol" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "x25519-dalek" -version = "2.0.1" +version = "3.0.0-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +checksum = "eee64e8620caa64914d669b1f68f858aaff54e2d0f9ad3b30a613b58a1baa83e" dependencies = [ "curve25519-dalek", - "rand_core 0.6.4", - "serde", + "rand_core 0.10.1", "zeroize", ] @@ -6787,9 +7106,9 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ - "const-oid", - "der", - "spki", + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", "tls_codec", ] @@ -6805,7 +7124,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.13.0", "dlib", "log", "once_cell", @@ -6851,11 +7170,10 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -6863,91 +7181,91 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] [[package]] name = "yuv" -version = "0.8.6" +version = "0.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b08262a503468e0123115a872ac2fd250f965e0178489d393686e9dd19b47e6" +checksum = "5d85a782d94ee43f078bcfd6fa82d4e6a5b2d1cfbbad168e4df5a9f7b39ef48c" dependencies = [ "num-traits", ] [[package]] name = "zerocopy" -version = "0.8.26" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.26" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[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", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -6956,9 +7274,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -6967,15 +7285,21 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zstd-safe" version = "7.2.4" @@ -6987,9 +7311,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.15+zstd.1.5.7" +version = "2.0.16+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 3ec46b009b..1df36b015d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ exclude = [ ] [workspace.package] -edition = "2021" +edition = "2024" license = "MIT OR Apache-2.0" homepage = "https://github.com/Devolutions/IronRDP" repository = "https://github.com/Devolutions/IronRDP" @@ -34,12 +34,11 @@ categories = ["network-programming"] # even for private dependencies. expect-test = "1" proptest = "1.4" -rstest = "0.25" +rstest = "0.26" # Note: we are trying to move away from using these crates. # They are being kept around for now for legacy compatibility, # but new usage should be avoided. -lazy_static = "1.4" # Legacy crate; prefer std::sync::LazyLock or LazyCell num-derive = "0.4" num-traits = "0.2" @@ -54,7 +53,6 @@ unsafe_attr_outside_unsafe = "warn" # == Correctness == # ambiguous_negative_literals = "warn" -keyword_idents_2024 = "warn" # FIXME: remove when switched to 2024 edition # == Style, readability == # elided_lifetimes_in_paths = "warn" # https://quinedot.github.io/rust-learning/dont-hide.html @@ -93,6 +91,7 @@ fn_to_numeric_cast_any = "warn" ptr_cast_constness = "warn" # == Correctness == # +as_conversions = "warn" cast_lossless = "warn" cast_possible_truncation = "warn" cast_possible_wrap = "warn" @@ -102,7 +101,7 @@ float_cmp = "warn" lossy_float_literal = "warn" float_cmp_const = "warn" as_underscore = "warn" -# TODO: unwrap_used = "warn" # Let’s either handle `None`, `Err` or use `expect` to give a reason. +unwrap_used = "warn" large_stack_frames = "warn" mem_forget = "warn" mixed_read_write_in_expression = "warn" @@ -112,26 +111,40 @@ panic = "warn" precedence_bits = "warn" rc_mutex = "warn" same_name_method = "warn" +string_slice = "warn" +suspicious_xor_used_as_pow = "warn" +unused_result_ok = "warn" +missing_panics_doc = "warn" # == Style, readability == # semicolon_outside_block = "warn" # With semicolon-outside-block-ignore-multiline = true clone_on_ref_ptr = "warn" cloned_instead_of_copied = "warn" +pub_without_shorthand = "warn" +infinite_loop = "warn" +empty_enum_variants_with_brackets = "warn" +deref_by_slicing = "warn" +multiple_inherent_impl = "warn" +map_with_unused_argument_over_ranges = "warn" +partial_pub_fields = "warn" trait_duplication_in_bounds = "warn" type_repetition_in_bounds = "warn" checked_conversions = "warn" get_unwrap = "warn" similar_names = "warn" # Reduce risk of confusing similar names together, and protects against typos when variable shadowing was intended. str_to_string = "warn" -string_to_string = "warn" std_instead_of_core = "warn" separated_literal_suffix = "warn" unused_self = "warn" useless_let_if_seq = "warn" string_add = "warn" range_plus_one = "warn" -# TODO: self_named_module_files = "warn" +self_named_module_files = "warn" # TODO: partial_pub_fields = "warn" (should we enable only in pdu crates?) +redundant_type_annotations = "warn" +unnecessary_self_imports = "warn" +try_err = "warn" +rest_pat_in_fully_bound_structs = "warn" # == Compile-time / optimization == # doc_include_without_cfg = "warn" @@ -141,6 +154,7 @@ or_fun_call = "warn" rc_buffer = "warn" string_lit_chars_any = "warn" unnecessary_box_returns = "warn" +large_futures = "warn" # == Extra-pedantic clippy == # allow_attributes = "warn" @@ -174,6 +188,12 @@ opt-level = 1 inherits = "release" lto = true +[profile.production-ffi] +inherits = "release" +strip = "symbols" +codegen-units = 1 +lto = true + [profile.production-wasm] inherits = "release" opt-level = "s" @@ -185,7 +205,7 @@ opt-level = 3 [profile.test.package.rand_chacha] opt-level = 3 -# [patch.crates-io] -# # FIXME: We need to catch up with Diplomat upstream again, but this is a significant amount of work. -# # In the meantime, we use this forked version which fixes an undefined behavior in the code expanded by the bridge macro. -# diplomat = { git = "https://github.com/CBenoit/diplomat", rev = "6dc806e80162b6b39509a04a2835744236cd2396" } +[patch.crates-io] +# FIXME: We need to catch up with Diplomat upstream again, but this is a significant amount of work. +# In the meantime, we use this forked version which fixes an undefined behavior in the code expanded by the bridge macro. +diplomat = { git = "https://github.com/CBenoit/diplomat", rev = "6dc806e80162b6b39509a04a2835744236cd2396" } diff --git a/README.md b/README.md index 671cfded3d..fb3b3e8ceb 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,13 @@ Supported codecs: ## Examples -### [`ironrdp-client`](https://github.com/Devolutions/IronRDP/tree/master/crates/ironrdp-client) +### [`ironrdp-viewer`](https://github.com/Devolutions/IronRDP/tree/master/crates/ironrdp-viewer) A full-fledged RDP client based on IronRDP crates suite, and implemented using non-blocking, asynchronous I/O. +It is built on top of the reusable [`ironrdp-client`](https://github.com/Devolutions/IronRDP/tree/master/crates/ironrdp-client) library crate. ```shell -cargo run --bin ironrdp-client -- --username --password +cargo run --bin ironrdp-viewer -- --username --password ``` ### [`screenshot`](https://github.com/Devolutions/IronRDP/blob/master/crates/ironrdp/examples/screenshot.rs) @@ -64,6 +65,25 @@ Alternatively, you may change a few group policies using `gpedit.msc`: 5. Reboot. +## Binary releases + +Prebuilt, checksummed `.tar.gz` archives are attached to each GitHub Release for the executable +packages, one per supported platform: + +- [`ironrdp-agent`](./crates/ironrdp-agent) — the agentic, daemon-backed CLI. +- [`ironrdp-viewer`](./crates/ironrdp-viewer) — the windowed RDP client CLI. + +Each package is released under its own tag (`ironrdp-agent-v*`, `ironrdp-viewer-v*`). See the +[Releases page](https://github.com/Devolutions/IronRDP/releases) to pick a release and follow the +per-platform download, checksum, and extraction instructions included in its notes. + +## Rust version (MSRV) + +IronRDP libraries follow a conservative Minimum Supported Rust Version (MSRV) policy. +The MSRV is the oldest stable Rust release that is at least 6 months old, bounded by the Rust version available in [Debian stable-backports](https://packages.debian.org/search?suite=all&arch=any&searchon=names&keywords=rust) and [Fedora stable](https://packages.fedoraproject.org/pkgs/rust/rust/). +The pinned toolchain in `rust-toolchain.toml` is both the project toolchain and the MSRV validated by CI. +See [ARCHITECTURE.md](./ARCHITECTURE.md#msrv-policy) for the full policy. + ## Architecture See the [ARCHITECTURE.md](https://github.com/Devolutions/IronRDP/blob/master/ARCHITECTURE.md) document. diff --git a/STYLE.md b/STYLE.md index 85ee31d826..ba70c4e3cc 100644 --- a/STYLE.md +++ b/STYLE.md @@ -151,6 +151,34 @@ error!(%err, "Active stage failed"); **Rationale**: consistency. We can rely on this to filter and collect diagnostics. +### Log levels + +Choose a level by how often the event fires and who needs it, keeping in mind that +IronRDP crates are libraries embedded into a final client which owns the default +verbosity. The final consumer should not be flooded at default level by routine +protocol mechanics. + +- `info!`: reserved for **rare lifecycle milestones** a consumer would typically want + at default verbosity (e.g. connection or session lifecycle transitions). It should + be uncommon in a library, and never used for anything that repeats during normal + operation (per copy/paste, per lock/unlock, per frame, etc.). +- `debug!`: **significant one-off events** — nothing that repeats in abundance, and no + "entering function X" tracing. +- `trace!`: everything else, the fine-grained detail you only want when that is all + that is left to understand a problem. + +```rust +// GOOD: a rare lifecycle milestone the consumer wants by default. +info!(%server_addr, "Connection established"); + +// BAD: fires on every clipboard lock/unlock — routine mechanics belong at debug!/trace!. +info!(count = cleared.len(), "Releasing outgoing locks before taking clipboard ownership"); +``` + +**Rationale**: the binary at the top of the stack decides what to surface to the user; +a library that emits `info!` for routine operations takes that choice away and spams +default logs. + [tracing-fields]: https://docs.rs/tracing/latest/tracing/index.html#recording-fields ## Helper functions diff --git a/benches/Cargo.toml b/benches/Cargo.toml index b60f35cd3d..9d2302bcfe 100644 --- a/benches/Cargo.toml +++ b/benches/Cargo.toml @@ -15,15 +15,15 @@ qoi = ["ironrdp/qoi"] qoiz = ["ironrdp/qoiz"] [dependencies] -anyhow = "1.0.99" -async-trait = "0.1.89" -bytesize = "2.0.1" +anyhow = "1" +async-trait = "0.1" +bytesize = "2.3" ironrdp = { path = "../crates/ironrdp", features = [ "server", "pdu", "__bench", ] } -pico-args = "0.5.0" +pico-args = "0.5" tokio = { version = "1", features = ["sync", "fs", "time"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing = { version = "0.1", features = ["log"] } diff --git a/benches/src/perfenc.rs b/benches/src/perfenc.rs index 45b1383fcd..df1bb77dd4 100644 --- a/benches/src/perfenc.rs +++ b/benches/src/perfenc.rs @@ -2,7 +2,7 @@ #![allow(clippy::print_stderr)] #![allow(clippy::print_stdout)] -use core::num::NonZero; +use core::num::{NonZeroU16, NonZeroUsize}; use core::time::Duration; use std::io::Write as _; use std::time::Instant; @@ -59,15 +59,16 @@ async fn main() -> Result<(), anyhow::Error> { OptCodec::QoiZ => update_codecs.set_qoiz(Some(0)), }; - let mut encoder = UpdateEncoder::new(DesktopSize { width, height }, flags, update_codecs); + let mut encoder = UpdateEncoder::new(DesktopSize { width, height }, flags, update_codecs, 8 * 1024 * 1024) + .context("failed to initialize update encoder")?; let mut total_raw = 0u64; let mut total_enc = 0u64; let mut n_updates = 0u64; let mut updates = DisplayUpdates::new(file, DesktopSize { width, height }, fps); - while let Some(up) = updates.next_update().await { + while let Some(up) = updates.next_update().await? { if let DisplayUpdate::Bitmap(ref up) = up { - total_raw += up.data.len() as u64; + total_raw += u64::try_from(up.data.len())?; } else { eprintln!("Invalid update"); break; @@ -77,15 +78,16 @@ async fn main() -> Result<(), anyhow::Error> { let Some(frag) = iter.next().await else { break; }; - let len = frag?.data.len() as u64; + let len = u64::try_from(frag?.data.len())?; total_enc += len; } n_updates += 1; print!("."); - std::io::stdout().flush().unwrap(); + std::io::stdout().flush()?; } println!(); + #[expect(clippy::as_conversions, reason = "casting u64 to f64")] let ratio = total_enc as f64 / total_raw as f64; let percent = 100.0 - ratio * 100.0; println!("Encoder: {encoder:?}"); @@ -119,20 +121,21 @@ impl DisplayUpdates { #[async_trait::async_trait] impl RdpServerDisplayUpdates for DisplayUpdates { - async fn next_update(&mut self) -> Option { + async fn next_update(&mut self) -> anyhow::Result> { let stride = self.desktop_size.width as usize * 4; let frame_size = stride * self.desktop_size.height as usize; let mut buf = vec![0u8; frame_size]; - if self.file.read_exact(&mut buf).await.is_err() { - return None; - } + // FIXME: AsyncReadExt::read_exact is not cancellation safe. + self.file.read_exact(&mut buf).await.context("read exact")?; let now = Instant::now(); if let Some(last_update_time) = self.last_update_time { let elapsed = now - last_update_time; if self.fps > 0 && elapsed < Duration::from_millis(1000 / self.fps) { sleep(Duration::from_millis( - 1000 / self.fps - u64::try_from(elapsed.as_millis()).unwrap(), + 1000 / self.fps + - u64::try_from(elapsed.as_millis()) + .context("invalid `elapsed millis`: out of range integral conversion")?, )) .await; } @@ -142,20 +145,20 @@ impl RdpServerDisplayUpdates for DisplayUpdates { let up = DisplayUpdate::Bitmap(BitmapUpdate { x: 0, y: 0, - width: self.desktop_size.width.try_into().unwrap(), - height: self.desktop_size.height.try_into().unwrap(), + width: NonZeroU16::new(self.desktop_size.width).context("width cannot be zero")?, + height: NonZeroU16::new(self.desktop_size.height).context("height cannot be zero")?, format: PixelFormat::RgbX32, data: buf.into(), - stride: NonZero::new(stride).unwrap(), + stride: NonZeroUsize::new(stride).context("stride cannot be zero")?, }); - Some(up) + Ok(Some(up)) } } fn setup_logging() -> anyhow::Result<()> { use tracing::metadata::LevelFilter; - use tracing_subscriber::prelude::*; use tracing_subscriber::EnvFilter; + use tracing_subscriber::prelude::*; let fmt_layer = tracing_subscriber::fmt::layer().compact(); @@ -173,7 +176,9 @@ fn setup_logging() -> anyhow::Result<()> { Ok(()) } +#[derive(Default)] enum OptCodec { + #[default] RemoteFX, Bitmap, None, @@ -183,12 +188,6 @@ enum OptCodec { QoiZ, } -impl Default for OptCodec { - fn default() -> Self { - Self::RemoteFX - } -} - impl core::str::FromStr for OptCodec { type Err = anyhow::Error; @@ -201,7 +200,7 @@ impl core::str::FromStr for OptCodec { "qoi" => Ok(Self::Qoi), #[cfg(feature = "qoiz")] "qoiz" => Ok(Self::QoiZ), - _ => Err(anyhow::anyhow!("unknown codec: {}", s)), + _ => anyhow::bail!("unknown codec: {s}"), } } } diff --git a/clippy.toml b/clippy.toml index 7fc7b2d0df..17e7e90fda 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,5 +1,6 @@ -msrv = "1.84" +msrv = "1.87" semicolon-outside-block-ignore-multiline = true accept-comment-above-statement = true accept-comment-above-attributes = true allow-panic-in-tests = true +allow-unwrap-in-tests = true diff --git a/crates/iron-remote-desktop/CHANGELOG.md b/crates/iron-remote-desktop/CHANGELOG.md index 1a86a0e438..10bf54f311 100644 --- a/crates/iron-remote-desktop/CHANGELOG.md +++ b/crates/iron-remote-desktop/CHANGELOG.md @@ -6,6 +6,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.7.1](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.7.0...iron-remote-desktop-v0.7.1)] - 2026-05-27 + +### Features + +- Expose granular RDCleanPath error details ([#1117](https://github.com/Devolutions/IronRDP/issues/1117)) ([2911124e8f](https://github.com/Devolutions/IronRDP/commit/2911124e8fe6160bc8ba03a574b67077e6d2cca9)) + + Add RDCleanPathDetails struct to provide detailed error information for + RDCleanPath errors, including HTTP status codes, WSA error codes, and + TLS alert codes. + + Allows the web client to distinguish between different types of network + errors (say, WSAEACCES/10013) instead of showing a generic RDCleanpath + error message. + +- Implement clipboard file transfer support ([#1166](https://github.com/Devolutions/IronRDP/issues/1166)) ([c98a8fb774](https://github.com/Devolutions/IronRDP/commit/c98a8fb7741986e9afef00cb5615250c963a7fa9)) + + Add end-to-end clipboard file transfer (upload and download) across the + CLIPRDR channel per MS-RDPECLIP. + +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.6.0...iron-remote-desktop-v0.7.0)] - 2025-09-29 + +### Bug Fixes + +- [**breaking**] Changed onClipboardChanged to not consume the input (#992) ([6127e13c83](https://github.com/Devolutions/IronRDP/commit/6127e13c836d06764d483b6b55188fd23a4314a2)) + +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.5.0...iron-remote-desktop-v0.6.0)] - 2025-08-29 + +### Features + +- [**breaking**] Extend `DeviceEvent.wheelRotations` event to support passing rotation units other than pixels (#952) ([23c0cc2c36](https://github.com/Devolutions/IronRDP/commit/23c0cc2c365159d24330a89ec4015121b67bccb6)) + +## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.4.0...iron-remote-desktop-v0.5.0)] - 2025-08-29 + +### Bug Fixes + +- [**breaking**] Remove the `remote_received_format_list_callback` method from Session common API (#935) ([5b948e2161](https://github.com/Devolutions/IronRDP/commit/5b948e2161b08b13d32bdbb480b26c8fa44d42f7)) + ## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/iron-remote-desktop-v0.3.0...iron-remote-desktop-v0.4.0)] - 2025-06-27 ### Features @@ -18,4 +55,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [**breaking**] Rename extension_call to invoke_extension (#803) ([f68cd06ac3](https://github.com/Devolutions/IronRDP/commit/f68cd06ac3705608e6f2ac6bde684d9ae906ea53)) - diff --git a/crates/iron-remote-desktop/Cargo.toml b/crates/iron-remote-desktop/Cargo.toml index bed7538ea3..cbd4ce809f 100644 --- a/crates/iron-remote-desktop/Cargo.toml +++ b/crates/iron-remote-desktop/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "iron-remote-desktop" -version = "0.4.0" +version = "0.7.1" readme = "README.md" description = "Helper crate for building WASM modules compatible with iron-remote-desktop WebComponent" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true diff --git a/crates/iron-remote-desktop/README.md b/crates/iron-remote-desktop/README.md index 58ba88bfa3..75241fbf65 100644 --- a/crates/iron-remote-desktop/README.md +++ b/crates/iron-remote-desktop/README.md @@ -1,8 +1,33 @@ # Iron Remote Desktop — Helper Crate -Helper crate for building WASM modules compatible with the `iron-remote-desktop` WebComponent. +Helper crate for building WASM modules compatible with the `iron-remote-desktop` web component. -Implement the `RemoteDesktopApi` trait on a Rust type, and call the `make_bridge!` on -it to generate the WASM API that is expected by `iron-remote-desktop`. +Implement the `RemoteDesktopApi` trait on a Rust type and call `make_bridge!` on it to generate +the WASM API expected by `iron-remote-desktop`. -See the `ironrdp-web` crate in the repository to see how it is used in practice. +See the `ironrdp-web` crate for a complete example. + +## Design Philosophy + +`iron-remote-desktop` is **protocol-agnostic**. The traits in this crate (`Session`, +`SessionBuilder`) define only features that are universal across all remote protocols: +input, rendering, clipboard, resize, and connection lifecycle. + +**Protocol-specific features must not be added to these traits.** They belong in the backend +crate and must be surfaced via the extension mechanism: + +- `Session::invoke_extension` / `SessionBuilder::extension` are the pass-through points. +- This crate defines a concrete `Extension` type used by the traits; each backend defines its own + extension identifiers and payload formats and interprets the values carried inside `Extension`. +- `iron-remote-desktop` treats `Extension` as an opaque envelope and never inspects backend-specific + extension values. + +A method belongs in these traits if **either** of the following is true: + +1. **The web component itself needs to call it** to implement transparent, protocol-independent + behaviour (e.g., `supports_unicode_keyboard_shortcuts` is called by the component to adapt + keyboard handling, without the consumer being involved). +2. **The feature is universal** — every reasonable remote protocol backend would implement it + in a meaningful way (e.g., resize, clipboard text, cursor style). + +If neither applies, the method is protocol-specific and must go through extensions. diff --git a/crates/iron-remote-desktop/src/error.rs b/crates/iron-remote-desktop/src/error.rs index 4209384486..9a5c10f08d 100644 --- a/crates/iron-remote-desktop/src/error.rs +++ b/crates/iron-remote-desktop/src/error.rs @@ -1,9 +1,36 @@ +//! Error handling types and traits for iron-remote-desktop. +//! +//! # Example: Handling RDCleanPath errors +//! +//! ```no_run +//! # use iron_remote_desktop::*; +//! # fn handle_error(error: impl IronError) { +//! match error.kind() { +//! IronErrorKind::RDCleanPath => { +//! if let Some(details) = error.rdcleanpath_details() { +//! // Check for specific HTTP errors +//! if details.http_status_code() == Some(403) { +//! // Handle forbidden/VNET deleted case +//! } +//! // Check for WSA errors +//! if details.wsa_error_code() == Some(10013) { +//! // Handle permission denied +//! } +//! } +//! } +//! _ => {} +//! } +//! # } +//! ``` + use wasm_bindgen::prelude::*; pub trait IronError { fn backtrace(&self) -> String; fn kind(&self) -> IronErrorKind; + + fn rdcleanpath_details(&self) -> Option; } #[derive(Clone, Copy)] @@ -19,8 +46,83 @@ pub enum IronErrorKind { AccessDenied, /// Something wrong happened when sending or receiving the RDCleanPath message RDCleanPath, - /// Couldn’t connect to proxy + /// Couldn't connect to proxy ProxyConnect, /// Protocol negotiation failed NegotiationFailure, } + +/// Detailed error information for RDCleanPath errors. +/// +/// When an RDCleanPath error occurs, this structure provides granular details +/// about the underlying cause, including HTTP status codes, Windows Socket errors, +/// and TLS alert codes. +#[derive(Clone, Copy, Debug)] +#[wasm_bindgen] +pub struct RDCleanPathDetails { + http_status_code: Option, + wsa_error_code: Option, + tls_alert_code: Option, +} + +// NOTE: multiple impl blocks required because wasm-bindgen doesn't support +// non-exported constructors in #[wasm_bindgen] impl blocks +#[wasm_bindgen] +impl RDCleanPathDetails { + /// HTTP status code if the error originated from an HTTP response. + /// + /// Common values: + /// - 403: Forbidden (e.g., deleted VNET, insufficient permissions) + /// - 404: Not Found + /// - 500: Internal Server Error + /// - 502: Bad Gateway + /// - 503: Service Unavailable + #[wasm_bindgen(getter, js_name = httpStatusCode)] + pub fn http_status_code(&self) -> Option { + self.http_status_code + } + + /// Windows Socket API (WSA) error code. + /// + /// Common values: + /// - 10013: Permission denied (WSAEACCES) - often indicates deleted/invalid VNET + /// - 10060: Connection timed out (WSAETIMEDOUT) + /// - 10061: Connection refused (WSAECONNREFUSED) + /// - 10051: Network is unreachable (WSAENETUNREACH) + /// - 10065: No route to host (WSAEHOSTUNREACH) + #[wasm_bindgen(getter, js_name = wsaErrorCode)] + pub fn wsa_error_code(&self) -> Option { + self.wsa_error_code + } + + /// TLS alert code if the error occurred during TLS handshake. + /// + /// Common values: + /// - 40: Handshake failure + /// - 42: Bad certificate + /// - 45: Certificate expired + /// - 48: Unknown CA + /// - 112: Unrecognized name + #[wasm_bindgen(getter, js_name = tlsAlertCode)] + pub fn tls_alert_code(&self) -> Option { + self.tls_alert_code + } +} + +#[expect( + clippy::allow_attributes, + reason = "Unfortunately, expect attribute doesn't work with clippy::multiple_inherent_impl lint" +)] +#[allow( + clippy::multiple_inherent_impl, + reason = "We don't want to expose the constructor to JS" +)] +impl RDCleanPathDetails { + pub fn new(http_status_code: Option, wsa_error_code: Option, tls_alert_code: Option) -> Self { + Self { + http_status_code, + wsa_error_code, + tls_alert_code, + } + } +} diff --git a/crates/iron-remote-desktop/src/extension.rs b/crates/iron-remote-desktop/src/extension.rs index d0f24d5c40..5e68188026 100644 --- a/crates/iron-remote-desktop/src/extension.rs +++ b/crates/iron-remote-desktop/src/extension.rs @@ -1,5 +1,5 @@ -use wasm_bindgen::prelude::wasm_bindgen; use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::wasm_bindgen; #[macro_export] macro_rules! extension_match { @@ -53,6 +53,14 @@ impl Extension { } } +#[expect( + clippy::allow_attributes, + reason = "Unfortunately, expect attribute doesn't work with clippy::multiple_inherent_impl lint" +)] +#[allow( + clippy::multiple_inherent_impl, + reason = "We don't want to expose these methods to JS" +)] impl Extension { pub fn ident(&self) -> &str { self.ident.as_str() diff --git a/crates/iron-remote-desktop/src/input.rs b/crates/iron-remote-desktop/src/input.rs index 1ce86b4adf..a4b451358f 100644 --- a/crates/iron-remote-desktop/src/input.rs +++ b/crates/iron-remote-desktop/src/input.rs @@ -1,3 +1,12 @@ +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub enum RotationUnit { + Pixel, + Line, + Page, +} + pub trait DeviceEvent { fn mouse_button_pressed(button: u8) -> Self; @@ -5,7 +14,7 @@ pub trait DeviceEvent { fn mouse_move(x: u16, y: u16) -> Self; - fn wheel_rotations(vertical: bool, rotation_units: i16) -> Self; + fn wheel_rotations(vertical: bool, rotation_amount: i16, rotation_unit: RotationUnit) -> Self; fn key_pressed(scancode: u16) -> Self; diff --git a/crates/iron-remote-desktop/src/lib.rs b/crates/iron-remote-desktop/src/lib.rs index b0053faf7a..d5fb282963 100644 --- a/crates/iron-remote-desktop/src/lib.rs +++ b/crates/iron-remote-desktop/src/lib.rs @@ -12,9 +12,9 @@ mod session; pub use clipboard::{ClipboardData, ClipboardItem}; pub use cursor::CursorStyle; pub use desktop_size::DesktopSize; -pub use error::{IronError, IronErrorKind}; +pub use error::{IronError, IronErrorKind, RDCleanPathDetails}; pub use extension::Extension; -pub use input::{DeviceEvent, InputTransaction}; +pub use input::{DeviceEvent, InputTransaction, RotationUnit}; pub use session::{Session, SessionBuilder, SessionTerminationInfo}; pub trait RemoteDesktopApi { @@ -159,8 +159,8 @@ macro_rules! make_bridge { } #[wasm_bindgen(js_name = onClipboardPaste)] - pub async fn on_clipboard_paste(&self, content: ClipboardData) -> Result<(), IronError> { - $crate::Session::on_clipboard_paste(&self.0, content.0) + pub async fn on_clipboard_paste(&self, content: &ClipboardData) -> Result<(), IronError> { + $crate::Session::on_clipboard_paste(&self.0, &content.0) .await .map_err(IronError) } @@ -267,16 +267,6 @@ macro_rules! make_bridge { )) } - #[wasm_bindgen(js_name = remoteReceivedFormatListCallback)] - pub fn remote_received_format_list_callback( - &self, - callback: $crate::internal::web_sys::js_sys::Function, - ) -> Self { - Self($crate::SessionBuilder::remote_received_format_list_callback( - &self.0, callback, - )) - } - #[wasm_bindgen(js_name = forceClipboardUpdateCallback)] pub fn force_clipboard_update_callback( &self, @@ -292,6 +282,9 @@ macro_rules! make_bridge { Self($crate::SessionBuilder::canvas_resized_callback(&self.0, callback)) } + // File transfer callbacks are protocol-specific and routed through + // extension() — see the RDP backend for available extension factories. + pub fn extension(&self, ext: $crate::Extension) -> Self { Self($crate::SessionBuilder::extension(&self.0, ext)) } @@ -339,11 +332,12 @@ macro_rules! make_bridge { } #[wasm_bindgen(js_name = wheelRotations)] - pub fn wheel_rotations(vertical: bool, rotation_units: i16) -> Self { + pub fn wheel_rotations(vertical: bool, rotation_amount: i16, rotation_unit: $crate::RotationUnit) -> Self { Self( <<$api as $crate::RemoteDesktopApi>::DeviceEvent as $crate::DeviceEvent>::wheel_rotations( vertical, - rotation_units, + rotation_amount, + rotation_unit, ), ) } @@ -440,6 +434,11 @@ macro_rules! make_bridge { pub fn kind(&self) -> $crate::IronErrorKind { $crate::IronError::kind(&self.0) } + + #[wasm_bindgen(js_name = rdcleanpathDetails)] + pub fn rdcleanpath_details(&self) -> Option<$crate::RDCleanPathDetails> { + $crate::IronError::rdcleanpath_details(&self.0) + } } }; } diff --git a/crates/iron-remote-desktop/src/session.rs b/crates/iron-remote-desktop/src/session.rs index c618b05d4f..db347f0e70 100644 --- a/crates/iron-remote-desktop/src/session.rs +++ b/crates/iron-remote-desktop/src/session.rs @@ -1,5 +1,5 @@ use wasm_bindgen::JsValue; -use web_sys::{js_sys, HtmlCanvasElement}; +use web_sys::{HtmlCanvasElement, js_sys}; use crate::clipboard::ClipboardData; use crate::error::IronError; @@ -45,9 +45,6 @@ pub trait SessionBuilder { #[must_use] fn remote_clipboard_changed_callback(&self, callback: js_sys::Function) -> Self; - #[must_use] - fn remote_received_format_list_callback(&self, callback: js_sys::Function) -> Self; - #[must_use] fn force_clipboard_update_callback(&self, callback: js_sys::Function) -> Self; @@ -67,7 +64,7 @@ pub trait Session { type ClipboardData: ClipboardData; type Error: IronError; - fn run(&self) -> impl core::future::Future>; + fn run(&self) -> impl Future>; fn desktop_size(&self) -> DesktopSize; @@ -85,10 +82,7 @@ pub trait Session { fn shutdown(&self) -> Result<(), Self::Error>; - fn on_clipboard_paste( - &self, - content: Self::ClipboardData, - ) -> impl core::future::Future>; + fn on_clipboard_paste(&self, content: &Self::ClipboardData) -> impl Future>; fn resize( &self, diff --git a/crates/ironrdp-acceptor/CHANGELOG.md b/crates/ironrdp-acceptor/CHANGELOG.md index d915be3007..cd78fe14bf 100644 --- a/crates/ironrdp-acceptor/CHANGELOG.md +++ b/crates/ironrdp-acceptor/CHANGELOG.md @@ -6,6 +6,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-acceptor-v0.9.0...ironrdp-acceptor-v0.10.0)] - 2026-07-10 + +### Features + +- Negotiate the MCS message channel ([#1347](https://github.com/Devolutions/IronRDP/issues/1347)) ([efa5732805](https://github.com/Devolutions/IronRDP/commit/efa573280572f3c0f0270a40ae51a154562706cc)) + + Updates the handshake to properly negotiate the MCS message channel by advertising Extended Client Data Blocks support and, when requested by the client, allocating/joining the message channel and surfacing its ID in AcceptorResult. This enables server-initiated PDUs that must use the message channel (e.g., network auto-detect) to have a valid transport. + +- Expose the client's keyboard layout on AcceptorResult ([#1397](https://github.com/Devolutions/IronRDP/issues/1397)) ([5ca84a5724](https://github.com/Devolutions/IronRDP/commit/5ca84a5724f48093193e39a3097c4f4987d64bbe)) + +- Honor the client-requested desktop size ([#1373](https://github.com/Devolutions/IronRDP/issues/1373)) ([d471bd066f](https://github.com/Devolutions/IronRDP/commit/d471bd066f303df22f4767801fd97ecdbf527869)) + + Adds an opt-in server/acceptor knob to negotiate the RDP session desktop size using the client’s originally requested resolution (from GCC Client Core Data) so the server can start at the client’s native size without a Deactivation–Reactivation resize round trip. + +### Build + +- [**breaking**] Update `ironrdp-async` public dependency to 0.10 + +- [**breaking**] Update `ironrdp-connector` public dependency to 0.10 + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-svc` public dependency to 0.8 + + + +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-acceptor-v0.8.0...ironrdp-acceptor-v0.9.0)] - 2026-05-27 + +### Bug Fixes + +- Send RDP_NEG_FAILURE on security protocol mismatch ([#1152](https://github.com/Devolutions/IronRDP/issues/1152)) ([02b9f4efbb](https://github.com/Devolutions/IronRDP/commit/02b9f4efbbe634a50efa0601f30e0a2096a6f78e)) + + When the client and server have no common security protocol, the + acceptor now sends a proper `RDP_NEG_FAILURE` PDU before returning an + error, instead of dropping the TCP connection. + +### Features + +- Expose received client credentials in AcceptorResult ([#1155](https://github.com/Devolutions/IronRDP/issues/1155)) ([eda32d8acf](https://github.com/Devolutions/IronRDP/commit/eda32d8acffbb2e37d13c790105ff022067f5efb)) + +- Skip credential check when server credentials are None ([#1150](https://github.com/Devolutions/IronRDP/issues/1150)) ([84015c9467](https://github.com/Devolutions/IronRDP/commit/84015c946731579dfd7a49294b2e55259e4f8d3f)) + +### Build + +- Upgrade sspi to 0.19, picky to rc.22, fix NTLM fallback ([#1188](https://github.com/Devolutions/IronRDP/issues/1188)) ([c70d38a9f1](https://github.com/Devolutions/IronRDP/commit/c70d38a9f190d6ad6c84bd9027a388b5db3296ba)) + + +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-acceptor-v0.7.0...ironrdp-acceptor-v0.8.0)] - 2025-12-18 + +### Bug Fixes + +- [**breaking**] Use static dispatch for NetworkClient trait ([#1043](https://github.com/Devolutions/IronRDP/issues/1043)) ([bca6d190a8](https://github.com/Devolutions/IronRDP/commit/bca6d190a870708468534d224ff225a658767a9a)) + + - Rename `AsyncNetworkClient` to `NetworkClient` + - Replace dynamic dispatch (`Option<&mut dyn ...>`) with static dispatch + using generics (`&mut N where N: NetworkClient`) + - Reorder `connect_finalize` parameters for consistency across crates + ## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-acceptor-v0.5.0...ironrdp-acceptor-v0.6.0)] - 2025-07-08 ### Features diff --git a/crates/ironrdp-acceptor/Cargo.toml b/crates/ironrdp-acceptor/Cargo.toml index 3327ee5c6a..90e20eae66 100644 --- a/crates/ironrdp-acceptor/Cargo.toml +++ b/crates/ironrdp-acceptor/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-acceptor" -version = "0.6.0" +version = "0.10.0" readme = "README.md" description = "State machines to drive an RDP connection acceptance sequence" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -16,11 +17,11 @@ doctest = false test = false [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.6" } # public -ironrdp-async = { path = "../ironrdp-async", version = "0.6" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.10" } # public +ironrdp-async = { path = "../ironrdp-async", version = "0.10" } # public tracing = { version = "0.1", features = ["log"] } [lints] diff --git a/crates/ironrdp-acceptor/src/channel_connection.rs b/crates/ironrdp-acceptor/src/channel_connection.rs index 2c6aa62e8e..66a066823a 100644 --- a/crates/ironrdp-acceptor/src/channel_connection.rs +++ b/crates/ironrdp-acceptor/src/channel_connection.rs @@ -1,12 +1,11 @@ use std::collections::HashSet; use ironrdp_connector::{ - reason_err, ConnectorError, ConnectorErrorExt as _, ConnectorResult, Sequence, State, Written, + ConnectorError, ConnectorErrorExt as _, ConnectorResult, Sequence, State, Written, reason_err, }; use ironrdp_core::WriteBuf; +use ironrdp_pdu::mcs; use ironrdp_pdu::x224::X224; -use ironrdp_pdu::{self as pdu}; -use pdu::mcs; use tracing::debug; #[derive(Debug)] @@ -57,13 +56,13 @@ impl State for ChannelConnectionState { } impl Sequence for ChannelConnectionSequence { - fn next_pdu_hint(&self) -> Option<&dyn pdu::PduHint> { + fn next_pdu_hint(&self) -> Option<&dyn ironrdp_pdu::PduHint> { match &self.state { ChannelConnectionState::Consumed => None, - ChannelConnectionState::WaitErectDomainRequest => Some(&pdu::X224_HINT), - ChannelConnectionState::WaitAttachUserRequest => Some(&pdu::X224_HINT), + ChannelConnectionState::WaitErectDomainRequest => Some(&ironrdp_pdu::X224_HINT), + ChannelConnectionState::WaitAttachUserRequest => Some(&ironrdp_pdu::X224_HINT), ChannelConnectionState::SendAttachUserConfirm => None, - ChannelConnectionState::WaitChannelJoinRequest { .. } => Some(&pdu::X224_HINT), + ChannelConnectionState::WaitChannelJoinRequest { .. } => Some(&ironrdp_pdu::X224_HINT), ChannelConnectionState::SendChannelJoinConfirm { .. } => None, ChannelConnectionState::AllJoined => None, } diff --git a/crates/ironrdp-acceptor/src/connection.rs b/crates/ironrdp-acceptor/src/connection.rs index 68e214b755..5c8d7dbd1f 100644 --- a/crates/ironrdp-acceptor/src/connection.rs +++ b/crates/ironrdp-acceptor/src/connection.rs @@ -1,10 +1,10 @@ use core::mem; use ironrdp_connector::{ - encode_x224_packet, general_err, reason_err, ConnectorError, ConnectorErrorExt as _, ConnectorResult, DesktopSize, - Sequence, State, Written, + ConnectorError, ConnectorErrorExt as _, ConnectorResult, DesktopSize, Sequence, State, Written, encode_x224_packet, + general_err, reason_err, }; -use ironrdp_core::{decode, WriteBuf}; +use ironrdp_core::{WriteBuf, decode}; use ironrdp_pdu as pdu; use ironrdp_pdu::nego::SecurityProtocol; use ironrdp_pdu::x224::X224; @@ -29,12 +29,48 @@ pub struct Acceptor { security: SecurityProtocol, io_channel_id: u16, user_channel_id: u16, + message_channel_id: Option, desktop_size: DesktopSize, + keyboard_layout: u32, server_capabilities: Vec, static_channels: StaticChannelSet, saved_for_reactivation: AcceptorState, pub(crate) creds: Option, + received_credentials: Option, reactivation: bool, + honor_client_desktop_size: bool, +} + +/// Minimum and maximum desktop dimension honored from a client. +/// +/// A desktop dimension in RDP is a `u16`; [MS-RDPBCGR] caps it at 8192, and +/// 200 is a conservative floor. A client-requested dimension outside this +/// range is not honored: the acceptor keeps the server-provided desktop size +/// rather than treating the request as an error. +const MIN_DESKTOP_DIM: u16 = 200; +const MAX_DESKTOP_DIM: u16 = 8192; + +/// Returns the client-requested desktop size if both dimensions are within the +/// protocol-legal range, otherwise `None`. +fn validate_desktop_size(width: u16, height: u16) -> Option { + if (MIN_DESKTOP_DIM..=MAX_DESKTOP_DIM).contains(&width) && (MIN_DESKTOP_DIM..=MAX_DESKTOP_DIM).contains(&height) { + Some(DesktopSize { width, height }) + } else { + None + } +} + +/// Writes `size` into every Bitmap capability set in `capabilities`. +/// +/// The server advertises its desktop size in the Bitmap capability set of the +/// Demand Active PDU; this keeps that advertisement in sync with `size`. +fn set_bitmap_desktop_size(capabilities: &mut [CapabilitySet], size: DesktopSize) { + for cap in capabilities.iter_mut() { + if let CapabilitySet::Bitmap(cap) = cap { + cap.desktop_width = size.width; + cap.desktop_height = size.height; + } + } } #[derive(Debug)] @@ -44,7 +80,31 @@ pub struct AcceptorResult { pub input_events: Vec>, pub user_channel_id: u16, pub io_channel_id: u16, + /// MCS channel ID of the message channel, present when the client requested + /// one via Client Message Channel Data (section 2.2.1.3.7). + /// + /// Server-initiated PDUs that ride the message channel (network auto-detect + /// per section 2.2.14, multitransport bootstrap, heartbeat) are sent on this + /// channel. `None` when the client did not request it. + pub message_channel_id: Option, pub reactivation: bool, + /// Keyboard layout identifier (KLID) announced by the client in its GCC + /// Client Core Data (section 2.2.1.3.2, `keyboardLayout`). + /// + /// This is the low word of a Windows locale identifier (e.g. `0x0000_0409` + /// for US English, `0x0000_040C` for French). `0` when the client did not + /// announce one. Servers can use it to pick a server-side keyboard layout + /// matching the client without changing any local input state. + pub keyboard_layout: u32, + /// Credentials received from the client during SecureSettingsExchange. + /// + /// Present for TLS-mode connections where the client sends credentials + /// in the ClientInfoPdu. `None` for CredSSP/Hybrid connections (where + /// authentication happens during the CredSSP exchange instead). + /// + /// Servers that need to validate credentials (e.g., via PAM or LDAP) + /// can use this field for post-handshake validation. + pub credentials: Option, } impl Acceptor { @@ -59,15 +119,51 @@ impl Acceptor { state: AcceptorState::InitiationWaitRequest, user_channel_id: USER_CHANNEL_ID, io_channel_id: IO_CHANNEL_ID, + message_channel_id: None, desktop_size, + keyboard_layout: 0, server_capabilities: capabilities, static_channels: StaticChannelSet::new(), saved_for_reactivation: Default::default(), creds, + received_credentials: None, reactivation: false, + honor_client_desktop_size: false, } } + /// Adopt the desktop size requested by the client in its Client Core Data + /// instead of the size this acceptor was constructed with. + /// + /// The client's requested resolution is only carried in the GCC Client + /// Core Data of the MCS Connect Initial PDU; the desktop size echoed back + /// later in the client's Confirm Active is, per [MS-RDPBCGR] 2.2.1.13.2, + /// the value the client copied from the *server's* Demand Active, so it + /// cannot be used to discover what the client originally asked for. When + /// this is enabled and the client's request is within the protocol-legal + /// range, the acceptor negotiates that size from the start (it is written + /// into the server's Bitmap capability set before Demand Active is sent), + /// avoiding a Deactivation-Reactivation resize round trip. + /// + /// Disabled by default, preserving the previous behavior of always + /// enforcing the server-provided size. + /// + /// # Precondition + /// + /// Enabling this only makes sense together with a display handler + /// ([`RdpServerDisplay`]) whose `request_initial_size` actually adopts (or + /// at least intersects) the size it is given. The acceptor negotiates the + /// client's size, but the server still builds its framebuffer/encoder from + /// the size the display handler reports; if that handler ignores the + /// requested size and returns a fixed, smaller framebuffer, the resulting + /// mismatch can cause the client to be dropped. With a fixed-size display + /// handler, leave this disabled. + /// + /// [`RdpServerDisplay`]: + pub fn set_honor_client_desktop_size(&mut self, honor: bool) { + self.honor_client_desktop_size = honor; + } + pub fn new_deactivation_reactivation( mut consumed: Acceptor, static_channels: StaticChannelSet, @@ -81,12 +177,7 @@ impl Acceptor { return Err(general_err!("invalid acceptor state")); }; - for cap in consumed.server_capabilities.iter_mut() { - if let CapabilitySet::Bitmap(cap) = cap { - cap.desktop_width = desktop_size.width; - cap.desktop_height = desktop_size.height; - } - } + set_bitmap_desktop_size(&mut consumed.server_capabilities, desktop_size); let state = AcceptorState::CapabilitiesSendServer { early_capability, channels: channels.clone(), @@ -100,12 +191,16 @@ impl Acceptor { state, user_channel_id: consumed.user_channel_id, io_channel_id: consumed.io_channel_id, + message_channel_id: consumed.message_channel_id, desktop_size, + keyboard_layout: consumed.keyboard_layout, server_capabilities: consumed.server_capabilities, static_channels, saved_for_reactivation, creds: consumed.creds, + received_credentials: consumed.received_credentials, reactivation: true, + honor_client_desktop_size: consumed.honor_client_desktop_size, }) } @@ -123,6 +218,9 @@ impl Acceptor { } } + /// # Panics + /// + /// Panics if state is not [AcceptorState::SecurityUpgrade]. pub fn mark_security_upgrade_as_done(&mut self) { assert!(self.reached_security_upgrade().is_some()); self.step(&[], &mut WriteBuf::new()).expect("transition to next state"); @@ -133,6 +231,9 @@ impl Acceptor { matches!(self.state, AcceptorState::Credssp { .. }) } + /// # Panics + /// + /// Panics if state is not [AcceptorState::Credssp]. pub fn mark_credssp_as_done(&mut self) { assert!(self.should_perform_credssp()); let res = self.step(&[], &mut WriteBuf::new()).expect("transition to next state"); @@ -152,7 +253,10 @@ impl Acceptor { input_events, user_channel_id: self.user_channel_id, io_channel_id: self.io_channel_id, + message_channel_id: self.message_channel_id, + keyboard_layout: self.keyboard_layout, reactivation: self.reactivation, + credentials: self.received_credentials.take(), }), previous_state => { self.state = previous_state; @@ -318,10 +422,34 @@ impl Sequence for Acceptor { } else if self.security.is_empty() { SecurityProtocol::empty() } else { - return Err(ConnectorError::general("failed to negotiate security protocol")); + // No common security protocol. Send RDP_NEG_FAILURE so the client + // gets a well-formed response instead of a TCP reset (MS-RDPBCGR 2.2.1.2.2). + let failure_code = if self.security.intersects(SecurityProtocol::SSL) { + nego::FailureCode::SSL_REQUIRED_BY_SERVER + } else if self + .security + .intersects(SecurityProtocol::HYBRID | SecurityProtocol::HYBRID_EX) + { + nego::FailureCode::HYBRID_REQUIRED_BY_SERVER + } else { + nego::FailureCode::SSL_REQUIRED_BY_SERVER + }; + + let failure = nego::ConnectionConfirm::Failure { code: failure_code }; + + debug!(message = ?failure, "Send"); + + ironrdp_core::encode_buf(&X224(failure), output).map_err(ConnectorError::encode)?; + + return Err(reason_err!( + "security protocol mismatch", + "server requires {:?} but client only offered {:?}", + self.security, + requested_protocol, + )); }; let connection_confirm = nego::ConnectionConfirm::Response { - flags: nego::ResponseFlags::empty(), + flags: nego::ResponseFlags::EXTENDED_CLIENT_DATA_SUPPORTED, protocol, }; @@ -381,16 +509,38 @@ impl Sequence for Acceptor { debug!(message = ?settings_initial, "Received"); - let early_capability = settings_initial - .conference_create_request - .gcc_blocks - .core - .optional_data - .early_capability_flags; + let gcc_blocks = settings_initial.conference_create_request.into_gcc_blocks(); + let early_capability = gcc_blocks.core.optional_data.early_capability_flags; + let client_wants_message_channel = gcc_blocks.message_channel.is_some(); + self.keyboard_layout = gcc_blocks.core.keyboard_layout; + + // Adopt the client's requested desktop size (from its Client + // Core Data) before Demand Active is sent, so the session is + // negotiated at that size without a Deactivation-Reactivation + // resize. See `set_honor_client_desktop_size`. + if self.honor_client_desktop_size { + if let Some(client_size) = + validate_desktop_size(gcc_blocks.core.desktop_width, gcc_blocks.core.desktop_height) + { + if client_size != self.desktop_size { + debug!( + requested = ?client_size, + previous = ?self.desktop_size, + "Honoring client-requested desktop size" + ); + self.desktop_size = client_size; + set_bitmap_desktop_size(&mut self.server_capabilities, client_size); + } + } else { + debug!( + width = gcc_blocks.core.desktop_width, + height = gcc_blocks.core.desktop_height, + "Client requested an out-of-range desktop size; keeping the server-provided size" + ); + } + } - let joined: Vec<_> = settings_initial - .conference_create_request - .gcc_blocks + let joined: Vec<_> = gcc_blocks .network .map(|network| { network @@ -406,11 +556,11 @@ impl Sequence for Acceptor { .unwrap_or_default(); #[expect(clippy::arithmetic_side_effects)] // IO channel ID is not big enough for overflowing. - let channels = joined + let channels: Vec<_> = joined .into_iter() .enumerate() .map(|(i, channel)| { - let channel_id = u16::try_from(i).unwrap() + self.io_channel_id + 1; + let channel_id = u16::try_from(i).expect("always in the range") + self.io_channel_id + 1; if let Some((type_id, c)) = channel { self.static_channels.attach_channel_id(type_id, channel_id); (channel_id, Some(c)) @@ -420,6 +570,16 @@ impl Sequence for Acceptor { }) .collect(); + if client_wants_message_channel { + // Allocate the message channel ID after the I/O channel and + // any static virtual channels. It is advertised in Server + // Message Channel Data and joined alongside the others. + #[expect(clippy::arithmetic_side_effects)] // IO channel ID is not big enough for overflowing. + let channel_id = + u16::try_from(channels.len()).expect("always in the range") + self.io_channel_id + 1; + self.message_channel_id = Some(channel_id); + } + ( Written::Nothing, AcceptorState::BasicSettingsSendResponse { @@ -447,13 +607,12 @@ impl Sequence for Acceptor { channel_ids.clone(), requested_protocol, skip_channel_join, + self.message_channel_id, ); let settings_response = mcs::ConnectResponse { - conference_create_response: gcc::ConferenceCreateResponse { - user_id: self.user_channel_id, - gcc_blocks: server_blocks, - }, + conference_create_response: gcc::ConferenceCreateResponse::new(self.user_channel_id, server_blocks) + .map_err(ConnectorError::decode)?, called_connect_id: 1, domain_parameters: mcs::DomainParameters::target(), }; @@ -472,7 +631,9 @@ impl Sequence for Acceptor { connection: if skip_channel_join { ChannelConnectionSequence::skip_channel_join(self.user_channel_id) } else { - ChannelConnectionSequence::new(self.user_channel_id, self.io_channel_id, channel_ids) + let mut join_channel_ids = channel_ids; + join_channel_ids.extend(self.message_channel_id); + ChannelConnectionSequence::new(self.user_channel_id, self.io_channel_id, join_channel_ids) }, }, ) @@ -532,19 +693,24 @@ impl Sequence for Acceptor { if !protocol.intersects(SecurityProtocol::HYBRID | SecurityProtocol::HYBRID_EX) { let creds = client_info.client_info.credentials; - if self.creds.as_ref() != Some(&creds) { - // FIXME: How authorization should be denied with standard RDP security? - // Since standard RDP security is not a priority, we just send a ServerDeniedConnection ServerSetErrorInfo PDU. - let info = ServerSetErrorInfoPdu(ErrorInfo::ProtocolIndependentCode( - ProtocolIndependentCode::ServerDeniedConnection, - )); + if let Some(expected) = &self.creds { + if expected != &creds { + // FIXME: How authorization should be denied with standard RDP security? + // Since standard RDP security is not a priority, we just send a ServerDeniedConnection ServerSetErrorInfo PDU. + let info = ServerSetErrorInfoPdu(ErrorInfo::ProtocolIndependentCode( + ProtocolIndependentCode::ServerDeniedConnection, + )); - debug!(message = ?info, "Send"); + debug!(message = ?info, "Send"); - util::encode_send_data_indication(self.user_channel_id, self.io_channel_id, &info, output)?; + util::encode_send_data_indication(self.user_channel_id, self.io_channel_id, &info, output)?; - return Err(ConnectorError::general("invalid credentials")); + return Err(ConnectorError::general("invalid credentials")); + } } + + // Store credentials for later retrieval via AcceptorResult. + self.received_credentials = Some(creds); } ( @@ -693,7 +859,7 @@ impl Sequence for Acceptor { } mcs::McsMessage::DisconnectProviderUltimatum(ultimatum) => { - return Err(reason_err!("received disconnect ultimatum", "{:?}", ultimatum.reason)) + return Err(reason_err!("received disconnect ultimatum", "{:?}", ultimatum.reason)); } _ => { @@ -715,7 +881,7 @@ impl Sequence for Acceptor { AcceptorState::Accepted { channels, client_capabilities, - input_events: finalization.input_events, + input_events: finalization.into_input_events(), } } else { AcceptorState::ConnectionFinalization { @@ -741,6 +907,7 @@ fn create_gcc_blocks( channel_ids: Vec, requested: SecurityProtocol, skip_channel_join: bool, + message_channel_id: Option, ) -> gcc::ServerGccBlocks { gcc::ServerGccBlocks { core: gcc::ServerCoreData { @@ -756,7 +923,9 @@ fn create_gcc_blocks( channel_ids, io_channel, }, - message_channel: None, + message_channel: message_channel_id.map(|id| gcc::ServerMessageChannelData { + mcs_message_channel_id: id, + }), multi_transport_channel: None, } } diff --git a/crates/ironrdp-acceptor/src/credssp.rs b/crates/ironrdp-acceptor/src/credssp.rs index f8840780a1..e665724dbf 100644 --- a/crates/ironrdp-acceptor/src/credssp.rs +++ b/crates/ironrdp-acceptor/src/credssp.rs @@ -1,14 +1,13 @@ -use ironrdp_async::AsyncNetworkClient; +use ironrdp_async::NetworkClient; use ironrdp_connector::sspi::credssp::{ CredSspServer, CredentialsProxy, ServerError, ServerMode, ServerState, TsRequest, }; use ironrdp_connector::sspi::generator::{Generator, GeneratorState}; -use ironrdp_connector::sspi::negotiate::ProtocolConfig; use ironrdp_connector::sspi::{self, AuthIdentity, KerberosServerConfig, NegotiateConfig, NetworkRequest, Username}; use ironrdp_connector::{ - custom_err, general_err, ConnectorError, ConnectorErrorKind, ConnectorResult, ServerName, Written, + ConnectorError, ConnectorErrorKind, ConnectorResult, ServerName, Written, custom_err, general_err, }; -use ironrdp_core::{other_err, WriteBuf}; +use ironrdp_core::{WriteBuf, other_err}; use ironrdp_pdu::PduHint; use tracing::debug; @@ -67,11 +66,15 @@ impl CredentialsProxy for CredentialsProxyImpl<'_> { data.username = username.clone(); Ok(data) } + + fn auth_data(&mut self) -> Result, std::io::Error> { + Ok(vec![self.credentials.clone()]) + } } pub(crate) async fn resolve_generator( generator: &mut CredsspProcessGenerator<'_>, - network_client: &mut dyn AsyncNetworkClient, + network_client: &mut impl NetworkClient, ) -> Result { let mut state = generator.start(); @@ -107,22 +110,18 @@ impl<'a> CredsspSequence<'a> { let client_computer_name = client_computer_name.into_inner(); let credentials = CredentialsProxyImpl::new(creds); - let credssp_config: Box = if let Some(krb_config) = krb_config { - Box::new(krb_config) - } else { - Box::::default() - }; - - let server = CredSspServer::new( - public_key, - credentials, + let server_mode = if let Some(krb_config) = krb_config { ServerMode::Negotiate(NegotiateConfig { - protocol_config: credssp_config, + protocol_config: Box::new(krb_config), package_list: None, client_computer_name, - }), - ) - .map_err(|e| ConnectorError::new("CredSSP", ConnectorErrorKind::Credssp(e)))?; + }) + } else { + ServerMode::Ntlm(sspi::ntlm::NtlmConfig::new(client_computer_name)) + }; + + let server = CredSspServer::new(public_key, credentials, server_mode) + .map_err(|e| ConnectorError::new("CredSSP", ConnectorErrorKind::Credssp(e)))?; let sequence = Self { server, diff --git a/crates/ironrdp-acceptor/src/finalization.rs b/crates/ironrdp-acceptor/src/finalization.rs index 6192ebbfc5..9961e87ce7 100644 --- a/crates/ironrdp-acceptor/src/finalization.rs +++ b/crates/ironrdp-acceptor/src/finalization.rs @@ -1,8 +1,7 @@ use ironrdp_connector::{ConnectorError, ConnectorErrorExt as _, ConnectorResult, Sequence, State, Written}; use ironrdp_core::WriteBuf; +use ironrdp_pdu::rdp; use ironrdp_pdu::x224::X224; -use ironrdp_pdu::{self as pdu}; -use pdu::rdp; use tracing::debug; use crate::util::{self, wrap_share_data}; @@ -13,7 +12,7 @@ pub struct FinalizationSequence { user_channel_id: u16, io_channel_id: u16, - pub input_events: Vec>, + input_events: Vec>, } #[derive(Default, Debug)] @@ -60,13 +59,13 @@ impl State for FinalizationState { } impl Sequence for FinalizationSequence { - fn next_pdu_hint(&self) -> Option<&dyn pdu::PduHint> { + fn next_pdu_hint(&self) -> Option<&dyn ironrdp_pdu::PduHint> { match &self.state { FinalizationState::Consumed => None, - FinalizationState::WaitSynchronize => Some(&pdu::X224Hint), - FinalizationState::WaitControlCooperate => Some(&pdu::X224Hint), - FinalizationState::WaitRequestControl => Some(&pdu::X224Hint), - FinalizationState::WaitFontList => Some(&pdu::RdpHint), + FinalizationState::WaitSynchronize => Some(&ironrdp_pdu::X224Hint), + FinalizationState::WaitControlCooperate => Some(&ironrdp_pdu::X224Hint), + FinalizationState::WaitRequestControl => Some(&ironrdp_pdu::X224Hint), + FinalizationState::WaitFontList => Some(&ironrdp_pdu::RdpHint), FinalizationState::SendSynchronizeConfirm => None, FinalizationState::SendControlCooperateConfirm => None, FinalizationState::SendGrantedControlConfirm => None, @@ -191,6 +190,10 @@ impl FinalizationSequence { } } + pub fn into_input_events(self) -> Vec> { + self.input_events + } + pub fn is_done(&self) -> bool { self.state.is_terminal() } @@ -221,7 +224,7 @@ fn create_font_map() -> rdp::headers::ShareDataPdu { } fn decode_share_control(input: &[u8]) -> ConnectorResult { - let data_request = ironrdp_core::decode::>>(input) + let data_request = ironrdp_core::decode::>>(input) .map_err(ConnectorError::decode) .map(|p| p.0)?; let share_control = ironrdp_core::decode::(data_request.user_data.as_ref()) @@ -230,7 +233,7 @@ fn decode_share_control(input: &[u8]) -> ConnectorResult Result { - use pdu::rdp::headers::{ShareControlPdu, ShareDataPdu}; + use ironrdp_pdu::rdp::headers::{ShareControlPdu, ShareDataPdu}; let share_control = decode_share_control(input).map_err(|_| ())?; diff --git a/crates/ironrdp-acceptor/src/lib.rs b/crates/ironrdp-acceptor/src/lib.rs index aea782a48a..a8a709687e 100644 --- a/crates/ironrdp-acceptor/src/lib.rs +++ b/crates/ironrdp-acceptor/src/lib.rs @@ -1,10 +1,10 @@ #![cfg_attr(doc, doc = include_str!("../README.md"))] #![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] -use ironrdp_async::{single_sequence_step, AsyncNetworkClient, Framed, FramedRead, FramedWrite, StreamWrapper}; +use ironrdp_async::{Framed, FramedRead, FramedWrite, NetworkClient, StreamWrapper, single_sequence_step}; use ironrdp_connector::sspi::credssp::EarlyUserAuthResult; use ironrdp_connector::sspi::{AuthIdentity, KerberosServerConfig, Username}; -use ironrdp_connector::{custom_err, general_err, ConnectorResult, ServerName}; +use ironrdp_connector::{ConnectorResult, ServerName, custom_err, general_err}; use ironrdp_core::WriteBuf; use tracing::{debug, instrument, trace}; @@ -51,16 +51,17 @@ where } } -pub async fn accept_credssp( +pub async fn accept_credssp( framed: &mut Framed, acceptor: &mut Acceptor, + network_client: &mut N, client_computer_name: ServerName, public_key: Vec, kerberos_config: Option, - network_client: Option<&mut dyn AsyncNetworkClient>, ) -> ConnectorResult<()> where S: FramedRead + FramedWrite, + N: NetworkClient, { let mut buf = WriteBuf::new(); @@ -68,11 +69,11 @@ where perform_credssp_step( framed, acceptor, + network_client, &mut buf, client_computer_name, public_key, kerberos_config, - network_client, ) .await } else { @@ -98,34 +99,73 @@ where } #[instrument(level = "trace", skip_all, ret)] -async fn perform_credssp_step( +async fn perform_credssp_step( framed: &mut Framed, acceptor: &mut Acceptor, + network_client: &mut N, buf: &mut WriteBuf, client_computer_name: ServerName, public_key: Vec, kerberos_config: Option, - network_client: Option<&mut dyn AsyncNetworkClient>, ) -> ConnectorResult<()> where S: FramedRead + FramedWrite, + N: NetworkClient, { assert!(acceptor.should_perform_credssp()); let AcceptorState::Credssp { protocol, .. } = acceptor.state else { unreachable!() }; - async fn credssp_loop( + let result = credssp_loop( + framed, + acceptor, + network_client, + buf, + client_computer_name, + public_key, + kerberos_config, + ) + .await; + + if protocol.intersects(nego::SecurityProtocol::HYBRID_EX) { + trace!(?result, "HYBRID_EX"); + + let result = if result.is_ok() { + EarlyUserAuthResult::Success + } else { + EarlyUserAuthResult::AccessDenied + }; + + buf.clear(); + result + .to_buffer(&mut *buf) + .map_err(|e| ironrdp_connector::custom_err!("to_buffer", e))?; + let response = &buf[..result.buffer_len()]; + framed + .write_all(response) + .await + .map_err(|e| ironrdp_connector::custom_err!("write all", e))?; + } + + result?; + + acceptor.mark_credssp_as_done(); + + return Ok(()); + + async fn credssp_loop( framed: &mut Framed, acceptor: &mut Acceptor, + network_client: &mut N, buf: &mut WriteBuf, client_computer_name: ServerName, public_key: Vec, kerberos_config: Option, - mut network_client: Option<&mut dyn AsyncNetworkClient>, ) -> ConnectorResult<()> where S: FramedRead + FramedWrite, + N: NetworkClient, { let creds = acceptor .creds @@ -164,12 +204,7 @@ where let result = { let mut generator = sequence.process_ts_request(ts_request); - - if let Some(network_client_ref) = network_client.as_deref_mut() { - resolve_generator(&mut generator, network_client_ref).await - } else { - generator.resolve_to_result() - } + resolve_generator(&mut generator, network_client).await }; // drop generator buf.clear(); @@ -184,43 +219,7 @@ where .map_err(|e| ironrdp_connector::custom_err!("write all", e))?; } } - Ok(()) - } - - let result = credssp_loop( - framed, - acceptor, - buf, - client_computer_name, - public_key, - kerberos_config, - network_client, - ) - .await; - - if protocol.intersects(nego::SecurityProtocol::HYBRID_EX) { - trace!(?result, "HYBRID_EX"); - - let result = if result.is_ok() { - EarlyUserAuthResult::Success - } else { - EarlyUserAuthResult::AccessDenied - }; - buf.clear(); - result - .to_buffer(&mut *buf) - .map_err(|e| ironrdp_connector::custom_err!("to_buffer", e))?; - let response = &buf[..result.buffer_len()]; - framed - .write_all(response) - .await - .map_err(|e| ironrdp_connector::custom_err!("write all", e))?; + Ok(()) } - - result?; - - acceptor.mark_credssp_as_done(); - - Ok(()) } diff --git a/crates/ironrdp-acceptor/src/util.rs b/crates/ironrdp-acceptor/src/util.rs index 0b22a8c1a9..4ed6aa0fa9 100644 --- a/crates/ironrdp-acceptor/src/util.rs +++ b/crates/ironrdp-acceptor/src/util.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; use ironrdp_connector::{ConnectorError, ConnectorErrorExt as _, ConnectorResult}; -use ironrdp_core::{encode_vec, Encode, WriteBuf}; +use ironrdp_core::{Encode, WriteBuf, encode_vec}; use ironrdp_pdu::rdp; use ironrdp_pdu::x224::X224; diff --git a/crates/ironrdp-agent/CHANGELOG.md b/crates/ironrdp-agent/CHANGELOG.md new file mode 100644 index 0000000000..46674e619c --- /dev/null +++ b/crates/ironrdp-agent/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [[0.1.0](https://github.com/Devolutions/IronRDP/releases/tag/ironrdp-agent-v0.1.0)] - 2026-07-10 + +Initial release. diff --git a/crates/ironrdp-agent/Cargo.toml b/crates/ironrdp-agent/Cargo.toml new file mode 100644 index 0000000000..085c6b4598 --- /dev/null +++ b/crates/ironrdp-agent/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "ironrdp-agent" +version = "0.1.0" +readme = "README.md" +description = "CLI-driven, daemon-backed agentic RDP client suitable for LLM consumption" +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +[lib] +doctest = false +test = false + +[[bin]] +name = "ironrdp-agent" +path = "src/main.rs" +test = false + +[features] +# Exposes otherwise-internal modules (e.g. the wire codec helpers) for unit testing from the +# workspace test suite. Hidden from docs; not intended for downstream use. +internal = [] + +[dependencies] +# RDP client engine: only the TLS backend is mandated +ironrdp-client = { path = "../ironrdp-client", version = "0.1", features = ["rustls"] } + +# Configuration model and codecs +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } +ironrdp-propertyset = { path = "../ironrdp-propertyset", version = "0.1" } +ironrdp-cfg = { path = "../ironrdp-cfg", version = "0.1" } +ironrdp-rdpfile = { path = "../ironrdp-rdpfile", version = "0.1" } +ironrdp-input = { path = "../ironrdp-input", version = "0.7" } + +# Async runtime and IPC transport +tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "io-util", "time", "signal"] } + +# CLI +clap = { version = "4.6", features = ["derive", "cargo"] } + +# Logging (ring-buffer tracing layer) +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# PNG encoding for screenshots +png = "0.18" + +# Utils +anyhow = "1" +whoami = "2.1" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[lints] +workspace = true diff --git a/crates/ironrdp-agent/README.md b/crates/ironrdp-agent/README.md new file mode 100644 index 0000000000..4d59f08de1 --- /dev/null +++ b/crates/ironrdp-agent/README.md @@ -0,0 +1,82 @@ +# IronRDP Agent + +A CLI-driven, daemon-backed RDP client designed for programmatic (e.g. LLM) consumption. + +The single `ironrdp-agent` binary bundles two roles: + +- **Daemon** (`ironrdp-agent daemon-start`): a long-lived, foreground process that owns the + [`ironrdp-client`] engine and one RDP session. It stays alive across many CLI invocations and + serves requests over a local IPC transport (a Unix domain socket on Unix, a named pipe on + Windows). +- **CLI** (`ironrdp-agent …`): a short-lived invocation that opens the IPC endpoint, sends a + single request, prints the response, and exits. + +Run `ironrdp-agent --help-agent` for a structured, machine-readable description of every operation. + +## Prebuilt binaries + +Prebuilt, checksummed archives are attached to each GitHub Release under the `ironrdp-agent-v*` +tags. See the [Releases page](https://github.com/Devolutions/IronRDP/releases) for per-platform +download and verification instructions. + +## Wire format + +Messages are encoded with [`ironrdp-core`]'s `Encode`/`Decode` traits, length-delimited with a +little-endian `u32` byte-count prefix. There is no JSON anywhere. Both ends are the same binary at +the same version, so the format carries no version byte. + +Connection configuration travels as a binary-encoded [`PropertySet`][`ironrdp-propertyset`] inside a +strictly-typed `Request::Connect`. Runtime operations (mouse, keyboard, status, logs, …) are +strictly-typed messages. `Request::Screenshot` returns the most recent frame as PNG bytes (with the +mouse cursor composited in — the agent enables software pointer rendering), which the CLI writes to +disk. + +## Secrets + +The daemon never exposes secrets to the IPC reader. `ConfigBuilder::build` strips every +`ironrdp_cfg::is_secret_key` property (`ClearTextPassword`, `GatewayPassword`, the RDCleanPath +token, …) before producing the `Config`, and the daemon seeds its live property bag from that +post-build configuration. Secrets therefore never reach the live bag, so property dumps, status, +and logs cannot leak them — no separate redaction pass is needed. + +## Preloaded overlay + +An operator can preconfigure any settings — credentials in particular — without handing them to the +IPC caller. Pass an overlay [`PropertySet`][`ironrdp-propertyset`] to `daemon-start --overlay FILE`; +the daemon layers it on top of every `Request::Connect` before building the configuration (overlay +wins). When the overlay carries a secret (password/token), `Request::Status` reports +`credentials_loaded`, so a caller should check the status first to learn whether it still needs to +supply a password. + +## Property overrides + +`connect` and `daemon-start` both accept a repeatable `--prop KEY:TYPE:VALUE` flag, using the same +grammar as one `.rdp` file line (`TYPE` is `i` for integer or `s` for string, e.g. +`--prop ironrdp_autologon:i:1 --prop username:s:admin`). It lets a caller set any property without a +dedicated CLI flag existing for it. Final precedence, low to high: + +``` +.rdp file → --prop overrides → named flags (--server/--username/…) → daemon's overlay +``` + +On `connect`, `--prop` overrides win over an optional `--rdp-file` but lose to the named flags. On +`daemon-start`, `--prop` overrides win over an optional `--overlay` file, and the resulting overlay +still wins over everything a `connect` request supplies (unchanged). + +## Logging + +Two logging concerns are kept separate: + +- **Daemon logging** is the daemon's own operational logging (IPC handling, lifecycle). It is the + global `tracing` subscriber: a compact formatter writing to stderr, defaulting to `info` and + tunable with the `IRONRDP_LOG` environment variable, mirroring [`ironrdp-viewer`]. +- **RDP session logging** is captured into a small, queryable in-memory ring buffer (read via + `Request::QueryLogs`) instead of the terminal. It is installed as a thread-local subscriber for + the session thread only (`tracing::dispatcher::with_default`), so it never becomes the global + subscriber. It defaults to `debug`; a per-`Connect` `log_directive` (e.g. `ironrdp_connector=trace`) + refines the filter to troubleshoot IronRDP itself. + +[`ironrdp-client`]: ../ironrdp-client +[`ironrdp-core`]: ../ironrdp-core +[`ironrdp-propertyset`]: ../ironrdp-propertyset +[`ironrdp-viewer`]: ../ironrdp-viewer diff --git a/crates/ironrdp-agent/src/cli.rs b/crates/ironrdp-agent/src/cli.rs new file mode 100644 index 0000000000..0b8ecb1855 --- /dev/null +++ b/crates/ironrdp-agent/src/cli.rs @@ -0,0 +1,500 @@ +//! The short-lived CLI: parse arguments, build a request (merging a `.rdp` file with overrides for +//! `connect`), send it to the daemon, and print the response. +//! +//! The CLI operates purely at the [`PropertySet`] level for connection config — it never calls +//! typed `ConfigBuilder` setters. +//! +//! For `connect`, property precedence from low to high is: `.rdp` file → `--prop` overrides → +//! named flags (`--server`/`--username`/…). The daemon's own overlay (`daemon-start --overlay`, +//! itself built from a `.rdp` file with `--prop` overrides layered on top) wins over all of that — +//! see `Daemon::connect` in `daemon.rs`. + +#![allow(clippy::print_stdout, clippy::print_stderr)] + +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use anyhow::Context as _; +use clap::{Args, CommandFactory as _, Parser, Subcommand, ValueEnum}; +use ironrdp_cfg::{PropertySetExt as _, TargetAddr}; +use ironrdp_input::MouseButton; +use ironrdp_propertyset::{PropertySet, Value}; + +use crate::ipc::{KeyFilter, Payload, PropValue, Request, Response}; +use crate::transport::{self, Endpoint}; + +/// IronRDP agent: a CLI-driven, daemon-backed RDP client. +#[derive(Parser, Debug)] +#[command(name = "ironrdp-agent", version, about, long_about = None)] +pub struct Cli { + /// Print a structured, LLM-friendly guide to every operation and exit. + #[arg(long, global = true)] + help_agent: bool, + + /// Override the IPC endpoint (defaults to the per-user socket/pipe). + #[arg(long, global = true)] + endpoint: Option, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Run the long-lived daemon in the foreground (owns the RDP session). + DaemonStart(DaemonArgs), + /// Open an RDP session from a .rdp file and/or CLI overrides. + Connect(ConnectArgs), + /// Tear down the current RDP session (the daemon keeps running). + Disconnect, + /// Report the current session status. + Status, + /// Query the live session properties. + QueryProps(QueryPropsArgs), + /// Print the RDP session's captured log lines (from the daemon's in-memory ring buffer). + QueryLogs(QueryLogsArgs), + /// Capture the current frame (cursor included) as a PNG written to disk. + Screenshot(ScreenshotArgs), + /// Move the mouse pointer to an absolute position. + MouseMove { + #[arg(long)] + x: u16, + #[arg(long)] + y: u16, + }, + /// Press or release a mouse button. + MouseButton { + #[arg(long, value_enum)] + button: CliMouseButton, + #[arg(long, action = clap::ArgAction::Set)] + pressed: bool, + }, + /// Rotate the mouse wheel (negative delta scrolls down/left). + Wheel { + #[arg(long, allow_hyphen_values = true)] + delta: i16, + #[arg(long)] + horizontal: bool, + }, + /// Press or release a key identified by its RDP scancode. + KeyScancode { + #[arg(long, value_parser = parse_scancode)] + scancode: u16, + #[arg(long, action = clap::ArgAction::Set)] + pressed: bool, + }, + /// Press or release a key identified by a Unicode character. + KeyUnicode { + #[arg(long = "char")] + character: char, + #[arg(long, action = clap::ArgAction::Set)] + pressed: bool, + }, + /// Resize the remote desktop. + Resize { + #[arg(long)] + width: u16, + #[arg(long)] + height: u16, + }, +} + +#[derive(Args, Debug)] +struct DaemonArgs { + /// Path to a .rdp file whose properties are preloaded as an overlay applied to every `connect` + /// (overlay wins). Use this to provision any setting out of band — credentials in particular + /// (e.g. `ClearTextPassword`), so a caller never needs to supply them; `status` then reports + /// `credentials loaded: true`. + #[arg(long)] + overlay: Option, + /// Arbitrary overlay property override (repeatable): `KEY:TYPE:VALUE`, the same grammar as one + /// `.rdp` file line (`TYPE` is `i` for integer or `s` for string), e.g. + /// `--prop ironrdp_autologon:i:1`. Applied on top of `--overlay`, so it lets an operator set any + /// property without a dedicated flag existing for it. + #[arg(long = "prop", value_name = "KEY:TYPE:VALUE")] + prop: Vec, +} + +#[derive(Args, Debug)] +struct ConnectArgs { + /// Path to a .rdp file to read the base configuration from. + #[arg(long)] + rdp_file: Option, + /// Arbitrary property override (repeatable): `KEY:TYPE:VALUE`, the same grammar as one `.rdp` + /// file line (`TYPE` is `i` for integer or `s` for string), e.g. `--prop + /// ironrdp_autologon:i:1 --prop username:s:admin`. Applied on top of `--rdp-file` but under the + /// named flags below (e.g. `--username`), which always win for the same key. Use this to set + /// any property without a dedicated flag existing for it. + #[arg(long = "prop", value_name = "KEY:TYPE:VALUE")] + prop: Vec, + /// RDP server address (host[:port]). Overrides the .rdp file. + #[arg(long)] + server: Option, + /// RDP account user name. Overrides the .rdp file. + #[arg(short, long)] + username: Option, + /// RDP account password. Overrides the .rdp file. + #[arg(short, long)] + password: Option, + /// RDP account domain. Overrides the .rdp file. + #[arg(short, long)] + domain: Option, + /// Tracing filter directive applied to this session's log capture (e.g. + /// `ironrdp_connector=trace`), layered on top of the default `debug` level. Use it to raise + /// verbosity up-front when troubleshooting a connection. + #[arg(long)] + log_directive: Option, +} + +#[derive(Args, Debug)] +struct QueryPropsArgs { + /// Only show keys containing this substring (case-insensitive). + #[arg(long, conflicts_with = "prefix")] + filter: Option, + /// Only show keys starting with this prefix (case-insensitive). + #[arg(long)] + prefix: Option, +} + +#[derive(Args, Debug)] +struct QueryLogsArgs { + /// Only show lines containing this substring. + #[arg(long)] + substring: Option, + /// Only show the last N retained lines. + #[arg(long)] + last: Option, +} + +#[derive(Args, Debug)] +struct ScreenshotArgs { + /// Destination PNG path (defaults to `screenshot.png` in the current directory). + path: Option, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum CliMouseButton { + Left, + Middle, + Right, + X1, + X2, +} + +impl CliMouseButton { + fn into_button(self) -> MouseButton { + match self { + Self::Left => MouseButton::Left, + Self::Middle => MouseButton::Middle, + Self::Right => MouseButton::Right, + Self::X1 => MouseButton::X1, + Self::X2 => MouseButton::X2, + } + } +} + +/// A single `--prop KEY:TYPE:VALUE` override, parsed with the same grammar as one `.rdp` file line +/// (see `ironrdp_rdpfile::load`): `TYPE` is `i` for integer or `s` for string. +#[derive(Clone, Debug)] +struct PropOverride { + key: String, + value: Value, +} + +impl FromStr for PropOverride { + type Err = String; + + fn from_str(input: &str) -> Result { + let mut parts = input.splitn(3, ':'); + let (Some(key), Some(ty), Some(value)) = (parts.next(), parts.next(), parts.next()) else { + return Err(format!("malformed --prop '{input}', expected KEY:TYPE:VALUE")); + }; + let key = key.trim(); + if key.is_empty() { + return Err(format!("empty key in --prop '{input}', expected KEY:TYPE:VALUE")); + } + let value = match ty { + "i" => value + .parse::() + .map(Value::from) + .map_err(|_| format!("invalid integer value in --prop '{input}'"))?, + "s" => Value::from(value), + other => { + return Err(format!( + "unknown type '{other}' in --prop '{input}', expected 'i' or 's'" + )); + } + }; + Ok(Self { + key: key.to_owned(), + value, + }) + } +} + +/// Applies `--prop` overrides onto `properties`, in argument order (last one for a given key wins). +fn apply_prop_overrides(properties: &mut PropertySet, overrides: Vec) { + for over in overrides { + properties.insert(over.key, over.value); + } +} + +/// Parses an RDP scancode in decimal or `0x`-prefixed hexadecimal. +fn parse_scancode(input: &str) -> Result { + if let Some(hex) = input.strip_prefix("0x").or_else(|| input.strip_prefix("0X")) { + u16::from_str_radix(hex, 16) + } else { + input.parse() + } +} + +/// Entry point shared by the binary: dispatches the parsed [`Cli`]. +pub async fn run(cli: Cli) -> anyhow::Result<()> { + if cli.help_agent { + print!("{}", crate::help::AGENT_GUIDE); + return Ok(()); + } + + let endpoint = endpoint_from_arg(cli.endpoint); + + let Some(command) = cli.command else { + let _ = Cli::command().print_help(); + println!(); + return Ok(()); + }; + + let request = match command { + Command::DaemonStart(args) => { + let overlay = load_overlay(args.overlay.as_deref(), args.prop)?; + return crate::daemon::run(endpoint, overlay).await; + } + Command::Connect(args) => build_connect_request(args)?, + Command::Disconnect => Request::Disconnect, + Command::Status => Request::Status, + Command::QueryProps(args) => Request::QueryProps { + filter: args + .filter + .map(KeyFilter::Substring) + .or_else(|| args.prefix.map(KeyFilter::Prefix)), + }, + Command::QueryLogs(args) => Request::QueryLogs { + substring: args.substring, + last: args.last, + }, + Command::Screenshot(args) => { + let response = transport::send_request(&endpoint, &Request::Screenshot).await?; + let payload = match response { + Response::Ok(payload) => payload, + Response::Err(message) => anyhow::bail!("{message}"), + }; + let Payload::Screenshot { width, height, png } = payload else { + anyhow::bail!("unexpected response to screenshot request"); + }; + let path = args.path.unwrap_or_else(|| PathBuf::from("screenshot.png")); + return write_screenshot(width, height, &png, &path); + } + Command::MouseMove { x, y } => Request::MouseMove { x, y }, + Command::MouseButton { button, pressed } => Request::MouseButton { + button: button.into_button(), + pressed, + }, + Command::Wheel { delta, horizontal } => Request::Wheel { delta, horizontal }, + Command::KeyScancode { scancode, pressed } => Request::KeyScancode { scancode, pressed }, + Command::KeyUnicode { character, pressed } => Request::KeyUnicode { ch: character, pressed }, + Command::Resize { width, height } => Request::Resize { width, height }, + }; + + let response = transport::send_request(&endpoint, &request).await?; + print_response(response) +} + +/// Loads an operator-provided overlay [`PropertySet`] from an optional `.rdp` file, then layers +/// `--prop` overrides on top. Returns an empty set when neither is given. +fn load_overlay(path: Option<&Path>, prop_overrides: Vec) -> anyhow::Result { + let mut properties = PropertySet::new(); + if let Some(path) = path { + let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + if let Err(errors) = ironrdp_rdpfile::load(&mut properties, &text) { + for error in &errors { + eprintln!("warning: skipped entry in {}: {error}", path.display()); + } + } + } + apply_prop_overrides(&mut properties, prop_overrides); + Ok(properties) +} + +/// Builds a `Connect` request by merging an optional `.rdp` file with CLI overrides into one +/// [`PropertySet`]. Configuration validation happens daemon-side (via +/// `ConfigBuilder::from_property_set`); this only parses and merges the inputs. +fn build_connect_request(args: ConnectArgs) -> anyhow::Result { + let mut properties = PropertySet::new(); + + if let Some(path) = &args.rdp_file { + let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + if let Err(errors) = ironrdp_rdpfile::load(&mut properties, &text) { + for error in &errors { + eprintln!("warning: skipped entry in {}: {error}", path.display()); + } + } + } + + // `--prop` overrides win over the .rdp file but lose to the named flags below. + apply_prop_overrides(&mut properties, args.prop); + + // Named CLI flags win over everything above. + if let Some(server) = args.server { + let address: TargetAddr = server + .parse() + .with_context(|| format!("invalid server address: {server}"))?; + properties.set_full_address(&address); + } + if let Some(username) = args.username { + properties.set_username(username); + } + if let Some(password) = args.password { + properties.set_clear_text_password(password); + } + if let Some(domain) = args.domain { + properties.set_domain(domain); + } + + Ok(Request::Connect { + properties, + log_directive: args.log_directive, + }) +} + +fn print_response(response: Response) -> anyhow::Result<()> { + match response { + Response::Ok(payload) => { + print_payload(payload); + Ok(()) + } + Response::Err(message) => anyhow::bail!("{message}"), + } +} + +fn print_payload(payload: Payload) { + match payload { + Payload::Empty => println!("ok"), + Payload::Status(status) => { + println!("state: {:?}", status.state); + if let Some(destination) = status.destination { + println!("destination: {destination}"); + } + if let (Some(width), Some(height)) = (status.width, status.height) { + println!("resolution: {width}x{height}"); + } + if let Some(message) = status.message { + println!("detail: {message}"); + } + println!("credentials loaded: {}", status.credentials_loaded); + } + Payload::Properties(dump) => { + for entry in dump.entries { + let value = match entry.value { + PropValue::Int(value) => value.to_string(), + PropValue::Str(value) => value, + }; + // Descriptions are derived locally from the key: they are a static function of the + // property name, so there is no reason to carry them over the wire. + match property_description(&entry.key) { + Some(description) => println!("{} = {value} # {description}", entry.key), + None => println!("{} = {value}", entry.key), + } + } + } + Payload::Logs(lines) => { + for line in lines { + println!("{line}"); + } + } + // Screenshots are handled out-of-band by `write_screenshot`, never printed. + Payload::Screenshot { width, height, .. } => println!("frame {width}x{height}"), + } +} + +/// Writes screenshot PNG bytes to disk, defaulting to `screenshot.png`. +fn write_screenshot(width: u16, height: u16, png: &[u8], path: &Path) -> anyhow::Result<()> { + std::fs::write(path, png).with_context(|| format!("write {}", path.display()))?; + println!("wrote {} ({width}x{height}, {} bytes)", path.display(), png.len()); + Ok(()) +} + +#[cfg(unix)] +fn endpoint_from_arg(arg: Option) -> Endpoint { + match arg { + Some(value) => Endpoint(PathBuf::from(value)), + None => transport::default_endpoint(), + } +} + +#[cfg(windows)] +fn endpoint_from_arg(arg: Option) -> Endpoint { + match arg { + Some(value) => Endpoint(value), + None => transport::default_endpoint(), + } +} + +/// Short, LLM-facing descriptions for the configuration keys recognized by [`ironrdp_cfg`], derived +/// locally from the key name when printing a dump (kept out of the wire protocol on purpose). +/// +/// Keys are the canonical lowercase `.rdp` names. Secret keys are listed for completeness even +/// though `ConfigBuilder::build` strips them before a session starts, so they never appear in a +/// dump. +fn property_description(key: &str) -> Option<&'static str> { + // PropertySet keys are case-sensitive and ironrdp-cfg mixes casings (e.g. `ClearTextPassword`), + // so normalize to lowercase to match the canonical lowercase arms below. + let description = match key.to_ascii_lowercase().as_str() { + // ── Standard .rdp keys ────────────────────────────────────────────── + "full address" => "RDP server address as host[:port]", + "alternate full address" => "fallback RDP server address (host[:port]) tried if 'full address' fails", + "server port" => "RDP server TCP port (default 3389)", + "username" => "RDP account user name", + "domain" => "RDP account domain", + "cleartextpassword" => "plaintext RDP account password (secret)", + "desktopwidth" => "requested remote desktop width in pixels", + "desktopheight" => "requested remote desktop height in pixels", + "desktopscalefactor" => "remote desktop DPI scale factor, in percent (e.g. 100, 150)", + "compression" => "enable bulk data compression (0/1)", + "audiomode" => "remote audio mode (0 = play on client, 1 = play on server, 2 = disabled)", + "redirectclipboard" => "enable clipboard redirection (0/1)", + "enablecredsspsupport" => "enable CredSSP/NLA authentication (0/1)", + "alternate shell" => "program to launch on connect instead of the desktop shell", + "shell working directory" => "working directory for the alternate shell or RemoteApp program", + "remoteapplicationname" => "RemoteApp display name", + "remoteapplicationprogram" => "RemoteApp program path to launch", + // ── RD gateway ────────────────────────────────────────────────────── + "gatewayhostname" => "RD gateway host name", + "gatewayusername" => "RD gateway user name", + "gatewaypassword" => "RD gateway password (secret)", + "gatewayusagemethod" => { + "when to use the RD gateway (0 = direct, 1 = always, 2 = detect, 3 = default, 4 = direct, bypass for local)" + } + "gatewaycredentialssource" => { + "RD gateway credential source (0 = server, 1 = user, 2 = profile, 3 = prompt, 4 = smart card, 5 = logon)" + } + // ── Kerberos ──────────────────────────────────────────────────────── + "kdcproxyname" => "Kerberos KDC proxy name", + "kdcproxyurl" => "Kerberos KDC proxy URL", + // ── IronRDP extensions (ironrdp_ prefix) ──────────────────────────── + "ironrdp_autologon" => "attempt automatic logon with the supplied credentials (0/1)", + "ironrdp_colordepth" => "color depth in bits per pixel (e.g. 16 or 32)", + "ironrdp_compressionlevel" => "bulk compression level", + "ironrdp_dvcpipeproxy" => "DVC pipe proxy specs, comma-separated 'channel=pipe' pairs", + "ironrdp_dvcplugin" => "DVC plugin library paths, comma-separated", + "ironrdp_qoi" => "enable the QOI graphics codec (0/1)", + "ironrdp_qoiz" => "enable the QOIZ (compressed QOI) graphics codec (0/1)", + "ironrdp_rdpdr" => "enable the RDPDR device-redirection channel (0/1)", + "ironrdp_smartcard" => "enable smart-card device redirection (0/1)", + "ironrdp_tls" => "use plain TLS security instead of CredSSP/Hybrid (0/1)", + "ironrdp_fakeeventsinterval" => "interval in minutes between synthetic keep-alive input events", + "ironrdp_rdcleanpathtoken" => "RDCleanPath authentication token (secret)", + "ironrdp_rdcleanpathurl" => "RDCleanPath proxy URL", + "ironrdp_serverpointer" => "render the server-side pointer instead of a client-drawn pointer (0/1)", + _ => return None, + }; + Some(description) +} diff --git a/crates/ironrdp-agent/src/daemon.rs b/crates/ironrdp-agent/src/daemon.rs new file mode 100644 index 0000000000..bfb348f956 --- /dev/null +++ b/crates/ironrdp-agent/src/daemon.rs @@ -0,0 +1,567 @@ +//! The long-lived daemon: owns the [`RdpClient`] engine and one RDP session, and serves IPC +//! requests until shut down. +//! +//! One daemon serves one RDP session (multi-session is out of scope for V1). It is started +//! explicitly with `daemon-start` and runs in the foreground; the caller is expected to background +//! it. On a clean shutdown the Unix socket file is removed (see [`crate::transport`]). + +use std::sync::{Arc, Mutex}; + +use anyhow::Context as _; +use ironrdp_client::config::{ConfigBuilder, MissingField}; +use ironrdp_client::rdp::{RdpClient, RdpInputEvent, RdpOutputEvent}; +use ironrdp_input::{Database, MousePosition, Operation, Scancode, WheelRotations}; +use ironrdp_pdu::rdp::capability_sets::MajorPlatformType; +use ironrdp_propertyset::{PropertySet, Value}; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::sync::mpsc; +use tracing::{debug, error, info, trace, warn}; + +use crate::ipc::{ + ConnState, KeyFilter, Payload, PropValue, PropertyDump, PropertyEntry, Request, Response, StatusInfo, +}; +use crate::logbuf::{self, LogBuffer}; +use crate::transport::{Endpoint, Listener, read_message, write_message}; + +/// Binds the IPC endpoint and serves requests until a shutdown signal is received. +/// +/// `overlay` is an operator-provided [`PropertySet`] layered on top of every `Connect` request +/// (overlay wins), so any setting — credentials in particular — can be preconfigured without the +/// caller ever supplying it. Pass an empty set when no overlay is desired. +pub async fn run(endpoint: Endpoint, overlay: PropertySet) -> anyhow::Result<()> { + // On Unix a leftover socket file would make `bind` fail; clear it if no daemon is alive. + #[cfg(unix)] + if endpoint.0.exists() { + if crate::transport::connect(&endpoint).await.is_ok() { + anyhow::bail!("a daemon already appears to be running at {endpoint}"); + } + // No daemon answered, so the path is a stale socket we can reclaim. Guard against deleting + // an unrelated regular file (or following a symlink) when `--endpoint` points elsewhere: + // inspect the path itself and only remove genuine sockets. + use std::os::unix::fs::FileTypeExt as _; + let metadata = + std::fs::symlink_metadata(&endpoint.0).with_context(|| format!("stat IPC endpoint {endpoint}"))?; + if !metadata.file_type().is_socket() { + anyhow::bail!("refusing to remove {endpoint}: path exists and is not a socket"); + } + std::fs::remove_file(&endpoint.0).with_context(|| format!("remove stale socket {endpoint}"))?; + } + + init_daemon_logging(); + let logs = LogBuffer::new(); + + let mut listener = Listener::bind(&endpoint).with_context(|| format!("bind IPC endpoint {endpoint}"))?; + let daemon = Daemon::new(logs, overlay); + + info!(%endpoint, "Daemon listening"); + + loop { + tokio::select! { + result = listener.accept() => { + let stream = result.context("accept IPC connection")?; + if let Err(error) = handle_connection(stream, &daemon).await { + debug!(error = format!("{error:#}"), "IPC connection error"); + } + } + _ = tokio::signal::ctrl_c() => { + info!("Received shutdown signal, stopping"); + break; + } + } + } + + Ok(()) +} + +async fn handle_connection(mut stream: S, daemon: &Daemon) -> anyhow::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let request: Request = read_message(&mut stream).await?; + trace!(?request, "Handling IPC request"); + let response = daemon.handle(request); + trace!(ok = response.is_ok(), "Replying to IPC request"); + write_message(&mut stream, &response).await?; + Ok(()) +} + +/// The daemon's mutable state: the (single) current session, plus the shared log buffer. +struct Daemon { + state: Mutex>, + logs: Arc, + /// Operator-provided overlay layered on top of every `Connect` (overlay wins). Holds any + /// preconfigured settings, credentials in particular. + overlay: PropertySet, + /// Whether [`Self::overlay`] contributes any secret (password/token) value, i.e. whether the + /// caller can omit credentials of its own. + credentials_loaded: bool, +} + +/// Per-session state owned by the request handler. +struct Session { + input_tx: mpsc::UnboundedSender, + input_db: Database, + destination: String, + live: Arc>, +} + +/// Per-session state shared with the output-consumer task. +struct Live { + /// Live property bag, seeded from `Config::properties` and updated on (re)negotiation. + properties: PropertySet, + state: ConnState, + error: Option, + /// Most recent frame (with the cursor already composited in by the session). Replaced on every + /// graphics update; `None` until the first frame arrives. + frame: Option, +} + +/// A decoded frame retained for screenshots. `pixels` are `0x00RRGGBB` (`to_be_bytes()` yields +/// `[0, R, G, B]`), row-major, `width * height` entries, with the remote cursor blended in. +struct Frame { + width: u16, + height: u16, + pixels: Vec, +} + +impl Daemon { + fn new(logs: Arc, overlay: PropertySet) -> Self { + // Credentials are considered "loaded" when the overlay provides at least one secret value, + // which is what frees the caller from supplying a password. + let credentials_loaded = overlay.iter().any(|(key, _)| ironrdp_cfg::is_secret_key(key)); + Self { + state: Mutex::new(None), + logs, + overlay, + credentials_loaded, + } + } + + fn handle(&self, request: Request) -> Response { + match request { + Request::Connect { + properties, + log_directive, + } => self.connect(properties, log_directive), + Request::Disconnect => self.disconnect(), + Request::Status => self.status(), + Request::QueryProps { filter } => self.query_props(filter.as_ref()), + Request::QueryLogs { substring, last } => self.query_logs(substring.as_deref(), last), + Request::Screenshot => self.screenshot(), + Request::MouseMove { x, y } => self.input(Operation::MouseMove(MousePosition { x, y })), + Request::MouseButton { button, pressed } => self.input(if pressed { + Operation::MouseButtonPressed(button) + } else { + Operation::MouseButtonReleased(button) + }), + Request::Wheel { delta, horizontal } => self.input(Operation::WheelRotations(WheelRotations { + is_vertical: !horizontal, + rotation_units: delta, + })), + Request::KeyScancode { scancode, pressed } => { + let scancode = Scancode::from_u16(scancode); + self.input(if pressed { + Operation::KeyPressed(scancode) + } else { + Operation::KeyReleased(scancode) + }) + } + Request::KeyUnicode { ch, pressed } => self.input(if pressed { + Operation::UnicodeKeyPressed(ch) + } else { + Operation::UnicodeKeyReleased(ch) + }), + Request::Resize { width, height } => self.resize(width, height), + } + } + + fn connect(&self, mut properties: PropertySet, log_directive: Option) -> Response { + debug!(?log_directive, "Received connect request"); + // Refuse to clobber a live session: the previous RDP engine runs on its own thread and is + // not torn down by simply replacing the session slot. Require an explicit `disconnect` first. + { + let guard = self.state.lock().expect("daemon state poisoned"); + if let Some(session) = guard.as_ref() { + let state = session.live.lock().expect("session live state poisoned").state; + if matches!( + state, + ConnState::Connecting | ConnState::Connected | ConnState::Disconnecting + ) { + debug!("Refusing connect: a session is already active"); + return Response::error("a session is already active; disconnect first"); + } + } + } + + // Layer the operator-provided overlay on top (overlay wins), so any setting — credentials + // in particular — can be preconfigured without the (possibly untrusted) caller supplying it. + properties.merge(&self.overlay); + + let builder = match ConfigBuilder::from_property_set(&properties) { + Ok(builder) => builder, + Err(error) => return Response::error(format!("invalid configuration: {error:#}")), + }; + + // Derive the headless client identity. These fields are never representable as `.rdp` + // properties and are never prompted; the daemon supplies them itself. + let builder = builder + .with_client_build(client_build()) + .with_client_dir("C:\\Windows\\System32\\mstscax.dll") + .with_platform(current_platform()) + .with_client_name(client_name()) + // Headless: composite the remote cursor into the framebuffer so it appears in + // screenshots (there is no separate overlay to draw it). + .with_pointer_software_rendering(true); + + let missing = builder.missing(); + if !missing.is_empty() { + return Response::error(format!( + "missing required fields: {}", + missing + .iter() + .map(MissingField::to_string) + .collect::>() + .join(", ") + )); + } + + let config = match builder.build() { + Ok(config) => config, + Err(error) => return Response::error(format!("{error:#}")), + }; + + // `ConfigBuilder::build` strips every secret property, so the live bag carries no secrets. + let live_seed = config.properties().clone(); + let destination = config.destination().to_string(); + + let (output_tx, output_rx) = mpsc::channel(16); + let client = RdpClient::new(config, output_tx); + let input_tx = client.input_sender(); + + let live = Arc::new(Mutex::new(Live { + properties: live_seed, + state: ConnState::Connecting, + error: None, + frame: None, + })); + + // Capture this session's logs into the ring buffer (queryable via `Request::QueryLogs`) + // instead of the daemon's terminal, refined by the caller-supplied directive. The dispatch + // is installed as the session thread's thread-local default below. + let dispatch = logbuf::session_dispatch(Arc::clone(&self.logs), log_directive.as_deref()); + + // The RDP client engine runs on its own thread with a current-thread runtime, mirroring + // `ironrdp-viewer`. This sidesteps any `Send` requirement on the connection future. + let spawn_result = std::thread::Builder::new() + .name("ironrdp-agent-session".to_owned()) + .spawn(move || { + tracing::dispatcher::with_default(&dispatch, || { + match tokio::runtime::Builder::new_current_thread().enable_all().build() { + Ok(runtime) => runtime.block_on(client.run()), + Err(error) => error!(%error, "Failed to build the session runtime"), + } + }); + }); + if let Err(error) = spawn_result { + return Response::error(format!("failed to spawn session thread: {error}")); + } + + tokio::spawn(consume_output(output_rx, Arc::clone(&live))); + + info!(%destination, "Started RDP session"); + + *self.state.lock().expect("daemon state poisoned") = Some(Session { + input_tx, + input_db: Database::new(), + destination, + live, + }); + + Response::ok() + } + + fn disconnect(&self) -> Response { + let mut guard = self.state.lock().expect("daemon state poisoned"); + match guard.as_mut() { + None => { + debug!("Disconnect requested but no session is active"); + Response::error("no active session") + } + Some(session) => { + let mut live = session.live.lock().expect("session live state poisoned"); + match live.state { + ConnState::Connecting | ConnState::Connected => { + info!(destination = %session.destination, "Disconnecting RDP session"); + // Request a graceful shutdown and move to `Disconnecting`. The engine thread + // keeps running until it drains the close; `consume_output` flips the state + // to a terminal one once it does, which is what re-enables `connect`. Leaving + // it `Connected` here would let a new `connect` race the still-live thread. + let _ = session.input_tx.send(RdpInputEvent::Close); + live.state = ConnState::Disconnecting; + Response::ok() + } + // Already shutting down or terminated: nothing to do (idempotent). + _ => Response::ok(), + } + } + } + } + + fn status(&self) -> Response { + let guard = self.state.lock().expect("daemon state poisoned"); + let info = match guard.as_ref() { + None => StatusInfo { + state: ConnState::NoSession, + destination: None, + width: None, + height: None, + message: None, + credentials_loaded: self.credentials_loaded, + }, + Some(session) => { + let live = session.live.lock().expect("session live state poisoned"); + let (width, height) = match &live.frame { + Some(frame) => (Some(frame.width), Some(frame.height)), + None => (None, None), + }; + StatusInfo { + state: live.state, + destination: Some(session.destination.clone()), + width, + height, + message: live.error.clone(), + credentials_loaded: self.credentials_loaded, + } + } + }; + Response::Ok(Payload::Status(info)) + } + + fn query_props(&self, filter: Option<&KeyFilter>) -> Response { + let guard = self.state.lock().expect("daemon state poisoned"); + let Some(session) = guard.as_ref() else { + return Response::error("no active session"); + }; + let live = session.live.lock().expect("session live state poisoned"); + + let mut entries = Vec::new(); + for (key, value) in live.properties.iter() { + let key = key.as_ref(); + if filter.is_some_and(|filter| !filter.matches(key)) { + continue; + } + let value = match value { + Value::Int(value) => PropValue::Int(*value), + Value::Str(value) => PropValue::Str(value.clone()), + }; + entries.push(PropertyEntry { + key: key.to_owned(), + value, + }); + } + + Response::Ok(Payload::Properties(PropertyDump { entries })) + } + + fn query_logs(&self, substring: Option<&str>, last: Option) -> Response { + let mut lines = self.logs.query(substring); + if let Some(last) = last { + let last = usize::try_from(last).unwrap_or(usize::MAX); + if last < lines.len() { + lines.drain(0..lines.len() - last); + } + } + Response::Ok(Payload::Logs(lines)) + } + + fn screenshot(&self) -> Response { + let guard = self.state.lock().expect("daemon state poisoned"); + let Some(session) = guard.as_ref() else { + return Response::error("no active session"); + }; + let live = session.live.lock().expect("session live state poisoned"); + let Some(frame) = live.frame.as_ref() else { + return Response::error("no frame available yet"); + }; + match encode_png(frame.width, frame.height, &frame.pixels) { + Ok(png) => { + debug!( + width = frame.width, + height = frame.height, + bytes = png.len(), + "Encoded screenshot" + ); + Response::Ok(Payload::Screenshot { + width: frame.width, + height: frame.height, + png, + }) + } + Err(error) => Response::error(format!("failed to encode screenshot: {error:#}")), + } + } + + fn resize(&self, width: u16, height: u16) -> Response { + if width == 0 || height == 0 { + return Response::error("width and height must be non-zero"); + } + let guard = self.state.lock().expect("daemon state poisoned"); + let Some(session) = guard.as_ref() else { + return Response::error("no active session"); + }; + match session.input_tx.send(RdpInputEvent::Resize { + width, + height, + // No window/DPI concept in a headless agent: request the plain pixel size unscaled. + scale_factor: 100, + physical_size: None, + }) { + Ok(()) => Response::ok(), + Err(_) => Response::error("session input channel is closed"), + } + } + + fn input(&self, operation: Operation) -> Response { + let mut guard = self.state.lock().expect("daemon state poisoned"); + let Some(session) = guard.as_mut() else { + return Response::error("no active session"); + }; + let events = session.input_db.apply([operation]); + if events.is_empty() { + return Response::ok(); + } + match session.input_tx.send(RdpInputEvent::FastPath(events)) { + Ok(()) => Response::ok(), + Err(_) => Response::error("session input channel is closed"), + } + } +} + +/// Consumes the bounded output-event stream, keeping the live state current. +async fn consume_output(mut output_rx: mpsc::Receiver, live: Arc>) { + while let Some(event) = output_rx.recv().await { + let mut guard = live.lock().expect("session live state poisoned"); + let previous = guard.state; + match event { + RdpOutputEvent::Image { buffer, width, height } => { + let width = width.get(); + let height = height.get(); + guard.properties.insert("desktopwidth", width); + guard.properties.insert("desktopheight", height); + guard.frame = Some(Frame { + width, + height, + pixels: buffer, + }); + guard.state = ConnState::Connected; + guard.error = None; + if previous != ConnState::Connected { + info!(width, height, "Session connected"); + } + } + RdpOutputEvent::ConnectionFailure(error) => { + guard.state = ConnState::Failed; + guard.error = Some(format!("{error}")); + error!(%error, "Session connection failed"); + } + RdpOutputEvent::Terminated(Ok(reason)) => { + guard.state = ConnState::Disconnected; + guard.error = Some(format!("{reason:?}")); + info!(?reason, "Session terminated"); + } + RdpOutputEvent::Terminated(Err(error)) => { + guard.state = ConnState::Failed; + guard.error = Some(format!("{error}")); + warn!(%error, "Session terminated with an error"); + } + // With software pointer rendering the cursor is composited into the `Image` frames + // above; the remaining pointer events (default/hidden) carry no live state we track. + _ => {} + } + } + + // The engine thread has ended (channel closed). Resolve any transient state so a subsequent + // `connect` is not blocked indefinitely, even if no explicit `Terminated` event was emitted. + let mut guard = live.lock().expect("session live state poisoned"); + if matches!( + guard.state, + ConnState::Connecting | ConnState::Connected | ConnState::Disconnecting + ) { + guard.state = ConnState::Disconnected; + } +} + +/// Encodes a retained framebuffer to PNG bytes. +/// +/// `pixels` are `0x00RRGGBB` (`to_be_bytes()` yields `[0, R, G, B]`); the leading byte is the unused +/// alpha placeholder, so we emit opaque 8-bit RGB. +fn encode_png(width: u16, height: u16, pixels: &[u32]) -> anyhow::Result> { + let mut rgb = Vec::with_capacity(pixels.len() * 3 /* RGB */); + for pixel in pixels { + let [_, r, g, b] = pixel.to_be_bytes(); + rgb.extend_from_slice(&[r, g, b]); + } + + let mut png = Vec::new(); + let mut encoder = png::Encoder::new(&mut png, u32::from(width), u32::from(height)); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().context("write PNG header")?; + writer.write_image_data(&rgb).context("write PNG image data")?; + writer.finish().context("finish PNG stream")?; + Ok(png) +} + +/// Installs the daemon's global tracing subscriber: a compact formatter to stderr, defaulting to +/// `INFO` and tunable via `IRONRDP_LOG`. +/// +/// This is the daemon's *own* operational logging (IPC handling, lifecycle), mirroring +/// `ironrdp-viewer` but quieter by default. The RDP session's logs are captured separately into a +/// ring buffer (see [`logbuf::session_dispatch`]). Best-effort: a no-op if a global subscriber is +/// already set. +fn init_daemon_logging() { + use tracing::level_filters::LevelFilter; + use tracing_subscriber::EnvFilter; + use tracing_subscriber::prelude::*; + + let env_filter = EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .with_env_var("IRONRDP_LOG") + .from_env_lossy(); + + let fmt_layer = tracing_subscriber::fmt::layer().compact().with_writer(std::io::stderr); + + let _ = tracing_subscriber::registry() + .with(env_filter) + .with(fmt_layer) + .try_init(); +} + +/// Derives a build number from the crate version (`major*100 + minor*10 + patch`). +fn client_build() -> u32 { + let mut parts = env!("CARGO_PKG_VERSION") + .split('.') + .map(|part| part.parse::().unwrap_or(0)); + let major = parts.next().unwrap_or(0); + let minor = parts.next().unwrap_or(0); + let patch = parts.next().unwrap_or(0); + major + .saturating_mul(100) + .saturating_add(minor.saturating_mul(10)) + .saturating_add(patch) +} + +fn client_name() -> String { + whoami::hostname().unwrap_or_else(|_| "ironrdp-agent".to_owned()) +} + +fn current_platform() -> MajorPlatformType { + match whoami::platform() { + whoami::Platform::Windows => MajorPlatformType::WINDOWS, + whoami::Platform::Linux => MajorPlatformType::UNIX, + whoami::Platform::Mac => MajorPlatformType::MACINTOSH, + whoami::Platform::Ios => MajorPlatformType::IOS, + whoami::Platform::Android => MajorPlatformType::ANDROID, + _ => MajorPlatformType::UNSPECIFIED, + } +} diff --git a/crates/ironrdp-agent/src/help.rs b/crates/ironrdp-agent/src/help.rs new file mode 100644 index 0000000000..429abbd161 --- /dev/null +++ b/crates/ironrdp-agent/src/help.rs @@ -0,0 +1,84 @@ +//! The `--help-agent` guide: a concise, structured, LLM-friendly description of every operation. + +/// Structured guide printed by `ironrdp-agent --help-agent`. +pub(crate) const AGENT_GUIDE: &str = r#"# ironrdp-agent + +A CLI-driven, daemon-backed RDP client. One binary plays two roles: + +- DAEMON: `ironrdp-agent daemon-start` runs a long-lived foreground process that owns the RDP + engine and one RDP session. Background it yourself (e.g. `ironrdp-agent daemon-start &`). +- CLI: every other subcommand opens the local IPC endpoint, sends one request, prints the + response, and exits. + +The daemon stays alive across CLI invocations. One daemon serves one RDP session. + +## Endpoint + +Unix: `$XDG_RUNTIME_DIR/ironrdp-agent-.sock` (falls back to `/tmp/ironrdp-agent-.sock`). +Windows: `\\.\pipe\ironrdp-agent-`. +Override with `--endpoint ` on any subcommand. + +## Lifecycle + +- `daemon-start [--overlay FILE] [--prop KEY:TYPE:VALUE]...` + Start the daemon (foreground). Run this first. `--overlay` + preloads a .rdp file as an overlay applied to every `connect` + (overlay wins), letting an operator provision any setting out of + band -- credentials in particular (e.g. the password). `--prop` is + repeatable and layers additional overlay properties on top of + `--overlay`, using the same `KEY:TYPE:VALUE` grammar as one .rdp + file line (TYPE is `i` for integer or `s` for string), e.g. + `--prop ironrdp_autologon:i:1`. Check `status` to see whether + credentials are already loaded before supplying any yourself. +- `connect [--rdp-file F] [--prop KEY:TYPE:VALUE]... [--server H[:PORT]] [-u USER] [-p PASS] [-d DOMAIN] [--log-directive D]` + Merge an optional .rdp file with CLI overrides into one config and + open a session. Precedence (low to high): .rdp file -> `--prop` + overrides -> named flags (`--server`/`-u`/`-p`/`-d`). `--prop` is + repeatable and lets you set any property without a dedicated flag + existing for it, e.g. `--prop username:s:admin`. The config is + validated by the daemon, which replies with an error listing any + missing or invalid fields. If `status` reports + `credentials loaded: true`, omit `-p/--password` (and any other + preloaded secret) -- the daemon supplies it. `--log-directive` + refines this session's log capture (e.g. `ironrdp_connector=trace`) + on top of the default `debug` level; use it to troubleshoot a + connection, then read the result with `query-logs`. +- `disconnect` Tear down the current session (daemon keeps running). +- `status` Report connection state, destination, last frame size, and whether + credentials are preloaded (`credentials loaded: true|false`). Query + this first to decide whether you must supply a password. + +## Inspection + +- `query-props [--filter SUBSTR] [--prefix PREFIX]` + Print the live session property bag, one `key = value` per line. + Secrets are stripped from the configuration before a session + starts, so the dump never contains passwords or tokens. + `--filter` matches keys by substring; `--prefix` by prefix + (both case-insensitive). +- `query-logs [--substring S] [--last N]` + Print retained RDP session log lines (a bounded in-memory ring + buffer, default level `debug`). `--substring` filters to matching + lines; `--last N` keeps the last N. Raise verbosity for a specific + session with `connect --log-directive`. This is the session's own + log; the daemon's operational log goes to stderr (default `info`, + tune with the `IRONRDP_LOG` env var). +- `screenshot [PATH]` Capture the most recent frame (with the mouse cursor composited in) + as a PNG and write it to PATH (default `screenshot.png`). Prints + `wrote PATH (WxH, N bytes)`. Errors with `no frame available yet` + until the first frame arrives. + +## Input (require an active session) + +- `mouse-move --x X --y Y` Move the pointer to an absolute position. +- `mouse-button --button --pressed ` +- `wheel --delta N [--horizontal]` Rotate the wheel (negative N scrolls down/left). +- `key-scancode --scancode <0x1D|29> --pressed ` +- `key-unicode --char C --pressed ` Type by Unicode character. +- `resize --width W --height H` Resize the remote desktop. + +## Errors + +Failures print a single lowercase message (no trailing punctuation) and exit non-zero. A failed +`connect` carries the list of missing required fields. +"#; diff --git a/crates/ironrdp-agent/src/ipc.rs b/crates/ironrdp-agent/src/ipc.rs new file mode 100644 index 0000000000..1611643090 --- /dev/null +++ b/crates/ironrdp-agent/src/ipc.rs @@ -0,0 +1,797 @@ +//! Strictly-typed IPC schema (V1) and its binary codec. +//! +//! # Framing +//! +//! Every message is sent length-delimited: a little-endian `u32` byte-count prefix followed by the +//! `Encode`d body. The framing is identical over Unix domain sockets and Windows named pipes (see +//! [`crate::transport`]). Both ends are the same binary at the same version, so there is no version +//! byte and no forward/backward-compatibility handling. +//! +//! # Schema +//! +//! Connection configuration travels as a binary-encoded [`PropertySet`] inside [`Request::Connect`]; +//! everything else is a strictly-typed message. See [`Request`]/[`Response`]. + +use core::fmt; + +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_size}; +use ironrdp_input::MouseButton; +use ironrdp_pdu::impl_pdu_pod; +use ironrdp_propertyset::PropertySet; + +use crate::wire::propertyset; +use crate::wire::{ + bytes_size, opt_string_size, opt_u16_size, read_bool, read_bytes, read_char, read_mouse_button, read_opt_string, + read_opt_u16, read_string, string_size, write_bool, write_bytes, write_char, write_mouse_button, write_opt_string, + write_opt_u16, write_string, +}; + +/// A request sent by the CLI to the daemon. +/// +/// `Connect` carries a binary-encoded [`PropertySet`] — never `argv` or CLI strings. Runtime +/// operations are strictly-typed. +#[derive(Clone, PartialEq, Eq)] +pub enum Request { + /// Start an RDP session from a fully-merged property bag. + /// + /// `log_directive`, when set, is a [`tracing`]-style filter directive applied to *this* + /// session's log capture (e.g. `ironrdp_connector=trace`), layered on top of the default + /// `DEBUG` level. It lets a caller raise verbosity up-front to troubleshoot a connection. + Connect { + properties: PropertySet, + log_directive: Option, + }, + /// Tear down the current RDP session (the daemon keeps running). + Disconnect, + /// Query the current session status. + Status, + /// Query the live session property bag, optionally filtered. + QueryProps { filter: Option }, + /// Return retained log lines, optionally filtered by substring and/or limited to the last `n`. + QueryLogs { + substring: Option, + last: Option, + }, + /// Capture the most recent frame (cursor composited in) as a PNG. + Screenshot, + /// Move the mouse pointer to an absolute position. + MouseMove { x: u16, y: u16 }, + /// Press or release a mouse button. + MouseButton { button: MouseButton, pressed: bool }, + /// Rotate the mouse wheel. + Wheel { delta: i16, horizontal: bool }, + // TODO: questioning whether we need a way to send multiple keys at once, e.g. a small mini + // format to express in a single command that keys A and B are pressed while key C is released. + // This could save LLM tokens by collapsing several round-trips into one request. + /// Press or release a key identified by its RDP scancode. + KeyScancode { scancode: u16, pressed: bool }, + /// Press or release a key identified by a Unicode character. + KeyUnicode { ch: char, pressed: bool }, + /// Resize the remote desktop. + Resize { width: u16, height: u16 }, + // TODO: add clipboard support (CLIPRDR), e.g. requests to read the remote clipboard text and to + // set it, so an LLM can copy/paste to and from the session. +} + +// Manual `Debug` so the `Connect` payload's property *values* (which may include a password before +// it reaches `ConfigBuilder::build`) are never printed verbatim; only the keys are shown. +impl fmt::Debug for Request { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Connect { + properties, + log_directive, + } => f + .debug_struct("Connect") + .field("properties", &PropertyKeys(properties)) + .field("log_directive", log_directive) + .finish(), + Self::Disconnect => f.write_str("Disconnect"), + Self::Status => f.write_str("Status"), + Self::QueryProps { filter } => f.debug_struct("QueryProps").field("filter", filter).finish(), + Self::QueryLogs { substring, last } => f + .debug_struct("QueryLogs") + .field("substring", substring) + .field("last", last) + .finish(), + Self::Screenshot => f.write_str("Screenshot"), + Self::MouseMove { x, y } => f.debug_struct("MouseMove").field("x", x).field("y", y).finish(), + Self::MouseButton { button, pressed } => f + .debug_struct("MouseButton") + .field("button", button) + .field("pressed", pressed) + .finish(), + Self::Wheel { delta, horizontal } => f + .debug_struct("Wheel") + .field("delta", delta) + .field("horizontal", horizontal) + .finish(), + Self::KeyScancode { scancode, pressed } => f + .debug_struct("KeyScancode") + .field("scancode", scancode) + .field("pressed", pressed) + .finish(), + Self::KeyUnicode { ch, pressed } => f + .debug_struct("KeyUnicode") + .field("ch", ch) + .field("pressed", pressed) + .finish(), + Self::Resize { width, height } => f + .debug_struct("Resize") + .field("width", width) + .field("height", height) + .finish(), + } + } +} + +/// A [`PropertySet`] whose `Debug` output lists only the keys, never the (possibly secret) values. +struct PropertyKeys<'a>(&'a PropertySet); + +impl fmt::Debug for PropertyKeys<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_set().entries(self.0.iter().map(|(key, _)| key)).finish() + } +} + +/// The daemon's reply to a [`Request`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Response { + /// Success, carrying an operation-specific [`Payload`]. + Ok(Payload), + /// Failure. The message is lowercase with no trailing punctuation. + Err(String), +} + +impl Response { + /// A successful response with no payload. + pub fn ok() -> Self { + Self::Ok(Payload::Empty) + } + + /// A failure response. + pub fn error(message: impl Into) -> Self { + Self::Err(message.into()) + } + + /// Whether this is a success response. + pub fn is_ok(&self) -> bool { + matches!(self, Self::Ok(_)) + } +} + +/// The success payload carried by [`Response::Ok`]. +#[derive(Clone, PartialEq, Eq)] +pub enum Payload { + /// No data. + Empty, + /// Current session status. + Status(StatusInfo), + /// A dump of the live property bag. + Properties(PropertyDump), + /// Retained log lines. + Logs(Vec), + /// The most recent frame encoded as a PNG (cursor included), with its dimensions. + Screenshot { width: u16, height: u16, png: Vec }, +} + +impl fmt::Debug for Payload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => f.write_str("Empty"), + Self::Status(status) => f.debug_tuple("Status").field(status).finish(), + Self::Properties(dump) => f.debug_tuple("Properties").field(dump).finish(), + Self::Logs(lines) => f.debug_tuple("Logs").field(lines).finish(), + // Print the PNG byte length rather than the (large, binary) blob. + Self::Screenshot { width, height, png } => f + .debug_struct("Screenshot") + .field("width", width) + .field("height", height) + .field("png_len", &png.len()) + .finish(), + } + } +} + +/// Coarse connection state reported by [`Request::Status`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnState { + /// No session has been started. + NoSession, + /// A session was started and is connecting. + Connecting, + /// A session is active (at least one frame received). + Connected, + /// A graceful disconnect was requested; the engine thread is still shutting down. + Disconnecting, + /// A session terminated gracefully. + Disconnected, + /// A session failed. + Failed, +} + +impl ConnState { + fn tag(self) -> u8 { + match self { + Self::NoSession => 0, + Self::Connecting => 1, + Self::Connected => 2, + Self::Disconnected => 3, + Self::Failed => 4, + Self::Disconnecting => 5, + } + } + + fn from_tag(tag: u8) -> DecodeResult { + match tag { + 0 => Ok(Self::NoSession), + 1 => Ok(Self::Connecting), + 2 => Ok(Self::Connected), + 3 => Ok(Self::Disconnected), + 4 => Ok(Self::Failed), + 5 => Ok(Self::Disconnecting), + _ => Err(ironrdp_core::invalid_field_err!("connection state", "unknown tag")), + } + } +} + +/// Status snapshot returned by [`Request::Status`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusInfo { + /// Coarse connection state. + pub state: ConnState, + /// RDP target (`host:port`), if a session exists. + pub destination: Option, + /// Most recent frame width, if any. + pub width: Option, + /// Most recent frame height, if any. + pub height: Option, + /// Human-readable detail, e.g. the failure reason. + pub message: Option, + /// `true` when the daemon was started with preloaded credentials (an operator-provided overlay). + /// + /// When set, a caller driving `connect` does not need to supply a password (or other secrets): + /// the daemon layers the overlay on top of the request before building the configuration. + pub credentials_loaded: bool, +} + +/// A bulk dump of live properties. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PropertyDump { + /// One entry per property, in key order. + pub entries: Vec, +} + +/// A single dumped property. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PropertyEntry { + /// Property key. + pub key: String, + /// Property value. + pub value: PropValue, +} + +/// A dumped property value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PropValue { + /// Integer value. + Int(i64), + /// String value. + Str(String), +} + +/// A small key filter for [`Request::QueryProps`]. Matching is case-insensitive. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeyFilter { + /// Match keys containing this substring. + Substring(String), + /// Match keys starting with this prefix. + Prefix(String), +} + +impl KeyFilter { + /// Returns `true` when `key` matches this filter (case-insensitive). + pub fn matches(&self, key: &str) -> bool { + let key = key.to_ascii_lowercase(); + match self { + Self::Substring(needle) => key.contains(&needle.to_ascii_lowercase()), + Self::Prefix(prefix) => key.starts_with(&prefix.to_ascii_lowercase()), + } + } +} + +// ── KeyFilter codec ───────────────────────────────────────────────────────── + +impl Encode for KeyFilter { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + match self { + Self::Substring(value) => { + dst.write_u8(0); + write_string(dst, value) + } + Self::Prefix(value) => { + dst.write_u8(1); + write_string(dst, value) + } + } + } + + fn name(&self) -> &'static str { + "ironrdp_agent::KeyFilter" + } + + fn size(&self) -> usize { + let value = match self { + Self::Substring(value) | Self::Prefix(value) => value, + }; + 1 /* tag */ + string_size(value) + } +} + +impl Decode<'_> for KeyFilter { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + match src.read_u8() { + 0 => Ok(Self::Substring(read_string(src)?)), + 1 => Ok(Self::Prefix(read_string(src)?)), + _ => Err(ironrdp_core::invalid_field_err!("key filter", "unknown tag")), + } + } +} + +impl_pdu_pod!(KeyFilter); + +// ── PropValue / PropertyEntry / PropertyDump codec ────────────────────────── + +impl Encode for PropValue { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + match self { + Self::Int(value) => { + dst.write_u8(0); + dst.write_i64(*value); + } + Self::Str(value) => { + dst.write_u8(1); + write_string(dst, value)?; + } + } + Ok(()) + } + + fn name(&self) -> &'static str { + "ironrdp_agent::PropValue" + } + + fn size(&self) -> usize { + 1 /* tag */ + + match self { + Self::Int(_) => 8, + Self::Str(value) => string_size(value), + } + } +} + +impl Decode<'_> for PropValue { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + match src.read_u8() { + 0 => { + ensure_size!(in: src, size: 8); + Ok(Self::Int(src.read_i64())) + } + 1 => Ok(Self::Str(read_string(src)?)), + _ => Err(ironrdp_core::invalid_field_err!("property value", "unknown tag")), + } + } +} + +impl_pdu_pod!(PropValue); + +impl Encode for PropertyEntry { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + write_string(dst, &self.key)?; + self.value.encode(dst) + } + + fn name(&self) -> &'static str { + "ironrdp_agent::PropertyEntry" + } + + fn size(&self) -> usize { + string_size(&self.key) + self.value.size() + } +} + +impl Decode<'_> for PropertyEntry { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let key = read_string(src)?; + let value = PropValue::decode(src)?; + Ok(Self { key, value }) + } +} + +impl_pdu_pod!(PropertyEntry); + +impl Encode for PropertyDump { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + let count: u32 = cast_length!("property count", self.entries.len())?; + dst.write_u32(count); + for entry in &self.entries { + entry.encode(dst)?; + } + Ok(()) + } + + fn name(&self) -> &'static str { + "ironrdp_agent::PropertyDump" + } + + fn size(&self) -> usize { + 4 /* count */ + self.entries.iter().map(Encode::size).sum::() + } +} + +impl Decode<'_> for PropertyDump { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 4); + let count = src.read_u32(); + let mut entries = Vec::new(); + for _ in 0..count { + entries.push(PropertyEntry::decode(src)?); + } + Ok(Self { entries }) + } +} + +impl_pdu_pod!(PropertyDump); + +// ── StatusInfo codec ──────────────────────────────────────────────────────── + +impl Encode for StatusInfo { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + dst.write_u8(self.state.tag()); + write_opt_string(dst, self.destination.as_deref())?; + write_opt_u16(dst, self.width)?; + write_opt_u16(dst, self.height)?; + write_opt_string(dst, self.message.as_deref())?; + write_bool(dst, self.credentials_loaded) + } + + fn name(&self) -> &'static str { + "ironrdp_agent::StatusInfo" + } + + fn size(&self) -> usize { + 1 /* state */ + + opt_string_size(self.destination.as_deref()) + + opt_u16_size(self.width) + + opt_u16_size(self.height) + + opt_string_size(self.message.as_deref()) + + 1 /* credentials_loaded */ + } +} + +impl Decode<'_> for StatusInfo { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + let state = ConnState::from_tag(src.read_u8())?; + let destination = read_opt_string(src)?; + let width = read_opt_u16(src)?; + let height = read_opt_u16(src)?; + let message = read_opt_string(src)?; + let credentials_loaded = read_bool(src)?; + Ok(Self { + state, + destination, + width, + height, + message, + credentials_loaded, + }) + } +} + +impl_pdu_pod!(StatusInfo); + +// ── Payload codec ─────────────────────────────────────────────────────────── + +impl Encode for Payload { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + match self { + Self::Empty => dst.write_u8(0), + Self::Status(status) => { + dst.write_u8(1); + status.encode(dst)?; + } + Self::Properties(dump) => { + dst.write_u8(2); + dump.encode(dst)?; + } + Self::Logs(lines) => { + dst.write_u8(3); + let count: u32 = cast_length!("log line count", lines.len())?; + dst.write_u32(count); + for line in lines { + write_string(dst, line)?; + } + } + Self::Screenshot { width, height, png } => { + dst.write_u8(4); + dst.write_u16(*width); + dst.write_u16(*height); + write_bytes(dst, png)?; + } + } + Ok(()) + } + + fn name(&self) -> &'static str { + "ironrdp_agent::Payload" + } + + fn size(&self) -> usize { + 1 /* tag */ + + match self { + Self::Empty => 0, + Self::Status(status) => status.size(), + Self::Properties(dump) => dump.size(), + Self::Logs(lines) => 4 + lines.iter().map(|line| string_size(line)).sum::(), + Self::Screenshot { png, .. } => 2 /* width */ + 2 /* height */ + bytes_size(png), + } + } +} + +impl Decode<'_> for Payload { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + match src.read_u8() { + 0 => Ok(Self::Empty), + 1 => Ok(Self::Status(StatusInfo::decode(src)?)), + 2 => Ok(Self::Properties(PropertyDump::decode(src)?)), + 3 => { + ensure_size!(in: src, size: 4); + let count = src.read_u32(); + let mut lines = Vec::new(); + for _ in 0..count { + lines.push(read_string(src)?); + } + Ok(Self::Logs(lines)) + } + 4 => { + ensure_size!(in: src, size: 4); + let width = src.read_u16(); + let height = src.read_u16(); + let png = read_bytes(src)?; + Ok(Self::Screenshot { width, height, png }) + } + _ => Err(ironrdp_core::invalid_field_err!("payload", "unknown tag")), + } + } +} + +impl_pdu_pod!(Payload); + +// ── Response codec ────────────────────────────────────────────────────────── + +impl Encode for Response { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + match self { + Self::Ok(payload) => { + dst.write_u8(0); + payload.encode(dst) + } + Self::Err(message) => { + dst.write_u8(1); + write_string(dst, message) + } + } + } + + fn name(&self) -> &'static str { + "ironrdp_agent::Response" + } + + fn size(&self) -> usize { + 1 /* tag */ + + match self { + Self::Ok(payload) => payload.size(), + Self::Err(message) => string_size(message), + } + } +} + +impl Decode<'_> for Response { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + match src.read_u8() { + 0 => Ok(Self::Ok(Payload::decode(src)?)), + 1 => Ok(Self::Err(read_string(src)?)), + _ => Err(ironrdp_core::invalid_field_err!("response", "unknown tag")), + } + } +} + +impl_pdu_pod!(Response); + +// ── Request codec ─────────────────────────────────────────────────────────── + +impl Encode for Request { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + match self { + Self::Connect { + properties, + log_directive, + } => { + dst.write_u8(0); + propertyset::write(properties, dst)?; + write_opt_string(dst, log_directive.as_deref())?; + } + Self::Disconnect => dst.write_u8(1), + Self::Status => dst.write_u8(2), + Self::QueryProps { filter } => { + dst.write_u8(3); + match filter { + Some(filter) => { + dst.write_u8(1); + filter.encode(dst)?; + } + None => dst.write_u8(0), + } + } + Self::QueryLogs { substring, last } => { + dst.write_u8(4); + write_opt_string(dst, substring.as_deref())?; + match last { + Some(last) => { + dst.write_u8(1); + dst.write_u32(*last); + } + None => dst.write_u8(0), + } + } + Self::Screenshot => dst.write_u8(5), + Self::MouseMove { x, y } => { + dst.write_u8(6); + dst.write_u16(*x); + dst.write_u16(*y); + } + Self::MouseButton { button, pressed } => { + dst.write_u8(7); + write_mouse_button(dst, *button)?; + write_bool(dst, *pressed)?; + } + Self::Wheel { delta, horizontal } => { + dst.write_u8(8); + dst.write_i16(*delta); + write_bool(dst, *horizontal)?; + } + Self::KeyScancode { scancode, pressed } => { + dst.write_u8(9); + dst.write_u16(*scancode); + write_bool(dst, *pressed)?; + } + Self::KeyUnicode { ch, pressed } => { + dst.write_u8(10); + write_char(dst, *ch)?; + write_bool(dst, *pressed)?; + } + Self::Resize { width, height } => { + dst.write_u8(11); + dst.write_u16(*width); + dst.write_u16(*height); + } + } + Ok(()) + } + + fn name(&self) -> &'static str { + "ironrdp_agent::Request" + } + + fn size(&self) -> usize { + 1 /* tag */ + + match self { + Self::Connect { properties, log_directive } => { + propertyset::size(properties) + opt_string_size(log_directive.as_deref()) + } + Self::Disconnect | Self::Status | Self::Screenshot => 0, + Self::QueryProps { filter } => 1 /* presence */ + filter.as_ref().map_or(0, Encode::size), + Self::QueryLogs { substring, last } => { + opt_string_size(substring.as_deref()) + 1 /* presence */ + last.map_or(0, |_| 4) + } + Self::MouseMove { .. } => 2 /* x */ + 2 /* y */, + Self::MouseButton { .. } => 1 /* button */ + 1 /* pressed */, + Self::Wheel { .. } => 2 /* delta */ + 1 /* horizontal */, + Self::KeyScancode { .. } => 2 /* scancode */ + 1 /* pressed */, + Self::KeyUnicode { .. } => 4 /* ch */ + 1 /* pressed */, + Self::Resize { .. } => 2 /* width */ + 2 /* height */, + } + } +} + +impl Decode<'_> for Request { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + match src.read_u8() { + 0 => { + let mut properties = PropertySet::new(); + propertyset::read(&mut properties, src)?; + let log_directive = read_opt_string(src)?; + Ok(Self::Connect { + properties, + log_directive, + }) + } + 1 => Ok(Self::Disconnect), + 2 => Ok(Self::Status), + 3 => { + ensure_size!(in: src, size: 1); + let filter = match src.read_u8() { + 0 => None, + 1 => Some(KeyFilter::decode(src)?), + _ => return Err(ironrdp_core::invalid_field_err!("dump filter", "invalid presence flag")), + }; + Ok(Self::QueryProps { filter }) + } + 4 => { + let substring = read_opt_string(src)?; + ensure_size!(in: src, size: 1); + let last = match src.read_u8() { + 0 => None, + 1 => { + ensure_size!(in: src, size: 4); + Some(src.read_u32()) + } + _ => return Err(ironrdp_core::invalid_field_err!("query last", "invalid presence flag")), + }; + Ok(Self::QueryLogs { substring, last }) + } + 5 => Ok(Self::Screenshot), + 6 => { + ensure_size!(in: src, size: 4); + let x = src.read_u16(); + let y = src.read_u16(); + Ok(Self::MouseMove { x, y }) + } + 7 => { + let button = read_mouse_button(src)?; + let pressed = read_bool(src)?; + Ok(Self::MouseButton { button, pressed }) + } + 8 => { + ensure_size!(in: src, size: 2); + let delta = src.read_i16(); + let horizontal = read_bool(src)?; + Ok(Self::Wheel { delta, horizontal }) + } + 9 => { + ensure_size!(in: src, size: 2); + let scancode = src.read_u16(); + let pressed = read_bool(src)?; + Ok(Self::KeyScancode { scancode, pressed }) + } + 10 => { + let ch = read_char(src)?; + let pressed = read_bool(src)?; + Ok(Self::KeyUnicode { ch, pressed }) + } + 11 => { + ensure_size!(in: src, size: 4); + let width = src.read_u16(); + let height = src.read_u16(); + Ok(Self::Resize { width, height }) + } + _ => Err(ironrdp_core::invalid_field_err!("request", "unknown tag")), + } + } +} + +impl_pdu_pod!(Request); diff --git a/crates/ironrdp-agent/src/lib.rs b/crates/ironrdp-agent/src/lib.rs new file mode 100644 index 0000000000..7028107a22 --- /dev/null +++ b/crates/ironrdp-agent/src/lib.rs @@ -0,0 +1,27 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] +#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] + +//! A CLI-driven, daemon-backed agentic RDP client. +//! +//! The public surface is intentionally small and split into: +//! +//! - [`ipc`]: the strictly-typed request/response schema and its binary codec. +//! - [`transport`]: the local IPC transport (Unix socket / Windows named pipe) and framing. +//! - [`daemon`]: the long-lived daemon driver. +//! - [`cli`]: the short-lived CLI driver. + +pub mod cli; +pub mod daemon; +pub mod ipc; +pub mod transport; + +pub(crate) mod help; +pub(crate) mod logbuf; + +// The wire codec helpers are internal, but the `internal` feature exposes them (hidden from docs) +// so they can be unit tested from the workspace test suite. +#[cfg(feature = "internal")] +#[doc(hidden)] +pub mod wire; +#[cfg(not(feature = "internal"))] +pub(crate) mod wire; diff --git a/crates/ironrdp-agent/src/logbuf.rs b/crates/ironrdp-agent/src/logbuf.rs new file mode 100644 index 0000000000..7d858dc2d8 --- /dev/null +++ b/crates/ironrdp-agent/src/logbuf.rs @@ -0,0 +1,147 @@ +//! The RDP session log ring buffer and its [`tracing`] layer. +//! +//! The logs emitted while driving the RDP engine are captured into a small, queryable +//! [`LogBuffer`] ring (read via `Request::QueryLogs`) instead of the terminal. The capture is +//! installed as a thread-local subscriber for the session thread only (see [`session_dispatch`] and +//! [`tracing::dispatcher::with_default`]), so it never becomes the global subscriber. It defaults +//! to `DEBUG`, which is useful when inspecting a session, and a per-`Connect` directive can refine +//! the filter (e.g. `ironrdp_connector=trace`) to troubleshoot IronRDP itself. +//! +//! The daemon's *own* operational logging is a separate concern; see +//! [`crate::daemon`]'s global subscriber setup. + +use core::fmt::Write as _; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use tracing::field::{Field, Visit}; +use tracing::{Dispatch, Event, Subscriber}; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::Context; + +/// Default ring-buffer capacity, in lines. +const DEFAULT_CAPACITY: usize = 100; + +/// A bounded ring buffer of formatted log lines. +pub(crate) struct LogBuffer { + inner: Mutex, +} + +struct Inner { + capacity: usize, + lines: VecDeque, +} + +impl LogBuffer { + pub(crate) fn new() -> Arc { + Self::with_capacity(DEFAULT_CAPACITY) + } + + pub(crate) fn with_capacity(capacity: usize) -> Arc { + Arc::new(Self { + inner: Mutex::new(Inner { + capacity: capacity.max(1), + lines: VecDeque::new(), + }), + }) + } + + fn push(&self, line: String) { + let mut inner = self.inner.lock().expect("log buffer poisoned"); + + if inner.capacity <= inner.lines.len() { + inner.lines.pop_front(); + } + inner.lines.push_back(line); + } + + /// Returns retained lines, optionally filtered to those containing `substring`. + pub(crate) fn query(&self, substring: Option<&str>) -> Vec { + let inner = self.inner.lock().expect("log buffer poisoned"); + inner + .lines + .iter() + .filter(|line| substring.is_none_or(|needle| line.contains(needle))) + .cloned() + .collect() + } +} + +/// Builds a session-scoped [`Dispatch`] that routes the RDP session's logs into `buffer`. +/// +/// The session runs on its own thread; wrapping its execution in +/// [`tracing::dispatcher::with_default`] keeps the engine's events out of the daemon's terminal and +/// in the ring buffer instead. The default level is `DEBUG`; `directive` (carried by +/// `Request::Connect`) refines it per-session — a bare level sets the global session level, while a +/// targeted directive (e.g. `ironrdp_connector=trace`) layers on top of the `DEBUG` default. +pub(crate) fn session_dispatch(buffer: Arc, directive: Option<&str>) -> Dispatch { + use tracing::level_filters::LevelFilter; + use tracing_subscriber::EnvFilter; + use tracing_subscriber::prelude::*; + + let env_filter = EnvFilter::builder() + .with_default_directive(LevelFilter::DEBUG.into()) + .parse_lossy(directive.unwrap_or("")); + + let subscriber = tracing_subscriber::registry() + .with(env_filter) + .with(LogLayer::new(buffer)); + + Dispatch::new(subscriber) +} + +/// A tracing [`Layer`] that formats each event into a single line and pushes it to a [`LogBuffer`]. +struct LogLayer { + buffer: Arc, +} + +impl LogLayer { + fn new(buffer: Arc) -> Self { + Self { buffer } + } +} + +impl Layer for LogLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let meta = event.metadata(); + + let mut visitor = LogVisitor { + message: None, + fields: String::new(), + }; + event.record(&mut visitor); + + let mut line = String::new(); + let _ = write!(line, "{:>5} {}", meta.level(), meta.target()); + if let Some(message) = &visitor.message { + let _ = write!(line, " {message}"); + } + line.push_str(&visitor.fields); + + self.buffer.push(line); + } +} + +/// Collects an event's message and structured fields into strings. +struct LogVisitor { + message: Option, + fields: String, +} + +impl Visit for LogVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn core::fmt::Debug) { + if field.name() == "message" { + self.message = Some(format!("{value:?}")); + } else { + let _ = write!(self.fields, " {}={:?}", field.name(), value); + } + } + + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message = Some(value.to_owned()); + } else { + let _ = write!(self.fields, " {}={}", field.name(), value); + } + } +} diff --git a/crates/ironrdp-agent/src/main.rs b/crates/ironrdp-agent/src/main.rs new file mode 100644 index 0000000000..2d42729e21 --- /dev/null +++ b/crates/ironrdp-agent/src/main.rs @@ -0,0 +1,10 @@ +// The binary uses only a subset of the library's dependencies; the rest are used by the lib target. +#![allow(unused_crate_dependencies)] + +use clap::Parser as _; +use ironrdp_agent::cli::Cli; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + ironrdp_agent::cli::run(Cli::parse()).await +} diff --git a/crates/ironrdp-agent/src/transport.rs b/crates/ironrdp-agent/src/transport.rs new file mode 100644 index 0000000000..405ab72365 --- /dev/null +++ b/crates/ironrdp-agent/src/transport.rs @@ -0,0 +1,195 @@ +//! Local IPC transport and message framing. +//! +//! The daemon and CLI talk over a platform-native local transport: +//! +//! - **Unix**: a [`tokio::net::UnixListener`]/[`tokio::net::UnixStream`] at +//! `$XDG_RUNTIME_DIR/ironrdp-agent-.sock`, falling back to `/tmp/ironrdp-agent-.sock` +//! when `XDG_RUNTIME_DIR` is unset. +//! - **Windows**: a named pipe at `\\.\pipe\ironrdp-agent-`. +//! +//! Framing is identical on both: a little-endian `u32` byte-count prefix followed by the `Encode`d +//! message body. + +use anyhow::{Context as _, bail}; +use ironrdp_core::{DecodeOwned, Encode}; +use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _}; + +use crate::ipc::{Request, Response}; + +/// Upper bound on a single framed message, guarding against absurd length prefixes. +const MAX_MESSAGE_LEN: usize = 16 * 1024 * 1024; + +/// Writes `message` to `stream`, length-delimited. +pub(crate) async fn write_message(stream: &mut S, message: &M) -> anyhow::Result<()> +where + S: AsyncWrite + Unpin, + M: Encode, +{ + let body = ironrdp_core::encode_vec(message).map_err(|e| anyhow::anyhow!("encode {}: {e}", message.name()))?; + let len = u32::try_from(body.len()).context("message too large to frame")?; + stream + .write_all(&len.to_le_bytes()) + .await + .context("write frame length")?; + stream.write_all(&body).await.context("write frame body")?; + stream.flush().await.context("flush frame")?; + Ok(()) +} + +/// Reads a single length-delimited message from `stream`. +pub(crate) async fn read_message(stream: &mut S) -> anyhow::Result +where + S: AsyncRead + Unpin, + M: DecodeOwned, +{ + let mut len_buf = [0u8; 4]; + stream.read_exact(&mut len_buf).await.context("read frame length")?; + let len = usize::try_from(u32::from_le_bytes(len_buf)).expect("u32 fits in usize on supported platforms"); + if MAX_MESSAGE_LEN < len { + bail!("frame length {len} exceeds the {MAX_MESSAGE_LEN}-byte limit"); + } + let mut body = vec![0u8; len]; + stream.read_exact(&mut body).await.context("read frame body")?; + ironrdp_core::decode_owned(&body).map_err(|e| anyhow::anyhow!("decode: {e}")) +} + +/// Opens the endpoint, sends one `request`, and returns the daemon's `Response`. +pub async fn send_request(endpoint: &Endpoint, request: &Request) -> anyhow::Result { + let mut stream = connect(endpoint) + .await + .with_context(|| format!("connect to daemon at {endpoint}"))?; + write_message(&mut stream, request).await?; + read_message(&mut stream).await +} + +#[cfg(unix)] +mod imp { + use std::io; + use std::path::PathBuf; + + use tokio::net::{UnixListener, UnixStream}; + + /// A resolved IPC endpoint (a Unix domain socket path). + #[derive(Debug, Clone)] + pub struct Endpoint(pub PathBuf); + + impl core::fmt::Display for Endpoint { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.0.display()) + } + } + + /// Returns the default per-user endpoint. + pub fn default_endpoint() -> Endpoint { + // SAFETY: `getuid` has no preconditions and is always safe to call. + let uid = unsafe { libc::getuid() }; + let dir = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/tmp")); + Endpoint(dir.join(format!("ironrdp-agent-{uid}.sock"))) + } + + /// Connects to a listening daemon. + pub async fn connect(endpoint: &Endpoint) -> io::Result { + UnixStream::connect(&endpoint.0).await + } + + /// A bound listener that accepts client connections. + pub struct Listener { + inner: UnixListener, + path: PathBuf, + } + + impl Listener { + /// Binds the listener at `endpoint`. + pub fn bind(endpoint: &Endpoint) -> io::Result { + let inner = UnixListener::bind(&endpoint.0)?; + // Restrict the socket to the owner. The fallback directory is world-writable `/tmp`, so + // without this any local user could connect and drive the session (input, screenshots, + // logs). Fail loudly rather than serve on a world-accessible endpoint. + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&endpoint.0, std::fs::Permissions::from_mode(0o600))?; + Ok(Self { + inner, + path: endpoint.0.clone(), + }) + } + + /// Accepts the next client connection. + pub async fn accept(&mut self) -> io::Result { + let (stream, _addr) = self.inner.accept().await?; + Ok(stream) + } + } + + impl Drop for Listener { + fn drop(&mut self) { + // Best-effort removal of the socket file on shutdown (named pipes need no cleanup). + let _ = std::fs::remove_file(&self.path); + } + } +} + +#[cfg(windows)] +mod imp { + use std::io; + + use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient, NamedPipeServer, ServerOptions}; + + /// A resolved IPC endpoint (a named pipe path). + #[derive(Debug, Clone)] + pub struct Endpoint(pub String); + + impl core::fmt::Display for Endpoint { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.0) + } + } + + /// Returns the default per-user endpoint. + pub fn default_endpoint() -> Endpoint { + let user = whoami::username().unwrap_or_else(|_| "user".to_owned()); + Endpoint(format!(r"\\.\pipe\ironrdp-agent-{user}")) + } + + /// Connects to a listening daemon. + pub async fn connect(endpoint: &Endpoint) -> io::Result { + ClientOptions::new().open(&endpoint.0) + } + + /// A named-pipe listener. + /// + /// It always keeps one ready (unconnected) server instance alive, which is both what serves the + /// next connection and what upholds the `first_pipe_instance` exclusivity (a pipe with no live + /// instance would let a second daemon claim the name). + pub struct Listener { + name: String, + ready: NamedPipeServer, + } + + impl Listener { + /// Creates the first pipe instance, claiming the name exclusively. + /// + /// `first_pipe_instance(true)` makes this fail with `ERROR_ACCESS_DENIED` if another daemon + /// already owns the pipe, so two daemons cannot coexist on the same endpoint. + pub fn bind(endpoint: &Endpoint) -> io::Result { + let ready = ServerOptions::new().first_pipe_instance(true).create(&endpoint.0)?; + Ok(Self { + name: endpoint.0.clone(), + ready, + }) + } + + /// Waits for the next client to connect to the ready instance, then mints a replacement. + pub async fn accept(&mut self) -> io::Result { + // Connect by reference so a cancelled future leaves `ready` intact (and the pipe alive). + self.ready.connect().await?; + // Mint the next listening instance before returning so the pipe is never instance-less. + // Subsequent instances must omit `first_pipe_instance`, which is only valid on the first. + let next = ServerOptions::new().create(&self.name)?; + Ok(core::mem::replace(&mut self.ready, next)) + } + } +} + +pub use imp::{Endpoint, Listener, connect, default_endpoint}; diff --git a/crates/ironrdp-agent/src/wire/mod.rs b/crates/ironrdp-agent/src/wire/mod.rs new file mode 100644 index 0000000000..cc8edc38e2 --- /dev/null +++ b/crates/ironrdp-agent/src/wire/mod.rs @@ -0,0 +1,160 @@ +//! Binary wire primitives shared by the IPC message codecs. +//! +//! Everything is little-endian and cursor-based so it composes directly with [`ironrdp_core`]'s +//! `Encode`/`Decode`/`DecodeOwned` traits. Strings (and string-shaped payloads) are length-delimited +//! with a `u32` byte-count prefix. +//! +//! These helpers are `pub` so the [`internal`](crate) feature can expose them for unit testing in +//! the workspace test suite; the [`wire`](crate::wire) module itself is only public under that +//! feature. + +// The helpers are unconditionally `pub`; their effective visibility is the `wire` module's, which is +// `pub(crate)` unless the `internal` feature exposes it. +#![cfg_attr(not(feature = "internal"), allow(unreachable_pub))] + +pub mod propertyset; + +use ironrdp_core::{DecodeResult, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_size}; +use ironrdp_input::MouseButton; + +/// Size on the wire of a length-prefixed UTF-8 string. +pub fn string_size(value: &str) -> usize { + 4 /* length prefix */ + value.len() /* UTF-8 bytes */ +} + +/// Size on the wire of an optional length-prefixed UTF-8 string. +pub fn opt_string_size(value: Option<&str>) -> usize { + 1 /* presence flag */ + value.map_or(0, string_size) +} + +pub fn write_string(dst: &mut WriteCursor<'_>, value: &str) -> EncodeResult<()> { + ensure_size!(in: dst, size: string_size(value)); + let len: u32 = cast_length!("string length", value.len())?; + dst.write_u32(len); + dst.write_slice(value.as_bytes()); + Ok(()) +} + +pub fn read_string(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 4); + let len = src.read_u32(); + let len = usize::try_from(len).map_err(|_| ironrdp_core::other_err!("string", "length does not fit in usize"))?; + ensure_size!(in: src, size: len); + let bytes = src.read_slice(len); + String::from_utf8(bytes.to_vec()).map_err(|_| ironrdp_core::invalid_field_err!("string", "not valid UTF-8")) +} + +/// Size on the wire of a length-prefixed raw byte blob. +pub fn bytes_size(value: &[u8]) -> usize { + 4 /* length prefix */ + value.len() /* raw bytes */ +} + +pub fn write_bytes(dst: &mut WriteCursor<'_>, value: &[u8]) -> EncodeResult<()> { + ensure_size!(in: dst, size: bytes_size(value)); + let len: u32 = cast_length!("bytes length", value.len())?; + dst.write_u32(len); + dst.write_slice(value); + Ok(()) +} + +pub fn read_bytes(src: &mut ReadCursor<'_>) -> DecodeResult> { + ensure_size!(in: src, size: 4); + let len = src.read_u32(); + let len = usize::try_from(len).map_err(|_| ironrdp_core::other_err!("bytes", "length does not fit in usize"))?; + ensure_size!(in: src, size: len); + Ok(src.read_slice(len).to_vec()) +} + +pub fn write_opt_string(dst: &mut WriteCursor<'_>, value: Option<&str>) -> EncodeResult<()> { + ensure_size!(in: dst, size: 1); + match value { + Some(value) => { + dst.write_u8(1); + write_string(dst, value) + } + None => { + dst.write_u8(0); + Ok(()) + } + } +} + +pub fn read_opt_string(src: &mut ReadCursor<'_>) -> DecodeResult> { + ensure_size!(in: src, size: 1); + match src.read_u8() { + 0 => Ok(None), + 1 => Ok(Some(read_string(src)?)), + _ => Err(ironrdp_core::invalid_field_err!( + "optional string", + "invalid presence flag" + )), + } +} + +pub fn write_bool(dst: &mut WriteCursor<'_>, value: bool) -> EncodeResult<()> { + ensure_size!(in: dst, size: 1); + dst.write_u8(u8::from(value)); + Ok(()) +} + +pub fn read_bool(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + Ok(src.read_u8() != 0) +} + +pub fn write_char(dst: &mut WriteCursor<'_>, value: char) -> EncodeResult<()> { + ensure_size!(in: dst, size: 4); + dst.write_u32(u32::from(value)); + Ok(()) +} + +pub fn read_char(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 4); + let code = src.read_u32(); + char::from_u32(code).ok_or_else(|| ironrdp_core::invalid_field_err!("char", "not a valid Unicode scalar value")) +} + +pub fn write_mouse_button(dst: &mut WriteCursor<'_>, button: MouseButton) -> EncodeResult<()> { + ensure_size!(in: dst, size: 1); + let idx: u8 = cast_length!("mouse button index", button.as_idx())?; + dst.write_u8(idx); + Ok(()) +} + +pub fn read_mouse_button(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 1); + let idx = src.read_u8(); + MouseButton::from_idx(usize::from(idx)) + .ok_or_else(|| ironrdp_core::invalid_field_err!("mouse button", "unknown button index")) +} + +pub fn opt_u16_size(value: Option) -> usize { + 1 /* presence */ + value.map_or(0, |_| 2) +} + +pub fn write_opt_u16(dst: &mut WriteCursor<'_>, value: Option) -> EncodeResult<()> { + ensure_size!(in: dst, size: opt_u16_size(value)); + match value { + Some(value) => { + dst.write_u8(1); + dst.write_u16(value); + } + None => dst.write_u8(0), + } + Ok(()) +} + +pub fn read_opt_u16(src: &mut ReadCursor<'_>) -> DecodeResult> { + ensure_size!(in: src, size: 1); + match src.read_u8() { + 0 => Ok(None), + 1 => { + ensure_size!(in: src, size: 2); + Ok(Some(src.read_u16())) + } + _ => Err(ironrdp_core::invalid_field_err!( + "optional u16", + "invalid presence flag" + )), + } +} diff --git a/crates/ironrdp-agent/src/wire/propertyset.rs b/crates/ironrdp-agent/src/wire/propertyset.rs new file mode 100644 index 0000000000..25f984deb6 --- /dev/null +++ b/crates/ironrdp-agent/src/wire/propertyset.rs @@ -0,0 +1,83 @@ +//! Binary wire codec for [`PropertySet`]. +//! +//! This mirrors the shape of [`ironrdp_rdpfile::load`]/[`ironrdp_rdpfile::write`] but is binary and +//! cursor-based so it composes with [`ironrdp_core`]'s `Encode`/`DecodeOwned` traits. +//! +//! Layout: a `u32` entry count, then for each entry a length-prefixed UTF-8 key, a 1-byte value tag +//! (`0` = `Int`, `1` = `Str`), and the value (an `i64`, or a length-prefixed UTF-8 string). +//! +//! [`ironrdp_rdpfile::load`]: https://docs.rs/ironrdp-rdpfile + +#![cfg_attr(not(feature = "internal"), allow(unreachable_pub))] + +use ironrdp_core::{DecodeResult, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_size}; +use ironrdp_propertyset::{PropertySet, Value}; + +use crate::wire::{read_string, string_size, write_string}; + +const TAG_INT: u8 = 0; +const TAG_STR: u8 = 1; + +/// Size on the wire of `properties`, for use from an enclosing `Encode::size`. +pub fn size(properties: &PropertySet) -> usize { + let mut total = 4; // Entry count. + for (key, value) in properties.iter() { + total += string_size(key); // Key. + total += 1; // Value tag. + total += match value { + Value::Int(_) => 8, // i64. + Value::Str(value) => string_size(value), // Length-prefixed string. + }; + } + total +} + +/// Encodes `properties` into `dst`. +pub fn write(properties: &PropertySet, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: size(properties)); + + let count: u32 = cast_length!("property count", properties.iter().count())?; + dst.write_u32(count); + + for (key, value) in properties.iter() { + write_string(dst, key)?; + match value { + Value::Int(value) => { + dst.write_u8(TAG_INT); + dst.write_i64(*value); + } + Value::Str(value) => { + dst.write_u8(TAG_STR); + write_string(dst, value)?; + } + } + } + + Ok(()) +} + +/// Decodes entries from `src`, inserting them into `properties` (layering onto any existing keys, +/// matching the contract of [`ironrdp_rdpfile::load`]). +pub fn read(properties: &mut PropertySet, src: &mut ReadCursor<'_>) -> DecodeResult<()> { + ensure_size!(in: src, size: 4); + let count = src.read_u32(); + + for _ in 0..count { + let key = read_string(src)?; + + ensure_size!(in: src, size: 1); + match src.read_u8() { + TAG_INT => { + ensure_size!(in: src, size: 8); + properties.insert(key, src.read_i64()); + } + TAG_STR => { + let value = read_string(src)?; + properties.insert(key, value); + } + _ => return Err(ironrdp_core::invalid_field_err!("property value tag", "unknown tag")), + } + } + + Ok(()) +} diff --git a/crates/ironrdp-ainput/CHANGELOG.md b/crates/ironrdp-ainput/CHANGELOG.md index 188f0c3ea6..dd5cc20410 100644 --- a/crates/ironrdp-ainput/CHANGELOG.md +++ b/crates/ironrdp-ainput/CHANGELOG.md @@ -6,6 +6,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.7.0...ironrdp-ainput-v0.8.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency to 0.8 + + + +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.6.0...ironrdp-ainput-v0.7.0)] - 2026-06-05 + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency + + + +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.5.0...ironrdp-ainput-v0.6.0)] - 2026-05-27 + +### Bug Fixes + +- Replace all from_bits_truncate with from_bits_retain ([#1144](https://github.com/Devolutions/IronRDP/issues/1144)) ([353e30ddfd](https://github.com/Devolutions/IronRDP/commit/353e30ddfdaafc897db10b8663e364ef7775a7fd)) + + from_bits_truncate silently discards unknown bits, which breaks the + encode/decode round-trip property. This matters for fuzzing because a + PDU that decodes and re-encodes should produce identical bytes. + from_bits_retain preserves all bits, including those not yet defined in + our bitflags types, so the round-trip property holds. + +### Build + +- Bump the patch group across 1 directory with 2 updates ([#1222](https://github.com/Devolutions/IronRDP/issues/1222)) ([3fe6d157e0](https://github.com/Devolutions/IronRDP/commit/3fe6d157e0b55bddfdac20af290a6cfa6e550576)) + + ## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.2.0...ironrdp-ainput-v0.2.1)] - 2025-05-27 ### Build @@ -13,7 +46,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump bitflags from 2.9.0 to 2.9.1 in the patch group across 1 directory (#792) ([87ed315bc2](https://github.com/Devolutions/IronRDP/commit/87ed315bc28fdd2dcfea89b052fa620a7e346e5a)) - ## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.1.2...ironrdp-ainput-v0.1.3)] - 2025-03-12 ### Build @@ -28,7 +60,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-ainput-v0.1.0...ironrdp-ainput-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-ainput/Cargo.toml b/crates/ironrdp-ainput/Cargo.toml index b419660c20..71bbb228ea 100644 --- a/crates/ironrdp-ainput/Cargo.toml +++ b/crates/ironrdp-ainput/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-ainput" -version = "0.3.0" +version = "0.8.0" readme = "README.md" description = "AInput dynamic channel implementation" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -16,12 +17,11 @@ doctest = false test = false [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.3" } # public -bitflags = "2.9" +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public +bitflags = "2.11" num-derive.workspace = true # TODO: remove num-traits.workspace = true # TODO: remove [lints] workspace = true - diff --git a/crates/ironrdp-ainput/src/lib.rs b/crates/ironrdp-ainput/src/lib.rs index c6cfdb9eb0..6c4ef9c0b7 100644 --- a/crates/ironrdp-ainput/src/lib.rs +++ b/crates/ironrdp-ainput/src/lib.rs @@ -3,11 +3,11 @@ use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, invalid_field_err, }; use ironrdp_dvc::DvcEncode; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; // Advanced Input channel as defined from Freerdp, [here]: // // [here]: https://github.com/FreeRDP/FreeRDP/blob/master/include/freerdp/channels/ainput.h @@ -32,6 +32,8 @@ bitflags! { const XBUTTON1 = 0x0000_0100; const XBUTTON2 = 0x0000_0200; + + const _ = !0; } } @@ -93,11 +95,22 @@ impl<'de> Decode<'de> for VersionPdu { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[repr(u16)] pub enum ServerPduType { Version = 0x01, } +impl ServerPduType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(&self) -> u16 { + *self as u16 + } +} + impl<'a> From<&'a ServerPdu> for ServerPduType { fn from(s: &'a ServerPdu) -> Self { match s { @@ -121,7 +134,7 @@ impl Encode for ServerPdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u16(ServerPduType::from(self).to_u16().unwrap()); + dst.write_u16(ServerPduType::from(self).as_u16()); match self { ServerPdu::Version(pdu) => pdu.encode(dst), } @@ -220,7 +233,7 @@ impl Encode for ClientPdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u16(ClientPduType::from(self).to_u16().unwrap()); + dst.write_u16(ClientPduType::from(self).as_u16()); match self { ClientPdu::Mouse(pdu) => pdu.encode(dst), } @@ -254,11 +267,22 @@ impl<'de> Decode<'de> for ClientPdu { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[repr(u16)] pub enum ClientPduType { Mouse = 0x02, } +impl ClientPduType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + impl<'a> From<&'a ClientPdu> for ClientPduType { fn from(s: &'a ClientPdu) -> Self { match s { diff --git a/crates/ironrdp-async/CHANGELOG.md b/crates/ironrdp-async/CHANGELOG.md index b83ee99e35..34a854a384 100644 --- a/crates/ironrdp-async/CHANGELOG.md +++ b/crates/ironrdp-async/CHANGELOG.md @@ -6,13 +6,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [[0.3.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.3.1...ironrdp-async-v0.3.2)] - 2025-03-12 +## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.9.0...ironrdp-async-v0.10.0)] - 2026-07-10 ### Build -- Bump ironrdp-pdu +- [**breaking**] Update `ironrdp-connector` public dependency to 0.10 + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.8.0...ironrdp-async-v0.9.0)] - 2026-05-27 + +### Bug Fixes + +- [**breaking**] Make Framed::read_exact crate-private ([#1247](https://github.com/Devolutions/IronRDP/issues/1247)) ([d02d24aad4](https://github.com/Devolutions/IronRDP/commit/d02d24aad44039c0425a022f1bd9677800706cea)) + +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.7.0...ironrdp-async-v0.8.0)] - 2025-12-18 +### Bug Fixes + +- [**breaking**] Use static dispatch for NetworkClient trait ([#1043](https://github.com/Devolutions/IronRDP/issues/1043)) ([bca6d190a8](https://github.com/Devolutions/IronRDP/commit/bca6d190a870708468534d224ff225a658767a9a)) + + - Rename `AsyncNetworkClient` to `NetworkClient` + - Replace dynamic dispatch (`Option<&mut dyn ...>`) with static dispatch + using generics (`&mut N where N: NetworkClient`) + - Reorder `connect_finalize` parameters for consistency across crates + +## [[0.3.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.3.1...ironrdp-async-v0.3.2)] - 2025-03-12 + +### Build + +- Bump ironrdp-pdu ## [[0.3.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.3.0...ironrdp-async-v0.3.1)] - 2025-03-12 @@ -31,7 +57,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-async-v0.2.0...ironrdp-async-v0.2.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-async/Cargo.toml b/crates/ironrdp-async/Cargo.toml index 77c51bfb16..af9b297366 100644 --- a/crates/ironrdp-async/Cargo.toml +++ b/crates/ironrdp-async/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-async" -version = "0.6.0" +version = "0.10.0" readme = "README.md" description = "Provides `Future`s wrapping the IronRDP state machines conveniently" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -16,9 +17,9 @@ doctest = false test = false [dependencies] -ironrdp-connector = { path = "../ironrdp-connector", version = "0.6" } # public -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.10" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public tracing = { version = "0.1", features = ["log"] } bytes = "1" # public diff --git a/crates/ironrdp-async/src/connector.rs b/crates/ironrdp-async/src/connector.rs index 5f33c19bca..9db6fa5a68 100644 --- a/crates/ironrdp-async/src/connector.rs +++ b/crates/ironrdp-async/src/connector.rs @@ -2,14 +2,14 @@ use ironrdp_connector::credssp::{CredsspProcessGenerator, CredsspSequence, Kerbe use ironrdp_connector::sspi::credssp::ClientState; use ironrdp_connector::sspi::generator::GeneratorState; use ironrdp_connector::{ - custom_err, general_err, ClientConnector, ClientConnectorState, ConnectionResult, ConnectorError, ConnectorResult, - ServerName, State as _, + ClientConnector, ClientConnectorState, ConnectionResult, ConnectorError, ConnectorResult, ServerName, State as _, + general_err, }; use ironrdp_core::WriteBuf; use tracing::{debug, info, instrument, trace}; use crate::framed::{Framed, FramedRead, FramedWrite}; -use crate::{single_sequence_step, AsyncNetworkClient}; +use crate::{NetworkClient, single_sequence_step}; #[non_exhaustive] pub struct ShouldUpgrade; @@ -30,6 +30,9 @@ where Ok(ShouldUpgrade) } +/// # Panics +/// +/// Panics if connector state is not [ClientConnectorState::EnhancedSecurityUpgrade]. pub fn skip_connect_begin(connector: &mut ClientConnector) -> ShouldUpgrade { assert!(connector.should_perform_security_upgrade()); ShouldUpgrade @@ -46,28 +49,29 @@ pub fn mark_as_upgraded(_: ShouldUpgrade, connector: &mut ClientConnector) -> Up } #[instrument(skip_all)] -pub async fn connect_finalize( +pub async fn connect_finalize( _: Upgraded, - framed: &mut Framed, mut connector: ClientConnector, + framed: &mut Framed, + network_client: &mut N, server_name: ServerName, server_public_key: Vec, - network_client: Option<&mut dyn AsyncNetworkClient>, kerberos_config: Option, ) -> ConnectorResult where S: FramedRead + FramedWrite, + N: NetworkClient, { let mut buf = WriteBuf::new(); if connector.should_perform_credssp() { perform_credssp_step( - framed, &mut connector, + framed, + network_client, &mut buf, server_name, server_public_key, - network_client, kerberos_config, ) .await?; @@ -88,7 +92,7 @@ where async fn resolve_generator( generator: &mut CredsspProcessGenerator<'_>, - network_client: &mut dyn AsyncNetworkClient, + network_client: &mut impl NetworkClient, ) -> ConnectorResult { let mut state = generator.start(); @@ -100,24 +104,25 @@ async fn resolve_generator( } GeneratorState::Completed(client_state) => { break client_state - .map_err(|e| ConnectorError::new("CredSSP", ironrdp_connector::ConnectorErrorKind::Credssp(e))) + .map_err(|e| ConnectorError::new("CredSSP", ironrdp_connector::ConnectorErrorKind::Credssp(e))); } } } } #[instrument(level = "trace", skip_all)] -async fn perform_credssp_step( - framed: &mut Framed, +async fn perform_credssp_step( connector: &mut ClientConnector, + framed: &mut Framed, + network_client: &mut N, buf: &mut WriteBuf, server_name: ServerName, server_public_key: Vec, - mut network_client: Option<&mut dyn AsyncNetworkClient>, kerberos_config: Option, ) -> ConnectorResult<()> where S: FramedRead + FramedWrite, + N: NetworkClient, { assert!(connector.should_perform_credssp()); @@ -138,15 +143,8 @@ where loop { let client_state = { let mut generator = sequence.process_ts_request(ts_request); - - if let Some(network_client_ref) = network_client.as_deref_mut() { - trace!("resolving network"); - resolve_generator(&mut generator, network_client_ref).await? - } else { - generator - .resolve_to_result() - .map_err(|e| custom_err!("resolve without network client", e))? - } + trace!("resolving network"); + resolve_generator(&mut generator, network_client).await? }; // drop generator buf.clear(); diff --git a/crates/ironrdp-async/src/framed.rs b/crates/ironrdp-async/src/framed.rs index 214682e916..eff516456c 100644 --- a/crates/ironrdp-async/src/framed.rs +++ b/crates/ironrdp-async/src/framed.rs @@ -10,7 +10,7 @@ use tracing::{debug, trace}; // https://github.com/rust-lang/rust/issues/91611 pub trait FramedRead { - type ReadFut<'read>: core::future::Future> + 'read + type ReadFut<'read>: Future> + 'read where Self: 'read; @@ -25,7 +25,7 @@ pub trait FramedRead { } pub trait FramedWrite { - type WriteAllFut<'write>: core::future::Future> + 'write + type WriteAllFut<'write>: Future> + 'write where Self: 'write; @@ -110,7 +110,7 @@ where /// `tokio::select!` statement and some other branch /// completes first, then it is safe to drop the future and re-create it later. /// Data may have been read, but it will be stored in the internal buffer. - pub async fn read_exact(&mut self, length: usize) -> io::Result { + pub(crate) async fn read_exact(&mut self, length: usize) -> io::Result { loop { if self.buf.len() >= length { return Ok(self.buf.split_to(length)); diff --git a/crates/ironrdp-async/src/lib.rs b/crates/ironrdp-async/src/lib.rs index b367753557..7d7fc2fd28 100644 --- a/crates/ironrdp-async/src/lib.rs +++ b/crates/ironrdp-async/src/lib.rs @@ -7,19 +7,13 @@ mod connector; mod framed; mod session; -use core::future::Future; -use core::pin::Pin; - -use ironrdp_connector::sspi::generator::NetworkRequest; use ironrdp_connector::ConnectorResult; +use ironrdp_connector::sspi::generator::NetworkRequest; pub use self::connector::*; pub use self::framed::*; // pub use self::session::*; -pub trait AsyncNetworkClient { - fn send<'a>( - &'a mut self, - network_request: &'a NetworkRequest, - ) -> Pin>> + 'a>>; +pub trait NetworkClient { + fn send(&mut self, network_request: &NetworkRequest) -> impl Future>>; } diff --git a/crates/ironrdp-bench/Cargo.toml b/crates/ironrdp-bench/Cargo.toml index 3ab6a658bc..ea35d2ceb8 100644 --- a/crates/ironrdp-bench/Cargo.toml +++ b/crates/ironrdp-bench/Cargo.toml @@ -6,7 +6,7 @@ edition.workspace = true publish = false [dev-dependencies] -criterion = "0.7" +criterion = "0.8" ironrdp-graphics.path = "../ironrdp-graphics" ironrdp-pdu.path = "../ironrdp-pdu" ironrdp-server = { path = "../ironrdp-server", features = ["__bench"] } diff --git a/crates/ironrdp-bench/benches/bench.rs b/crates/ironrdp-bench/benches/bench.rs index 1643430003..96c64043b9 100644 --- a/crates/ironrdp-bench/benches/bench.rs +++ b/crates/ironrdp-bench/benches/bench.rs @@ -1,37 +1,50 @@ -use core::num::NonZero; +#![expect(clippy::missing_panics_doc, reason = "panics in benches are allowed")] -use criterion::{criterion_group, criterion_main, Criterion}; +use core::num::{NonZeroU16, NonZeroUsize}; + +use criterion::{Criterion, criterion_group, criterion_main}; use ironrdp_graphics::color_conversion::to_64x64_ycbcr_tile; use ironrdp_pdu::codecs::rfx; -use ironrdp_server::bench::encoder::rfx::{rfx_enc, rfx_enc_tile}; use ironrdp_server::BitmapUpdate; +use ironrdp_server::bench::encoder::rfx::{rfx_enc, rfx_enc_tile}; pub fn rfx_enc_tile_bench(c: &mut Criterion) { + const WIDTH: NonZeroU16 = NonZeroU16::new(64).expect("value is guaranteed to be non-zero"); + const HEIGHT: NonZeroU16 = NonZeroU16::new(64).expect("value is guaranteed to be non-zero"); + const STRIDE: NonZeroUsize = NonZeroUsize::new(64 * 4).expect("value is guaranteed to be non-zero"); + let quant = rfx::Quant::default(); let algo = rfx::EntropyAlgorithm::Rlgr3; + let bitmap = BitmapUpdate { x: 0, y: 0, - width: NonZero::new(64).unwrap(), - height: NonZero::new(64).unwrap(), + width: WIDTH, + height: HEIGHT, format: ironrdp_server::PixelFormat::ARgb32, data: vec![0; 64 * 64 * 4].into(), - stride: NonZero::new(64 * 4).unwrap(), + stride: STRIDE, }; c.bench_function("rfx_enc_tile", |b| b.iter(|| rfx_enc_tile(&bitmap, &quant, algo, 0, 0))); } pub fn rfx_enc_bench(c: &mut Criterion) { + const WIDTH: NonZeroU16 = NonZeroU16::new(2048).expect("value is guaranteed to be non-zero"); + const HEIGHT: NonZeroU16 = NonZeroU16::new(2048).expect("value is guaranteed to be non-zero"); + // FIXME/QUESTION: It looks like we have a bug here, don't we? The stride value should be 2048 * 4. + const STRIDE: NonZeroUsize = NonZeroUsize::new(64 * 4).expect("value is guaranteed to be non-zero"); + let quant = rfx::Quant::default(); let algo = rfx::EntropyAlgorithm::Rlgr3; + let bitmap = BitmapUpdate { x: 0, y: 0, - width: NonZero::new(2048).unwrap(), - height: NonZero::new(2048).unwrap(), + width: WIDTH, + height: HEIGHT, format: ironrdp_server::PixelFormat::ARgb32, data: vec![0; 2048 * 2048 * 4].into(), - stride: NonZero::new(64 * 4).unwrap(), + stride: STRIDE, }; c.bench_function("rfx_enc", |b| b.iter(|| rfx_enc(&bitmap, &quant, algo))); } @@ -39,14 +52,27 @@ pub fn rfx_enc_bench(c: &mut Criterion) { pub fn to_ycbcr_bench(c: &mut Criterion) { const WIDTH: usize = 64; const HEIGHT: usize = 64; + let input = vec![0; WIDTH * HEIGHT * 4]; let stride = WIDTH * 4; let mut y = [0i16; WIDTH * HEIGHT]; let mut cb = [0i16; WIDTH * HEIGHT]; let mut cr = [0i16; WIDTH * HEIGHT]; let format = ironrdp_graphics::image_processing::PixelFormat::ARgb32; + c.bench_function("to_ycbcr", |b| { - b.iter(|| to_64x64_ycbcr_tile(&input, WIDTH, HEIGHT, stride, format, &mut y, &mut cb, &mut cr)) + b.iter(|| { + to_64x64_ycbcr_tile( + &input, + WIDTH.try_into().expect("can't panic"), + HEIGHT.try_into().expect("can't panic"), + stride.try_into().expect("can't panic"), + format, + &mut y, + &mut cb, + &mut cr, + ) + }) }); } diff --git a/crates/ironrdp-blocking/CHANGELOG.md b/crates/ironrdp-blocking/CHANGELOG.md index b02d867ab5..ced129c3f4 100644 --- a/crates/ironrdp-blocking/CHANGELOG.md +++ b/crates/ironrdp-blocking/CHANGELOG.md @@ -6,6 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-blocking-v0.9.0...ironrdp-blocking-v0.10.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-connector` public dependency to 0.10 + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-blocking-v0.8.0...ironrdp-blocking-v0.9.0)] - 2026-05-27 + +### Bug Fixes + +- [**breaking**] Make Framed::read_exact crate-private ([#1247](https://github.com/Devolutions/IronRDP/issues/1247)) ([d02d24aad4](https://github.com/Devolutions/IronRDP/commit/d02d24aad44039c0425a022f1bd9677800706cea)) + + +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-blocking-v0.7.0...ironrdp-blocking-v0.8.0)] - 2025-12-18 + +### Bug Fixes + +- [**breaking**] Use static dispatch for NetworkClient trait ([#1043](https://github.com/Devolutions/IronRDP/issues/1043)) ([bca6d190a8](https://github.com/Devolutions/IronRDP/commit/bca6d190a870708468534d224ff225a658767a9a)) + + - Rename `AsyncNetworkClient` to `NetworkClient` + - Replace dynamic dispatch (`Option<&mut dyn ...>`) with static dispatch + using generics (`&mut N where N: NetworkClient`) + - Reorder `connect_finalize` parameters for consistency across crates + ## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-blocking-v0.3.1...ironrdp-blocking-v0.4.0)] - 2025-03-12 ### Build @@ -13,7 +41,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump ironrdp-pdu - ## [[0.3.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-blocking-v0.3.0...ironrdp-blocking-v0.3.1)] - 2025-03-12 ### Build @@ -31,7 +58,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-blocking-v0.2.0...ironrdp-blocking-v0.2.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-blocking/Cargo.toml b/crates/ironrdp-blocking/Cargo.toml index 011c7a6f43..15643335bc 100644 --- a/crates/ironrdp-blocking/Cargo.toml +++ b/crates/ironrdp-blocking/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-blocking" -version = "0.6.0" +version = "0.10.0" readme = "README.md" description = "Blocking I/O abstraction wrapping the IronRDP state machines conveniently" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -16,9 +17,9 @@ doctest = false test = false [dependencies] -ironrdp-connector = { path = "../ironrdp-connector", version = "0.6" } # public -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.10" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public tracing = { version = "0.1", features = ["log"] } bytes = "1" # public diff --git a/crates/ironrdp-blocking/src/connector.rs b/crates/ironrdp-blocking/src/connector.rs index 80623e791f..865c582a13 100644 --- a/crates/ironrdp-blocking/src/connector.rs +++ b/crates/ironrdp-blocking/src/connector.rs @@ -5,8 +5,8 @@ use ironrdp_connector::sspi::credssp::ClientState; use ironrdp_connector::sspi::generator::GeneratorState; use ironrdp_connector::sspi::network_client::NetworkClient; use ironrdp_connector::{ - general_err, ClientConnector, ClientConnectorState, ConnectionResult, ConnectorError, ConnectorResult, - Sequence as _, ServerName, State as _, + ClientConnector, ClientConnectorState, ConnectionResult, ConnectorError, ConnectorResult, Sequence as _, + ServerName, State as _, general_err, }; use ironrdp_core::WriteBuf; use tracing::{debug, info, instrument, trace}; @@ -32,6 +32,9 @@ where Ok(ShouldUpgrade) } +/// # Panics +/// +/// Panics if connector state is not [ClientConnectorState::EnhancedSecurityUpgrade]. pub fn skip_connect_begin(connector: &mut ClientConnector) -> ShouldUpgrade { assert!(connector.should_perform_security_upgrade()); ShouldUpgrade @@ -50,11 +53,11 @@ pub fn mark_as_upgraded(_: ShouldUpgrade, connector: &mut ClientConnector) -> Up #[instrument(skip_all)] pub fn connect_finalize( _: Upgraded, - framed: &mut Framed, mut connector: ClientConnector, + framed: &mut Framed, + network_client: &mut impl NetworkClient, server_name: ServerName, server_public_key: Vec, - network_client: &mut impl NetworkClient, kerberos_config: Option, ) -> ConnectorResult where @@ -66,12 +69,12 @@ where if connector.should_perform_credssp() { perform_credssp_step( - framed, &mut connector, + framed, + network_client, &mut buf, server_name, server_public_key, - network_client, kerberos_config, )?; } @@ -100,12 +103,14 @@ fn resolve_generator( loop { match state { GeneratorState::Suspended(request) => { - let response = network_client.send(&request).unwrap(); + let response = network_client.send(&request).map_err(|e| { + ConnectorError::new("network client send", ironrdp_connector::ConnectorErrorKind::Credssp(e)) + })?; state = generator.resume(Ok(response)); } GeneratorState::Completed(client_state) => { break client_state - .map_err(|e| ConnectorError::new("CredSSP", ironrdp_connector::ConnectorErrorKind::Credssp(e))) + .map_err(|e| ConnectorError::new("CredSSP", ironrdp_connector::ConnectorErrorKind::Credssp(e))); } } } @@ -113,12 +118,12 @@ fn resolve_generator( #[instrument(level = "trace", skip_all)] fn perform_credssp_step( - framed: &mut Framed, connector: &mut ClientConnector, + framed: &mut Framed, + network_client: &mut impl NetworkClient, buf: &mut WriteBuf, server_name: ServerName, server_public_key: Vec, - network_client: &mut impl NetworkClient, kerberos_config: Option, ) -> ConnectorResult<()> where diff --git a/crates/ironrdp-blocking/src/framed.rs b/crates/ironrdp-blocking/src/framed.rs index 885fe5785a..a2964d4481 100644 --- a/crates/ironrdp-blocking/src/framed.rs +++ b/crates/ironrdp-blocking/src/framed.rs @@ -46,7 +46,7 @@ where S: Read, { /// Accumulates at least `length` bytes and returns exactly `length` bytes, keeping the leftover in the internal buffer. - pub fn read_exact(&mut self, length: usize) -> io::Result { + pub(crate) fn read_exact(&mut self, length: usize) -> io::Result { loop { if self.buf.len() >= length { return Ok(self.buf.split_to(length)); diff --git a/crates/ironrdp-bulk/CHANGELOG.md b/crates/ironrdp-bulk/CHANGELOG.md new file mode 100644 index 0000000000..cfbe2a4854 --- /dev/null +++ b/crates/ironrdp-bulk/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-bulk-v0.1.0...ironrdp-bulk-v0.1.1)] - 2026-05-27 + +### Bug Fixes + +- Gate alloc-dependent modules behind the alloc feature ([#1279](https://github.com/Devolutions/IronRDP/issues/1279)) ([18a430a51a](https://github.com/Devolutions/IronRDP/commit/18a430a51aca07aa45db4642df5c932ef65d2016)) + +- Off-by-one in forward match loop causes panic during compression ([#1293](https://github.com/Devolutions/IronRDP/issues/1293)) ([0dd7c94ba2](https://github.com/Devolutions/IronRDP/commit/0dd7c94ba22e9bd11b4ea36fd03af3bfcccecab8)) + +### Build + +- Bump criterion from 0.5.1 to 0.8.1 ([#1184](https://github.com/Devolutions/IronRDP/issues/1184)) ([d92dd382b3](https://github.com/Devolutions/IronRDP/commit/d92dd382b3fbaa163f355f6489db45ca8a3e7498)) + + diff --git a/crates/ironrdp-bulk/Cargo.toml b/crates/ironrdp-bulk/Cargo.toml new file mode 100644 index 0000000000..6cc8115eba --- /dev/null +++ b/crates/ironrdp-bulk/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "ironrdp-bulk" +version = "0.1.1" +description = "Bulk compression algorithms (MPPC, XCRUSH, NCRUSH) for IronRDP" +edition.workspace = true +rust-version = "1.89" +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +[lib] +doctest = false + +[features] +default = ["std"] +std = ["alloc"] +alloc = [] + +[dependencies] + +[dev-dependencies] +criterion = { version = "0.8", features = ["html_reports"] } + +[[bench]] +name = "bulk_compression" +harness = false + +[lints] +workspace = true diff --git a/crates/ironrdp-bulk/benches/bulk_compression.rs b/crates/ironrdp-bulk/benches/bulk_compression.rs new file mode 100644 index 0000000000..107d37dbb4 --- /dev/null +++ b/crates/ironrdp-bulk/benches/bulk_compression.rs @@ -0,0 +1,147 @@ +//! Benchmarks for ironrdp-bulk compression algorithms. +//! +//! Measures compress + decompress throughput for MPPC (RDP4, RDP5), +//! NCRUSH (RDP6), and XCRUSH (RDP6.1) with realistic input patterns. + +use core::hint::black_box; + +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use ironrdp_bulk::{BulkCompressor, CompressionType, flags}; + +/// Text-like data (highly compressible, typical of protocol messages). +fn generate_text_data(size: usize) -> Vec { + let phrases = [ + b"Session started for user Administrator on server DESKTOP-ABC1234 ".as_slice(), + b"Desktop width=1920 height=1080 bpp=32 keyboard=en-us locale=0409 ", + b"Channel joined: rdpdr cliprdr rdpsnd drdynvc MS_T120 ", + b"Bitmap update received for region (0,0)-(1920,1080) compressed=true ", + ]; + let mut data = Vec::with_capacity(size); + let mut idx = 0; + while data.len() < size { + let remaining = size - data.len(); + let phrase = phrases[idx % phrases.len()]; + let chunk = &phrase[..remaining.min(phrase.len())]; + data.extend_from_slice(chunk); + idx += 1; + } + data +} + +/// Structured bitmap-like data (moderately compressible - runs of similar values). +fn generate_structured_bitmap(size: usize) -> Vec { + let mut data = Vec::with_capacity(size); + // Simulate a desktop with horizontal runs of similar color + // Each "scanline" of 256 bytes has 4 color runs of 64 bytes each + let colors: [[u8; 4]; 4] = [ + [30, 60, 120, 255], // dark blue (taskbar-like) + [240, 240, 240, 255], // light gray (window background) + [0, 0, 0, 255], // black (text region) + [240, 240, 240, 255], // light gray again + ]; + let mut scanline = 0u32; + for i in 0..size { + let pos_in_scanline = i % 256; + let color_idx = pos_in_scanline / 64; + let channel = i % 4; + // Add slight variation every 4 scanlines to simulate content + let variation = if scanline.is_multiple_of(4) { + u8::try_from(pos_in_scanline & 0x03).unwrap_or(0) + } else { + 0 + }; + data.push(colors[color_idx][channel].wrapping_add(variation)); + if pos_in_scanline == 255 { + scanline += 1; + } + } + data +} + +fn algo_name(ct: CompressionType) -> &'static str { + match ct { + CompressionType::Rdp4 => "mppc_rdp4", + CompressionType::Rdp5 => "mppc_rdp5", + CompressionType::Rdp6 => "ncrush", + CompressionType::Rdp61 => "xcrush", + } +} + +fn bench_compress_decompress(c: &mut Criterion, ct: CompressionType, data: &[u8], label: &str) { + let name = algo_name(ct); + + // Verify data actually compresses with this algorithm + let mut test_comp = BulkCompressor::new(ct).expect("bulk compressor should initialize"); + let (test_size, test_flags) = test_comp.compress(data).expect("bulk compression should succeed"); + let is_compressed = test_flags & flags::PACKET_COMPRESSED != 0; + + if is_compressed { + let compressed = test_comp.compressed_data(test_size).to_vec(); + + // Benchmark compress + { + let mut group = c.benchmark_group(format!("{name}/{label}")); + group.throughput(Throughput::Bytes(u64::try_from(data.len()).unwrap_or(u64::MAX))); + + group.bench_function(BenchmarkId::new("compress", data.len()), |b| { + b.iter_batched( + || BulkCompressor::new(ct).expect("bulk compressor should initialize"), + |mut compressor| { + black_box( + compressor + .compress(black_box(data)) + .expect("bulk compression should succeed"), + ); + }, + criterion::BatchSize::SmallInput, + ); + }); + + group.finish(); + } + + // Benchmark decompress + { + let mut group = c.benchmark_group(format!("{name}/{label}")); + group.throughput(Throughput::Bytes(u64::try_from(data.len()).unwrap_or(u64::MAX))); + + group.bench_function(BenchmarkId::new("decompress", data.len()), |b| { + b.iter_batched( + || BulkCompressor::new(ct).expect("bulk compressor should initialize"), + |mut decompressor| { + black_box( + decompressor + .decompress(black_box(&compressed), black_box(test_flags)) + .expect("bulk decompression should succeed"), + ); + }, + criterion::BatchSize::SmallInput, + ); + }); + + group.finish(); + } + } +} + +fn bench_all(c: &mut Criterion) { + let text_4k = generate_text_data(4096); + let text_16k = generate_text_data(16384); + let bitmap_4k = generate_structured_bitmap(4096); + let bitmap_16k = generate_structured_bitmap(16384); + + for ct in [ + CompressionType::Rdp4, + CompressionType::Rdp5, + CompressionType::Rdp6, + CompressionType::Rdp61, + ] { + bench_compress_decompress(c, ct, &text_4k, "text_4k"); + bench_compress_decompress(c, ct, &text_16k, "text_16k"); + bench_compress_decompress(c, ct, &bitmap_4k, "bitmap_4k"); + bench_compress_decompress(c, ct, &bitmap_16k, "bitmap_16k"); + } +} + +criterion_group!(benches, bench_all); +criterion_main!(benches); diff --git a/crates/ironrdp-bulk/src/bitstream.rs b/crates/ironrdp-bulk/src/bitstream.rs new file mode 100644 index 0000000000..7a7693db7a --- /dev/null +++ b/crates/ironrdp-bulk/src/bitstream.rs @@ -0,0 +1,568 @@ +//! Bitstream reader and writer utilities for compression algorithms. +//! +//! The reader uses a 32-bit accumulator with a 32-bit prefetch lookahead, +//! loaded in big-endian order. The writer uses a 32-bit accumulator that +//! flushes in big-endian order. + +/// Reads bits from a byte buffer using a 32-bit accumulator with prefetch. +/// +/// The accumulator holds 32 bits loaded big-endian from the buffer. +/// Bits are consumed from the most significant bit (MSB) first. +pub(crate) struct BitStreamReader<'a> { + buffer: &'a [u8], + /// Byte offset of the current 4-byte window in the buffer. + byte_position: usize, + /// Total number of bits consumed so far. + bits_consumed: usize, + /// Number of bits consumed within the current 4-byte accumulator window. + offset: u32, + /// Current 32-bit accumulator (big-endian loaded). + accumulator: u32, + /// Prefetched next 32-bit word (big-endian loaded). + prefetch: u32, + /// Total number of bits available in the buffer. + total_bits: usize, +} + +impl<'a> BitStreamReader<'a> { + /// Creates a new BitStreamReader attached to the given byte buffer. + /// + /// Immediately fetches the first 4 bytes into the accumulator + /// and prefetches the next 4 bytes. + pub(crate) fn new(data: &'a [u8]) -> Self { + let mut reader = Self { + buffer: data, + byte_position: 0, + bits_consumed: 0, + offset: 0, + accumulator: 0, + prefetch: 0, + total_bits: data.len().saturating_mul(8), + }; + reader.fetch(); + reader + } + + /// Returns the current accumulator value. + /// + /// The top bits of the accumulator contain the next bits to be consumed. + /// This is used by algorithms like MPPC that inspect bit patterns + /// directly before deciding how many bits to shift. + #[inline] + pub(crate) fn accumulator(&self) -> u32 { + self.accumulator + } + + /// Returns the number of bits remaining in the stream. + #[inline] + pub(crate) fn remaining_bits(&self) -> usize { + self.total_bits.saturating_sub(self.bits_consumed) + } + + /// Advances the stream by `nbits` bits. + /// + /// Shifts the accumulator left and fills from the prefetch buffer. + /// When crossing a 32-bit boundary, it advances the byte pointer + /// and re-prefetches. + #[expect(clippy::as_conversions, reason = "nbits (u32 ≤ 31) always fits in usize")] + pub(crate) fn shift(&mut self, nbits: u32) { + if nbits == 0 { + return; + } + + debug_assert!(nbits < 32, "use shift32() for shifting 32 bits"); + + self.accumulator <<= nbits; + self.bits_consumed += nbits as usize; + self.offset += nbits; + + if self.offset < 32 { + // Still within the same 4-byte window. + // Fill lower bits of accumulator from top of prefetch. + let mask = (1u32 << nbits) - 1; + self.accumulator |= (self.prefetch >> (32 - nbits)) & mask; + self.prefetch <<= nbits; + } else { + // Crossed 32-bit boundary. + // First fill from remaining prefetch bits. + let mask = (1u32 << nbits) - 1; + self.accumulator |= (self.prefetch >> (32 - nbits)) & mask; + self.prefetch <<= nbits; + + self.offset -= 32; + self.byte_position += 4; + self.do_prefetch(); + + if self.offset > 0 { + let mask = (1u32 << self.offset) - 1; + self.accumulator |= (self.prefetch >> (32 - self.offset)) & mask; + self.prefetch <<= self.offset; + } + } + } + + /// Loads the accumulator with 4 bytes from the current position (big-endian) + /// and prefetches the next 4 bytes. + fn fetch(&mut self) { + self.accumulator = 0; + let pos = self.byte_position; + let cap = self.buffer.len(); + + if pos < cap { + self.accumulator |= u32::from(self.buffer[pos]) << 24; + } + if pos + 1 < cap { + self.accumulator |= u32::from(self.buffer[pos + 1]) << 16; + } + if pos + 2 < cap { + self.accumulator |= u32::from(self.buffer[pos + 2]) << 8; + } + if pos + 3 < cap { + self.accumulator |= u32::from(self.buffer[pos + 3]); + } + + self.do_prefetch(); + } + + /// Prefetches 4 bytes starting at `byte_position + 4` (big-endian). + fn do_prefetch(&mut self) { + self.prefetch = 0; + let pos = self.byte_position + 4; + let cap = self.buffer.len(); + + if pos < cap { + self.prefetch |= u32::from(self.buffer[pos]) << 24; + } + if pos + 1 < cap { + self.prefetch |= u32::from(self.buffer[pos + 1]) << 16; + } + if pos + 2 < cap { + self.prefetch |= u32::from(self.buffer[pos + 2]) << 8; + } + if pos + 3 < cap { + self.prefetch |= u32::from(self.buffer[pos + 3]); + } + } +} + +/// Writes bits to a byte buffer using a 32-bit accumulator. +/// +/// Bits are written from the most significant bit (MSB) first. +/// When the 32-bit accumulator is full, it is flushed to the buffer +/// in big-endian order. +pub(crate) struct BitStreamWriter<'a> { + buffer: &'a mut [u8], + /// Byte offset where the next 4-byte flush will write. + byte_position: usize, + /// Total number of bits written so far. + bits_written: usize, + /// Number of bits written within the current 4-byte accumulator. + offset: u32, + /// Current 32-bit accumulator (big-endian, MSB first). + accumulator: u32, +} + +impl<'a> BitStreamWriter<'a> { + /// Creates a new BitStreamWriter targeting the given byte buffer. + pub(crate) fn new(buffer: &'a mut [u8]) -> Self { + Self { + buffer, + byte_position: 0, + bits_written: 0, + offset: 0, + accumulator: 0, + } + } + + /// Writes `nbits` bits from `value` into the stream. + /// + /// The bits are taken from the lowest `nbits` bits of `value`. + /// They are placed MSB-first into the accumulator. When the + /// accumulator fills 32 bits, it is flushed to the buffer. + #[expect(clippy::as_conversions, reason = "nbits (u32 ≤ 32) always fits in usize")] + pub(crate) fn write_bits(&mut self, value: u32, nbits: u32) { + self.bits_written += nbits as usize; + self.offset += nbits; + + if self.offset < 32 { + // Fits within the current accumulator. + // Place bits at position (32 - offset), which is just after + // the previously written bits. + self.accumulator |= value << (32 - self.offset); + } else { + // Crossed the 32-bit boundary. + self.offset -= 32; + + // Put the upper (nbits - offset) bits into the current accumulator. + let mask = (1u32 << (nbits - self.offset)) - 1; + self.accumulator |= (value >> self.offset) & mask; + + // Flush the full accumulator to the buffer. + self.do_flush(); + self.accumulator = 0; + self.byte_position += 4; + + // Put the remaining lower `offset` bits into the new accumulator. + if self.offset > 0 { + let mask = (1u32 << self.offset) - 1; + self.accumulator |= (value & mask) << (32 - self.offset); + } + } + } + + /// Flushes any remaining bits in the accumulator to the output buffer. + /// + /// This must be called after all bits have been written to ensure + /// any partial accumulator contents are written to the buffer. + pub(crate) fn flush(&mut self) { + self.do_flush(); + } + + /// Returns the total number of bits written so far. + #[inline] + pub(crate) fn bits_written(&self) -> usize { + self.bits_written + } + + /// Returns the number of bytes needed to hold all written bits, + /// rounding up for any partial byte. + #[inline] + pub(crate) fn byte_length(&self) -> usize { + self.bits_written.div_ceil(8) + } + + /// Writes the accumulator bytes to the buffer in big-endian order. + fn do_flush(&mut self) { + let pos = self.byte_position; + let cap = self.buffer.len(); + let bytes = self.accumulator.to_be_bytes(); + + if pos < cap { + self.buffer[pos] = bytes[0]; + } + if pos + 1 < cap { + self.buffer[pos + 1] = bytes[1]; + } + if pos + 2 < cap { + self.buffer[pos + 2] = bytes[2]; + } + if pos + 3 < cap { + self.buffer[pos + 3] = bytes[3]; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Test-only helpers for BitStreamReader (not part of the public API). + impl BitStreamReader<'_> { + fn read_bits(&mut self, nbits: u32) -> u32 { + let value = self.peek_bits(nbits); + self.shift(nbits); + value + } + + fn peek_bits(&self, nbits: u32) -> u32 { + if nbits == 32 { + self.accumulator + } else { + self.accumulator >> (32 - nbits) + } + } + + fn bits_consumed(&self) -> usize { + self.bits_consumed + } + + fn shift32(&mut self) { + self.shift(16); + self.shift(16); + } + } + + // ======================== + // BitStreamReader tests + // ======================== + + #[test] + fn reader_read_single_bits() { + // 0xA5 = 1010_0101 + let data = [0xA5]; + let mut reader = BitStreamReader::new(&data); + + assert_eq!(reader.read_bits(1), 1); // bit 7: 1 + assert_eq!(reader.read_bits(1), 0); // bit 6: 0 + assert_eq!(reader.read_bits(1), 1); // bit 5: 1 + assert_eq!(reader.read_bits(1), 0); // bit 4: 0 + assert_eq!(reader.read_bits(1), 0); // bit 3: 0 + assert_eq!(reader.read_bits(1), 1); // bit 2: 1 + assert_eq!(reader.read_bits(1), 0); // bit 1: 0 + assert_eq!(reader.read_bits(1), 1); // bit 0: 1 + + assert_eq!(reader.bits_consumed(), 8); + assert_eq!(reader.remaining_bits(), 0); + } + + #[test] + fn reader_read_8_bits() { + let data = [0xDE, 0xAD]; + let mut reader = BitStreamReader::new(&data); + + assert_eq!(reader.read_bits(8), 0xDE); + assert_eq!(reader.read_bits(8), 0xAD); + assert_eq!(reader.remaining_bits(), 0); + } + + #[test] + fn reader_read_16_bits() { + let data = [0xCA, 0xFE, 0xBA, 0xBE]; + let mut reader = BitStreamReader::new(&data); + + assert_eq!(reader.read_bits(16), 0xCAFE); + assert_eq!(reader.read_bits(16), 0xBABE); + assert_eq!(reader.remaining_bits(), 0); + } + + #[test] + fn reader_read_mixed_widths() { + // 0b1100_1010 0b0011_1111 = 0xCA3F + let data = [0xCA, 0x3F]; + let mut reader = BitStreamReader::new(&data); + + assert_eq!(reader.read_bits(4), 0b1100); // top 4 bits of 0xCA + assert_eq!(reader.read_bits(4), 0b1010); // bottom 4 bits of 0xCA + assert_eq!(reader.read_bits(6), 0b001111); // top 6 bits of 0x3F + assert_eq!(reader.read_bits(2), 0b11); // bottom 2 bits of 0x3F + assert_eq!(reader.bits_consumed(), 16); + } + + #[test] + fn reader_accumulator_boundary_crossing() { + // 8 bytes = 64 bits, need to cross the 32-bit accumulator boundary + let data = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]; + let mut reader = BitStreamReader::new(&data); + + // Read first 32 bits (full first accumulator window) + assert_eq!(reader.read_bits(16), 0x0102); + assert_eq!(reader.read_bits(16), 0x0304); + + // This crosses into the prefetch/second window + assert_eq!(reader.read_bits(16), 0x0506); + assert_eq!(reader.read_bits(16), 0x0708); + assert_eq!(reader.remaining_bits(), 0); + } + + #[test] + fn reader_cross_boundary_odd_width() { + // Read across the 32-bit boundary with an odd-sized read + let data = [0xFF, 0x00, 0xFF, 0x00, 0xAA, 0xBB, 0xCC, 0xDD]; + let mut reader = BitStreamReader::new(&data); + + // Read 28 bits (within first window) + assert_eq!(reader.read_bits(28), 0xFF00FF0); + // Read 8 bits (crosses the 32-bit boundary: 4 bits from first window + 4 from second) + assert_eq!(reader.read_bits(8), 0x0A); + // Continue reading from second window + assert_eq!(reader.read_bits(16), 0xABBC); + } + + #[test] + fn reader_peek_does_not_consume() { + let data = [0xAB, 0xCD]; + let mut reader = BitStreamReader::new(&data); + + assert_eq!(reader.peek_bits(8), 0xAB); + assert_eq!(reader.peek_bits(8), 0xAB); // same value + assert_eq!(reader.bits_consumed(), 0); + + assert_eq!(reader.read_bits(8), 0xAB); // now consume + assert_eq!(reader.peek_bits(8), 0xCD); + } + + #[test] + fn reader_accumulator_direct_access() { + let data = [0xDE, 0xAD, 0xBE, 0xEF]; + let reader = BitStreamReader::new(&data); + + // Accumulator should hold all 4 bytes in big-endian order + assert_eq!(reader.accumulator(), 0xDEADBEEF); + } + + #[test] + fn reader_shift32() { + let data = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]; + let mut reader = BitStreamReader::new(&data); + + assert_eq!(reader.accumulator(), 0x11223344); + reader.shift32(); + assert_eq!(reader.accumulator(), 0x55667788); + assert_eq!(reader.bits_consumed(), 32); + } + + #[test] + fn reader_small_buffer() { + // Buffer smaller than 4 bytes + let data = [0xAB, 0xCD]; + let reader = BitStreamReader::new(&data); + + // Accumulator pads with zeros for missing bytes + assert_eq!(reader.accumulator(), 0xABCD0000); + assert_eq!(reader.remaining_bits(), 16); + } + + #[test] + fn reader_empty_buffer() { + let data: [u8; 0] = []; + let reader = BitStreamReader::new(&data); + + assert_eq!(reader.accumulator(), 0); + assert_eq!(reader.remaining_bits(), 0); + } + + // ======================== + // BitStreamWriter tests + // ======================== + + /// Helper: write bits, flush, and return (byte_length, bits_written) before + /// releasing the mutable borrow on the buffer. + fn write_and_flush(buf: &mut [u8], ops: &[(u32, u32)]) -> (usize, usize) { + let mut writer = BitStreamWriter::new(buf); + for &(value, nbits) in ops { + writer.write_bits(value, nbits); + } + writer.flush(); + (writer.byte_length(), writer.bits_written()) + } + + #[test] + fn writer_write_8_bits() { + let mut buf = [0u8; 4]; + let (byte_len, _) = write_and_flush(&mut buf, &[(0xAB, 8)]); + + assert_eq!(buf[0], 0xAB); + assert_eq!(byte_len, 1); + } + + #[test] + fn writer_write_16_bits() { + let mut buf = [0u8; 4]; + let (byte_len, _) = write_and_flush(&mut buf, &[(0xCAFE, 16)]); + + assert_eq!(buf[0], 0xCA); + assert_eq!(buf[1], 0xFE); + assert_eq!(byte_len, 2); + } + + #[test] + fn writer_write_single_bits() { + let mut buf = [0u8; 4]; + // Write 1010_0101 one bit at a time + let (_, bits_written) = write_and_flush( + &mut buf, + &[(1, 1), (0, 1), (1, 1), (0, 1), (0, 1), (1, 1), (0, 1), (1, 1)], + ); + + assert_eq!(buf[0], 0xA5); + assert_eq!(bits_written, 8); + } + + #[test] + fn writer_write_mixed_widths() { + let mut buf = [0u8; 4]; + write_and_flush(&mut buf, &[(0b1100, 4), (0b1010, 4)]); + + assert_eq!(buf[0], 0xCA); + } + + #[test] + fn writer_accumulator_boundary_crossing() { + let mut buf = [0u8; 8]; + let (byte_len, _) = write_and_flush(&mut buf, &[(0xDEAD, 16), (0xBEEF, 16), (0xCAFE, 16)]); + + assert_eq!(buf[0], 0xDE); + assert_eq!(buf[1], 0xAD); + assert_eq!(buf[2], 0xBE); + assert_eq!(buf[3], 0xEF); + assert_eq!(buf[4], 0xCA); + assert_eq!(buf[5], 0xFE); + assert_eq!(byte_len, 6); + } + + #[test] + fn writer_cross_boundary_odd_width() { + let mut buf = [0u8; 8]; + let (byte_len, _) = write_and_flush(&mut buf, &[(0x1234567, 28), (0x89A, 12)]); + + // Total 40 bits = 5 bytes + // Bits: 0001_0010_0011_0100_0101_0110_0111_1000_1001_1010 + assert_eq!(buf[0], 0x12); + assert_eq!(buf[1], 0x34); + assert_eq!(buf[2], 0x56); + assert_eq!(buf[3], 0x78); + assert_eq!(buf[4], 0x9A); + assert_eq!(byte_len, 5); + } + + #[test] + fn writer_byte_length_partial() { + let mut buf = [0u8; 4]; + let mut writer = BitStreamWriter::new(&mut buf); + + writer.write_bits(0b101, 3); + assert_eq!(writer.byte_length(), 1); // 3 bits rounds up to 1 byte + assert_eq!(writer.bits_written(), 3); + } + + // ======================== + // Round-trip tests + // ======================== + + #[test] + fn roundtrip_single_byte() { + let mut buf = [0u8; 4]; + write_and_flush(&mut buf, &[(0xA5, 8)]); + + let mut reader = BitStreamReader::new(&buf[..1]); + assert_eq!(reader.read_bits(8), 0xA5); + } + + #[test] + fn roundtrip_multiple_values() { + let mut buf = [0u8; 16]; + let (byte_len, total_bits) = write_and_flush(&mut buf, &[(0b110, 3), (0xFF, 8), (0b10101, 5), (0xCAFE, 16)]); + + let mut reader = BitStreamReader::new(&buf[..byte_len]); + assert_eq!(reader.read_bits(3), 0b110); + assert_eq!(reader.read_bits(8), 0xFF); + assert_eq!(reader.read_bits(5), 0b10101); + assert_eq!(reader.read_bits(16), 0xCAFE); + assert_eq!(reader.bits_consumed(), total_bits); + } + + #[test] + fn roundtrip_across_boundary() { + let mut buf = [0u8; 16]; + let (byte_len, _) = write_and_flush(&mut buf, &[(0x1234, 16), (0x5678, 16), (0x9ABC, 16), (0xDEF0, 16)]); + + let mut reader = BitStreamReader::new(&buf[..byte_len]); + assert_eq!(reader.read_bits(16), 0x1234); + assert_eq!(reader.read_bits(16), 0x5678); + assert_eq!(reader.read_bits(16), 0x9ABC); + assert_eq!(reader.read_bits(16), 0xDEF0); + } + + #[test] + fn roundtrip_many_small_values() { + let mut buf = [0u8; 16]; + // Write 20 x 3-bit values (60 bits total, crossing boundary) + let values: Vec<(u32, u32)> = (0..20).map(|i| (i % 8, 3)).collect(); + let (byte_len, _) = write_and_flush(&mut buf, &values); + + let mut reader = BitStreamReader::new(&buf[..byte_len]); + for i in 0..20u32 { + assert_eq!(reader.read_bits(3), i % 8); + } + } +} diff --git a/crates/ironrdp-bulk/src/bulk.rs b/crates/ironrdp-bulk/src/bulk.rs new file mode 100644 index 0000000000..2a49852be9 --- /dev/null +++ b/crates/ironrdp-bulk/src/bulk.rs @@ -0,0 +1,582 @@ +//! Bulk compressor coordinator that routes to the appropriate algorithm. +//! +//! Holds send/receive context pairs for MPPC, NCRUSH, and XCRUSH. +//! Selects the appropriate compressor based on the configured compression +//! level (for compression) or the type bits in the flags (for decompression). + +#[cfg(not(feature = "std"))] +use alloc::{boxed::Box, vec::Vec}; + +use crate::CompressionType; +use crate::error::BulkError; +use crate::mppc::MppcContext; +use crate::ncrush::NCrushContext; +use crate::xcrush::XCrushContext; + +/// Size of the internal output buffer used for compression. +const OUTPUT_BUFFER_SIZE: usize = 65536; + +/// Minimum input size for compression (below this, data is sent uncompressed). +const COMPRESS_MIN_SIZE: usize = 50; + +/// Maximum input size for compression (above this, data is sent uncompressed). +const COMPRESS_MAX_SIZE: usize = 16384; + +/// Mask for the compression control flags (COMPRESSED | AT_FRONT | FLUSHED). +const BULK_COMPRESSION_FLAGS_MASK: u32 = 0xE0; + +/// Bulk compression/decompression coordinator. +/// +/// Manages send (compression) and receive (decompression) context pairs +/// for all three RDP compression algorithms. Routes compress/decompress +/// requests to the appropriate algorithm based on the compression level +/// or type flags. +/// +pub struct BulkCompressor { + /// The compression level to use for outgoing data. + compression_level: CompressionType, + /// MPPC context for sending (compression). + mppc_send: MppcContext, + /// MPPC context for receiving (decompression). + mppc_recv: MppcContext, + /// NCRUSH context for sending (compression). + ncrush_send: NCrushContext, + /// NCRUSH context for receiving (decompression). + ncrush_recv: NCrushContext, + /// XCRUSH context for sending (compression). + xcrush_send: XCrushContext, + /// XCRUSH context for receiving (decompression). + xcrush_recv: XCrushContext, + /// Internal output buffer for compressed data. + output_buffer: Box<[u8; OUTPUT_BUFFER_SIZE]>, + + // -- Compression metrics -- + /// Cumulative uncompressed bytes (decompressed output size). + total_uncompressed_bytes: u64, + /// Cumulative compressed bytes (compressed input size). + total_compressed_bytes: u64, +} + +impl BulkCompressor { + /// Creates a new bulk compressor/decompressor with the given compression level. + /// + /// Allocates send and receive contexts for MPPC, NCRUSH, and XCRUSH. + /// The `compression_level` determines which algorithm is used for + /// outbound compression. Inbound decompression automatically selects + /// the algorithm based on the type bits in the packet flags. + /// + /// # Compression Levels + /// + /// - `Rdp4` (0x00): MPPC with 8K history buffer + /// - `Rdp5` (0x01): MPPC with 64K history buffer + /// - `Rdp6` (0x02): NCRUSH (Huffman-based) + /// - `Rdp61` (0x03): XCRUSH (two-level: chunk matching + MPPC) + /// + pub fn new(compression_level: CompressionType) -> Result { + // MPPC contexts are created with level 1 by default and adjusted dynamically. + let mppc_send = MppcContext::new(1); + let mppc_recv = MppcContext::new(1); + let ncrush_send = NCrushContext::new()?; + let ncrush_recv = NCrushContext::new()?; + let xcrush_send = XCrushContext::new(); + let xcrush_recv = XCrushContext::new(); + + // Heap-allocate the 64KB output buffer to avoid stack overflow. + // Vec length is exactly OUTPUT_BUFFER_SIZE, so the try_into is infallible. + let output_buffer = { + let v: Vec = alloc::vec![0u8; OUTPUT_BUFFER_SIZE]; + v.into_boxed_slice().try_into().unwrap_or_else(|_| unreachable!()) + }; + + Ok(Self { + compression_level, + mppc_send, + mppc_recv, + ncrush_send, + ncrush_recv, + xcrush_send, + xcrush_recv, + output_buffer, + total_uncompressed_bytes: 0, + total_compressed_bytes: 0, + }) + } + + /// Returns the configured compression level. + pub fn compression_level(&self) -> CompressionType { + self.compression_level + } + + /// Returns `true` if the input size is outside the compressible range. + /// + /// Skips compression for sizes <= 50 or >= 16384. + pub fn should_skip_compression(src_size: usize) -> bool { + src_size <= COMPRESS_MIN_SIZE || src_size >= COMPRESS_MAX_SIZE + } + + /// Decompresses bulk-compressed RDP data. + /// + /// `flags` contains the compression type (low 4 bits) and control flags + /// (`PACKET_COMPRESSED`, `PACKET_AT_FRONT`, `PACKET_FLUSHED`). + /// + /// If no compression flags are set, returns `src_data` unchanged. + /// Otherwise, routes to the appropriate algorithm based on the type bits: + /// - `0x00` (RDP4): MPPC with 8K buffer + /// - `0x01` (RDP5): MPPC with 64K buffer + /// - `0x02` (RDP6): NCRUSH + /// - `0x03` (RDP6.1): XCRUSH + /// + pub fn decompress<'a>(&'a mut self, src_data: &'a [u8], flags: u32) -> Result<&'a [u8], BulkError> { + let compression_flags = flags & BULK_COMPRESSION_FLAGS_MASK; + + // If no compression flags are set, return source data unchanged + if compression_flags == 0 { + return Ok(src_data); + } + + let comp_type = CompressionType::from_flags(flags)?; + + let result = match comp_type { + CompressionType::Rdp4 => { + self.mppc_recv.set_compression_level(0); + self.mppc_recv.decompress(src_data, flags) + } + CompressionType::Rdp5 => { + self.mppc_recv.set_compression_level(1); + self.mppc_recv.decompress(src_data, flags) + } + CompressionType::Rdp6 => self.ncrush_recv.decompress(src_data, flags), + CompressionType::Rdp61 => self.xcrush_recv.decompress(src_data, flags), + }?; + + // Update decompression metrics. + // Individual PDU payloads are at most 64 KB, so these fit in u32. + // We widen to u64 for the cumulative counter via From. + let compressed_len = u32::try_from(src_data.len()).unwrap_or(u32::MAX); + let uncompressed_len = u32::try_from(result.len()).unwrap_or(u32::MAX); + self.total_compressed_bytes = self.total_compressed_bytes.saturating_add(u64::from(compressed_len)); + self.total_uncompressed_bytes = self + .total_uncompressed_bytes + .saturating_add(u64::from(uncompressed_len)); + + Ok(result) + } + + /// Compresses data using the configured compression algorithm. + /// + /// Returns `Ok((compressed_size, flags))` on success: + /// - If `flags & PACKET_COMPRESSED != 0`: compressed data is available + /// in the internal output buffer via [`Self::compressed_data`]. + /// - If compression was skipped (size out of range) or the algorithm + /// flushed (compressed output larger than input): `flags` will **not** + /// have `PACKET_COMPRESSED` set, and the caller should transmit the + /// original `src_data` uncompressed. + /// + /// Skips compression for sizes ≤ 50 or ≥ 16384. + /// + pub fn compress(&mut self, src_data: &[u8]) -> Result<(usize, u32), BulkError> { + let src_size = src_data.len(); + + // Skip compression for edge case sizes + if Self::should_skip_compression(src_size) { + return Ok((src_size, 0)); + } + + let (compressed_size, flags) = match self.compression_level { + CompressionType::Rdp4 => { + self.mppc_send.set_compression_level(0); + self.mppc_send.compress(src_data, &mut *self.output_buffer) + } + CompressionType::Rdp5 => { + self.mppc_send.set_compression_level(1); + self.mppc_send.compress(src_data, &mut *self.output_buffer) + } + CompressionType::Rdp6 => self.ncrush_send.compress(src_data, &mut *self.output_buffer), + CompressionType::Rdp61 => self.xcrush_send.compress(src_data, &mut *self.output_buffer), + }?; + + // Update compression metrics. + let uncompressed_len = u32::try_from(src_size).unwrap_or(u32::MAX); + let compressed_len = u32::try_from(compressed_size).unwrap_or(u32::MAX); + self.total_uncompressed_bytes = self + .total_uncompressed_bytes + .saturating_add(u64::from(uncompressed_len)); + self.total_compressed_bytes = self.total_compressed_bytes.saturating_add(u64::from(compressed_len)); + + Ok((compressed_size, flags)) + } + + /// Returns a slice of the internal output buffer containing compressed + /// data from the most recent [`Self::compress`] call. + /// + /// `size` should be the `compressed_size` value returned by `compress`. + /// If `size` exceeds the output buffer length, it is clamped to the + /// buffer length to avoid a panic. + pub fn compressed_data(&self, size: usize) -> &[u8] { + let clamped = size.min(self.output_buffer.len()); + &self.output_buffer[..clamped] + } + + // -- Compression metrics -- + + /// Returns the cumulative number of uncompressed bytes processed. + /// + /// For decompression this is the total decompressed output size. + /// For compression this is the total uncompressed input size. + pub fn total_uncompressed_bytes(&self) -> u64 { + self.total_uncompressed_bytes + } + + /// Returns the cumulative number of compressed bytes processed. + /// + /// For decompression this is the total compressed input size. + /// For compression this is the total compressed output size. + pub fn total_compressed_bytes(&self) -> u64 { + self.total_compressed_bytes + } + + /// Returns the overall compression ratio as `uncompressed / compressed`. + /// + /// A ratio > 1.0 means compression is effective (e.g. 3.0 means data + /// was reduced to ~33% of its original size). Returns 0.0 if no + /// compressed bytes have been processed yet. + /// + #[expect( + clippy::as_conversions, + clippy::cast_precision_loss, + reason = "u64-to-f64 may lose precision for values > 2^53, acceptable for a ratio display" + )] + pub fn compression_ratio(&self) -> f64 { + if self.total_compressed_bytes == 0 { + return 0.0; + } + self.total_uncompressed_bytes as f64 / self.total_compressed_bytes as f64 + } + + /// Resets all compression and decompression contexts. + /// + pub fn reset(&mut self) { + self.mppc_send.reset(false); + self.mppc_recv.reset(false); + self.ncrush_send.reset(false); + self.ncrush_recv.reset(false); + self.xcrush_send.reset(false); + self.xcrush_recv.reset(false); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bulk_compressor_new_rdp4() { + let bulk = BulkCompressor::new(CompressionType::Rdp4).unwrap(); + assert_eq!(bulk.compression_level(), CompressionType::Rdp4); + } + + #[test] + fn test_bulk_compressor_new_rdp5() { + let bulk = BulkCompressor::new(CompressionType::Rdp5).unwrap(); + assert_eq!(bulk.compression_level(), CompressionType::Rdp5); + } + + #[test] + fn test_bulk_compressor_new_rdp6() { + let bulk = BulkCompressor::new(CompressionType::Rdp6).unwrap(); + assert_eq!(bulk.compression_level(), CompressionType::Rdp6); + } + + #[test] + fn test_bulk_compressor_new_rdp61() { + let bulk = BulkCompressor::new(CompressionType::Rdp61).unwrap(); + assert_eq!(bulk.compression_level(), CompressionType::Rdp61); + } + + #[test] + fn test_bulk_compressor_skip_small() { + assert!(BulkCompressor::should_skip_compression(10)); + assert!(BulkCompressor::should_skip_compression(50)); + } + + #[test] + fn test_bulk_compressor_skip_large() { + assert!(BulkCompressor::should_skip_compression(16384)); + assert!(BulkCompressor::should_skip_compression(65536)); + } + + #[test] + fn test_bulk_compressor_no_skip_normal() { + assert!(!BulkCompressor::should_skip_compression(51)); + assert!(!BulkCompressor::should_skip_compression(8192)); + assert!(!BulkCompressor::should_skip_compression(16383)); + } + + #[test] + fn test_bulk_compressor_reset() { + let mut bulk = BulkCompressor::new(CompressionType::Rdp61).unwrap(); + // Should not panic + bulk.reset(); + } + + #[test] + fn test_bulk_compressor_contexts_independent() { + let bulk = BulkCompressor::new(CompressionType::Rdp6).unwrap(); + // Send and receive NCRUSH contexts should be separate instances + // (we can only verify they exist and the struct was created) + assert_eq!(bulk.compression_level(), CompressionType::Rdp6); + } + + // --------------------------------------------------------------- + // Compression skip tests + // --------------------------------------------------------------- + + #[test] + fn test_bulk_compress_skip_small_input() { + let mut bulk = BulkCompressor::new(CompressionType::Rdp5).unwrap(); + let data = b"tiny"; // 4 bytes, below threshold + let (size, flags) = bulk.compress(data).unwrap(); + assert_eq!(size, data.len()); + assert_eq!(flags, 0); // no compression applied + } + + #[test] + fn test_bulk_compress_skip_empty() { + let mut bulk = BulkCompressor::new(CompressionType::Rdp5).unwrap(); + let data = b""; + let (size, flags) = bulk.compress(data).unwrap(); + assert_eq!(size, 0); + assert_eq!(flags, 0); + } + + // --------------------------------------------------------------- + // Decompress: no flags → pass-through + // --------------------------------------------------------------- + + #[test] + fn test_bulk_decompress_no_flags() { + let mut bulk = BulkCompressor::new(CompressionType::Rdp5).unwrap(); + let data = b"uncompressed data"; + let result = bulk.decompress(data, 0x00).unwrap(); + assert_eq!(result, data); + } + + #[test] + fn test_bulk_decompress_unsupported_type() { + let mut bulk = BulkCompressor::new(CompressionType::Rdp5).unwrap(); + // flags = PACKET_COMPRESSED | type 0x0F (invalid) + let result = bulk.decompress(b"data", 0x2F); + assert!(result.is_err()); + } + + // --------------------------------------------------------------- + // Round-trip tests through the bulk API for each algorithm + // --------------------------------------------------------------- + + /// Helper: compress with one BulkCompressor (sender) and decompress + /// with another (receiver). Returns the decompressed data as a Vec. + fn bulk_roundtrip(compression_level: CompressionType, input: &[u8]) -> Vec { + let mut sender = BulkCompressor::new(compression_level).unwrap(); + let mut receiver = BulkCompressor::new(compression_level).unwrap(); + + let (comp_size, flags) = sender.compress(input).unwrap(); + + if flags & crate::flags::PACKET_COMPRESSED != 0 { + // Compressed: pass compressed data to receiver + let compressed = sender.compressed_data(comp_size).to_vec(); + let decompressed = receiver.decompress(&compressed, flags).unwrap(); + decompressed.to_vec() + } else { + // Not compressed: data should be sent as-is + input.to_vec() + } + } + + #[test] + fn test_bulk_roundtrip_rdp5_mppc() { + let input = b"The quick brown fox jumps over the lazy dog. \ + The quick brown fox jumps over the lazy dog again."; + let output = bulk_roundtrip(CompressionType::Rdp5, input); + assert_eq!(output, input); + } + + #[test] + fn test_bulk_roundtrip_rdp4_mppc() { + let input = b"Hello world! Hello world! Hello world! Hello world! x"; + let output = bulk_roundtrip(CompressionType::Rdp4, input); + assert_eq!(output, input); + } + + #[test] + fn test_bulk_roundtrip_rdp6_ncrush() { + let input = b"for.whom.the.bell.tolls,.the.bell.tolls.for.thee!xx"; + let output = bulk_roundtrip(CompressionType::Rdp6, input); + assert_eq!(output, input); + } + + #[test] + fn test_bulk_roundtrip_rdp61_xcrush() { + let input = b"XCRUSH test data with repeated XCRUSH patterns for compression!!"; + let output = bulk_roundtrip(CompressionType::Rdp61, input); + assert_eq!(output, input); + } + + #[test] + fn test_bulk_roundtrip_rdp5_binary_data() { + // Binary data with all byte values + let mut input = Vec::new(); + for _ in 0..2 { + for b in 0u8..=255 { + input.push(b); + } + } + // 512 bytes — within compressible range + let output = bulk_roundtrip(CompressionType::Rdp5, &input); + assert_eq!(output, input); + } + + #[test] + fn test_bulk_roundtrip_rdp6_longer_text() { + let input = b"The RDP protocol uses bulk compression to reduce bandwidth. \ + Multiple algorithms are supported: MPPC for RDP4/5, \ + NCRUSH for RDP6, and XCRUSH for RDP6.1. Each has \ + different tradeoffs between speed and compression ratio."; + let output = bulk_roundtrip(CompressionType::Rdp6, input); + assert_eq!(output, input); + } + + // --------------------------------------------------------------- + // Routing verification + // --------------------------------------------------------------- + + #[test] + fn test_bulk_compress_rdp5_sets_type_bits() { + let mut bulk = BulkCompressor::new(CompressionType::Rdp5).unwrap(); + let input = b"Some data that should compress with MPPC level 1 algorithm!!"; + let (_size, flags) = bulk.compress(input).unwrap(); + + if flags & crate::flags::PACKET_COMPRESSED != 0 { + // Type bits should be 0x01 (RDP5) + let comp_type = flags & crate::flags::COMPRESSION_TYPE_MASK; + assert_eq!(comp_type, 0x01, "Expected RDP5 type bits"); + } + } + + #[test] + fn test_bulk_compress_rdp6_sets_type_bits() { + let mut bulk = BulkCompressor::new(CompressionType::Rdp6).unwrap(); + let input = b"for.whom.the.bell.tolls,.the.bell.tolls.for.thee!xx"; + let (_size, flags) = bulk.compress(input).unwrap(); + + if flags & crate::flags::PACKET_COMPRESSED != 0 { + // Type bits should be 0x02 (RDP6/NCRUSH) + let comp_type = flags & crate::flags::COMPRESSION_TYPE_MASK; + assert_eq!(comp_type, 0x02, "Expected RDP6 (NCRUSH) type bits"); + } + } + + // --------------------------------------------------------------- + // Metrics tests + // --------------------------------------------------------------- + + #[test] + fn test_metrics_start_at_zero() { + let bulk = BulkCompressor::new(CompressionType::Rdp5).unwrap(); + assert_eq!(bulk.total_compressed_bytes(), 0); + assert_eq!(bulk.total_uncompressed_bytes(), 0); + assert!(bulk.compression_ratio().abs() < f64::EPSILON); + } + + #[test] + fn test_metrics_accumulate_on_compress() { + let mut bulk = BulkCompressor::new(CompressionType::Rdp5).unwrap(); + let input = b"Hello world! Hello world! Hello world! Hello world! x"; + let (comp_size, flags) = bulk.compress(input).unwrap(); + + assert_eq!( + bulk.total_uncompressed_bytes(), + u64::try_from(input.len()).unwrap_or(u64::MAX) + ); + + if flags & crate::flags::PACKET_COMPRESSED != 0 { + assert_eq!( + bulk.total_compressed_bytes(), + u64::try_from(comp_size).unwrap_or(u64::MAX) + ); + assert!(bulk.compression_ratio() > 1.0, "compression should reduce size"); + } + } + + #[test] + fn test_metrics_accumulate_on_decompress() { + let mut sender = BulkCompressor::new(CompressionType::Rdp5).unwrap(); + let mut receiver = BulkCompressor::new(CompressionType::Rdp5).unwrap(); + + let input = b"Hello world! Hello world! Hello world! Hello world! x"; + let (comp_size, flags) = sender.compress(input).unwrap(); + + if flags & crate::flags::PACKET_COMPRESSED != 0 { + let compressed = sender.compressed_data(comp_size).to_vec(); + let _decompressed = receiver.decompress(&compressed, flags).unwrap(); + + assert_eq!( + receiver.total_compressed_bytes(), + u64::try_from(compressed.len()).unwrap_or(u64::MAX) + ); + assert_eq!( + receiver.total_uncompressed_bytes(), + u64::try_from(input.len()).unwrap_or(u64::MAX) + ); + assert!(receiver.compression_ratio() > 1.0); + } + } + + #[test] + fn test_metrics_accumulate_across_multiple_calls() { + let mut sender = BulkCompressor::new(CompressionType::Rdp5).unwrap(); + let mut receiver = BulkCompressor::new(CompressionType::Rdp5).unwrap(); + + let inputs: &[&[u8]] = &[ + b"Hello world! Hello world! Hello world! Hello world! x", + b"The quick brown fox jumps over the lazy dog. Again and again!", + ]; + + for input in inputs { + let (comp_size, flags) = sender.compress(input).unwrap(); + if flags & crate::flags::PACKET_COMPRESSED != 0 { + let compressed = sender.compressed_data(comp_size).to_vec(); + let _decompressed = receiver.decompress(&compressed, flags).unwrap(); + } + } + + // Both inputs were processed, so totals should reflect the sum + assert!(receiver.total_uncompressed_bytes() > 0); + assert!(receiver.total_compressed_bytes() > 0); + assert!(receiver.total_compressed_bytes() < receiver.total_uncompressed_bytes()); + } + + #[test] + fn test_metrics_not_reset_by_context_reset() { + let mut bulk = BulkCompressor::new(CompressionType::Rdp5).unwrap(); + let input = b"Hello world! Hello world! Hello world! Hello world! x"; + let _ = bulk.compress(input).unwrap(); + let before = bulk.total_uncompressed_bytes(); + + bulk.reset(); + + assert_eq!(bulk.total_uncompressed_bytes(), before); + } + + #[test] + fn test_bulk_compress_rdp61_sets_type_bits() { + let mut bulk = BulkCompressor::new(CompressionType::Rdp61).unwrap(); + let input = b"XCRUSH test data with repeated XCRUSH patterns for compression!!"; + let (_size, flags) = bulk.compress(input).unwrap(); + + if flags & crate::flags::PACKET_COMPRESSED != 0 { + // Type bits should be 0x03 (RDP6.1/XCRUSH) + let comp_type = flags & crate::flags::COMPRESSION_TYPE_MASK; + assert_eq!(comp_type, 0x03, "Expected RDP6.1 (XCRUSH) type bits"); + } + } +} diff --git a/crates/ironrdp-bulk/src/error.rs b/crates/ironrdp-bulk/src/error.rs new file mode 100644 index 0000000000..ea09aa51e5 --- /dev/null +++ b/crates/ironrdp-bulk/src/error.rs @@ -0,0 +1,60 @@ +//! Error types for bulk compression operations. + +use core::fmt; + +/// Error type for bulk compression and decompression operations. +#[derive(Debug)] +pub enum BulkError { + /// The compression type value is not supported. + UnsupportedCompressionType(u32), + /// The compressed data is malformed or truncated. + InvalidCompressedData(&'static str), + /// The output buffer is too small for the decompressed data. + OutputBufferTooSmall { + /// Required minimum size. + required: usize, + /// Actual available size. + available: usize, + }, + /// The history buffer overflowed. + HistoryBufferOverflow, + /// A decompression operation encountered an unexpected end of input. + UnexpectedEndOfInput, +} + +impl fmt::Display for BulkError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedCompressionType(value) => { + write!(f, "unsupported compression type: {value:#04x}") + } + Self::InvalidCompressedData(detail) => { + write!(f, "invalid compressed data: {detail}") + } + Self::OutputBufferTooSmall { required, available } => { + write!( + f, + "output buffer too small: need {required} bytes, but only {available} available" + ) + } + Self::HistoryBufferOverflow => { + write!(f, "history buffer overflow") + } + Self::UnexpectedEndOfInput => { + write!(f, "unexpected end of input") + } + } + } +} + +impl core::error::Error for BulkError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + match self { + Self::UnsupportedCompressionType(_) => None, + Self::InvalidCompressedData(_) => None, + Self::OutputBufferTooSmall { .. } => None, + Self::HistoryBufferOverflow => None, + Self::UnexpectedEndOfInput => None, + } + } +} diff --git a/crates/ironrdp-bulk/src/lib.rs b/crates/ironrdp-bulk/src/lib.rs new file mode 100644 index 0000000000..ba93fd852c --- /dev/null +++ b/crates/ironrdp-bulk/src/lib.rs @@ -0,0 +1,136 @@ +#![doc = "Bulk compression algorithms (MPPC, XCRUSH, NCRUSH) for IronRDP."] +#![allow(unused_crate_dependencies)] // Used by benches. +//! +//! This crate implements the RDP bulk compression algorithms. +//! It supports compression and decompression for all four RDP compression levels: +//! +//! | Level | Algorithm | History Buffer | RDP Version | +//! |-------|-----------|---------------|-------------| +//! | `Rdp4` | MPPC | 8 KB | RDP 4.0 | +//! | `Rdp5` | MPPC | 64 KB | RDP 5.0 | +//! | `Rdp6` | NCRUSH | 64 KB | RDP 6.0 | +//! | `Rdp61` | XCRUSH | 2 MB | RDP 6.1 | +//! +//! # Usage +//! +//! ```rust +//! use ironrdp_bulk::{BulkCompressor, CompressionType, flags}; +//! +//! // Create sender (compressor) and receiver (decompressor) +//! let mut sender = BulkCompressor::new(CompressionType::Rdp5).unwrap(); +//! let mut receiver = BulkCompressor::new(CompressionType::Rdp5).unwrap(); +//! +//! let input = b"Hello world! Hello world! Hello world! Hello world! x"; +//! +//! // Compress +//! let (compressed_size, compress_flags) = sender.compress(input).unwrap(); +//! +//! if compress_flags & flags::PACKET_COMPRESSED != 0 { +//! // Compressed data is available +//! let compressed = sender.compressed_data(compressed_size); +//! assert!(compressed.len() < input.len()); +//! +//! // Decompress +//! let decompressed = receiver.decompress(compressed, compress_flags).unwrap(); +//! assert_eq!(decompressed, input); +//! } +//! ``` +//! +//! # Features +//! +//! - **`std`** (default): Enables standard library support. +//! - **`alloc`** (implied by `std`): Enables heap allocation without `std`, +//! suitable for `no_std` environments such as WebAssembly. +//! +//! # Safety +//! +//! This crate contains **zero `unsafe` code**. The `#![forbid(unsafe_code)]` +//! attribute enforces this invariant at compile time. All numeric casts are +//! documented with `#[expect]` attributes explaining their safety bounds. +#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] +#![cfg_attr(not(feature = "std"), no_std)] +#![forbid(unsafe_code)] +#![warn(clippy::std_instead_of_alloc)] +#![warn(clippy::std_instead_of_core)] +#![cfg_attr(doc, warn(missing_docs))] + +#[cfg(feature = "alloc")] +extern crate alloc; + +mod error; + +#[cfg(feature = "alloc")] +mod bitstream; +#[cfg(feature = "alloc")] +mod bulk; +#[cfg(feature = "alloc")] +mod mppc; +#[cfg(feature = "alloc")] +mod ncrush; +#[cfg(feature = "alloc")] +mod xcrush; + +#[cfg(feature = "alloc")] +pub use self::bulk::BulkCompressor; +pub use self::error::BulkError; + +/// RDP bulk compression type (low 4 bits of compression flags). +/// +/// Determines which compression algorithm to use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum CompressionType { + /// MPPC with 8K history buffer (RDP 4.0) + Rdp4 = 0x00, + /// MPPC with 64K history buffer (RDP 5.0) + Rdp5 = 0x01, + /// NCRUSH Huffman-based compression (RDP 6.0) + Rdp6 = 0x02, + /// XCRUSH two-level compression (RDP 6.1) + Rdp61 = 0x03, +} + +impl CompressionType { + /// Attempts to parse a compression type from the low 4 bits of a flags byte. + pub fn from_flags(flags: u32) -> Result { + match flags & flags::COMPRESSION_TYPE_MASK { + 0x00 => Ok(Self::Rdp4), + 0x01 => Ok(Self::Rdp5), + 0x02 => Ok(Self::Rdp6), + 0x03 => Ok(Self::Rdp61), + other => Err(BulkError::UnsupportedCompressionType(other)), + } + } +} + +impl core::fmt::Display for CompressionType { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Rdp4 => write!(f, "RDP4 (MPPC 8K)"), + Self::Rdp5 => write!(f, "RDP5 (MPPC 64K)"), + Self::Rdp6 => write!(f, "RDP6 (NCRUSH)"), + Self::Rdp61 => write!(f, "RDP6.1 (XCRUSH)"), + } + } +} + +/// Level-2 and Level-1 compression flag constants. +pub mod flags { + /// Level-2 flag: data is compressed. + pub const PACKET_COMPRESSED: u32 = 0x20; + /// Level-2 flag: history buffer reset to beginning. + pub const PACKET_AT_FRONT: u32 = 0x40; + /// Level-2 flag: history buffer was flushed (reset). + pub const PACKET_FLUSHED: u32 = 0x80; + /// Mask to extract the compression type from the flags byte. + pub const COMPRESSION_TYPE_MASK: u32 = 0x0F; + + /// Level-1 flag (XCRUSH): history buffer reset to front. + pub const L1_PACKET_AT_FRONT: u32 = 0x04; + /// Level-1 flag (XCRUSH): data is not compressed at Level-1. + pub const L1_NO_COMPRESSION: u32 = 0x02; + /// Level-1 flag (XCRUSH): data is compressed at Level-1. + pub const L1_COMPRESSED: u32 = 0x01; + /// Level-1 flag (XCRUSH): inner (Level-2/MPPC) compression was applied. + pub const L1_INNER_COMPRESSION: u32 = 0x10; +} diff --git a/crates/ironrdp-bulk/src/mppc/mod.rs b/crates/ironrdp-bulk/src/mppc/mod.rs new file mode 100644 index 0000000000..36cf9c64a8 --- /dev/null +++ b/crates/ironrdp-bulk/src/mppc/mod.rs @@ -0,0 +1,881 @@ +//! MPPC (Microsoft Point-to-Point Compression) implementation. +//! +//! Supports RDP4 (8K history) and RDP5 (64K history) compression levels. + +pub(crate) mod tables; + +#[cfg(not(feature = "std"))] +use alloc::boxed::Box; + +use self::tables::{ + HISTORY_BUFFER_SIZE_RDP4, HISTORY_BUFFER_SIZE_RDP5, HISTORY_MASK_RDP4, HISTORY_MASK_RDP5, MATCH_BUFFER_SIZE, +}; +use crate::bitstream::{BitStreamReader, BitStreamWriter}; +use crate::error::BulkError; +use crate::flags; + +/// MPPC compression/decompression context. +/// +/// Holds the sliding-window history buffer and state needed for MPPC +/// compression and decompression. The history buffer is always allocated +/// at 64 KB, but only the first 8 KB is used for RDP4 mode. +/// +pub(crate) struct MppcContext { + /// Compression level: 0 = RDP4 (8K), 1 = RDP5 (64K). + compression_level: u32, + /// Effective history buffer size (8192 for RDP4, 65536 for RDP5). + history_buffer_size: usize, + /// History wrapping mask (0x1FFF for RDP4, 0xFFFF for RDP5). + history_mask: usize, + /// Sliding-window history buffer (always 64 KB). + pub(crate) history_buffer: Box<[u8; HISTORY_BUFFER_SIZE_RDP5]>, + /// Current write position in the history buffer. + pub(crate) history_ptr: usize, + /// History offset used by the compressor for tracking buffer position. + pub(crate) history_offset: usize, + /// Match buffer for compression hash table lookups. + pub(crate) match_buffer: Box<[u16; MATCH_BUFFER_SIZE]>, +} + +impl MppcContext { + /// Creates a new MPPC context. + /// + /// `compression_level`: 0 for RDP4 (8K history), 1 for RDP5 (64K history). + pub(crate) fn new(compression_level: u32) -> Self { + let (level, buffer_size, mask) = if compression_level < 1 { + (0u32, HISTORY_BUFFER_SIZE_RDP4, HISTORY_MASK_RDP4) + } else { + (1u32, HISTORY_BUFFER_SIZE_RDP5, HISTORY_MASK_RDP5) + }; + + let mut ctx = Self { + compression_level: level, + history_buffer_size: buffer_size, + history_mask: mask, + history_buffer: Box::new([0u8; HISTORY_BUFFER_SIZE_RDP5]), + history_ptr: 0, + history_offset: 0, + match_buffer: Box::new([0u16; MATCH_BUFFER_SIZE]), + }; + ctx.reset(false); + ctx + } + + /// Resets the MPPC context. + /// + /// Zeros the history buffer and match buffer. + /// If `flush` is `true`, sets `history_offset` to `history_buffer_size + 1` + /// (indicating a flush occurred). Otherwise sets `history_offset` to 0. + /// In both cases, `history_ptr` is reset to 0. + pub(crate) fn reset(&mut self, flush: bool) { + self.history_buffer.fill(0); + self.match_buffer.fill(0); + + if flush { + self.history_offset = self.history_buffer_size + 1; + self.history_ptr = 0; + } else { + self.history_offset = 0; + self.history_ptr = 0; + } + } + + /// Sets the compression level, adjusting buffer size and mask accordingly. + pub(crate) fn set_compression_level(&mut self, compression_level: u32) { + if compression_level < 1 { + self.compression_level = 0; + self.history_buffer_size = HISTORY_BUFFER_SIZE_RDP4; + self.history_mask = HISTORY_MASK_RDP4; + } else { + self.compression_level = 1; + self.history_buffer_size = HISTORY_BUFFER_SIZE_RDP5; + self.history_mask = HISTORY_MASK_RDP5; + } + } + + /// Decompresses MPPC-compressed data. + /// + /// Handles `PACKET_FLUSHED` (reset context), `PACKET_AT_FRONT` (reset history + /// pointer), literal decoding, CopyOffset decoding (different for RDP4 vs RDP5), + /// LengthOfMatch decoding, and history buffer copy with wrapping. + /// + /// Returns a slice of the decompressed data. For compressed packets this is a + /// slice into the internal history buffer. For uncompressed packets this is + /// the source data passed through directly. + #[expect( + clippy::as_conversions, + reason = "bit manipulation requires masked u32-to-u8/usize casts; values are bounded by masks" + )] + pub(crate) fn decompress<'a>(&'a mut self, src_data: &'a [u8], flags_value: u32) -> Result<&'a [u8], BulkError> { + let history_buffer_size = self.history_buffer_size; + let compression_level = self.compression_level; + let history_mask = self.history_mask; + + // Handle PACKET_AT_FRONT: reset history pointer to beginning + if flags_value & flags::PACKET_AT_FRONT != 0 { + self.history_offset = 0; + self.history_ptr = 0; + } + + // Handle PACKET_FLUSHED: reset context entirely + if flags_value & flags::PACKET_FLUSHED != 0 { + self.history_offset = 0; + self.history_ptr = 0; + self.history_buffer[..history_buffer_size].fill(0); + } + + // If data is not compressed, return source data directly + if flags_value & flags::PACKET_COMPRESSED == 0 { + return Ok(src_data); + } + + let mut bs = BitStreamReader::new(src_data); + let history_buffer_end = history_buffer_size - 1; + let output_start = self.history_ptr; + let mut history_ptr = self.history_ptr; + + while bs.remaining_bits() >= 8 { + let accumulator = bs.accumulator(); + + // Check history buffer bounds + if history_ptr > history_buffer_end { + return Err(BulkError::HistoryBufferOverflow); + } + + // --- Literal Encoding --- + + if (accumulator & 0x8000_0000) == 0x0000_0000 { + // Literal < 0x80: bit 0 followed by lower 7 bits + let literal = ((accumulator & 0x7F00_0000) >> 24) as u8; + self.history_buffer[history_ptr] = literal; + history_ptr += 1; + bs.shift(8); + continue; + } else if (accumulator & 0xC000_0000) == 0x8000_0000 { + // Literal >= 0x80: bits 10 followed by lower 7 bits + let literal = (((accumulator & 0x3F80_0000) >> 23) as u8).wrapping_add(0x80); + self.history_buffer[history_ptr] = literal; + history_ptr += 1; + bs.shift(9); + continue; + } + + // --- CopyOffset Encoding --- + + let copy_offset: usize; + + if compression_level != 0 { + // RDP5 + if (accumulator & 0xF800_0000) == 0xF800_0000 { + // CopyOffset [0, 63]: bits 11111 + 6 bits + copy_offset = ((accumulator >> 21) & 0x3F) as usize; + bs.shift(11); + } else if (accumulator & 0xF800_0000) == 0xF000_0000 { + // CopyOffset [64, 319]: bits 11110 + 8 bits + copy_offset = ((accumulator >> 19) & 0xFF) as usize + 64; + bs.shift(13); + } else if (accumulator & 0xF000_0000) == 0xE000_0000 { + // CopyOffset [320, 2367]: bits 1110 + 11 bits + copy_offset = ((accumulator >> 17) & 0x7FF) as usize + 320; + bs.shift(15); + } else if (accumulator & 0xE000_0000) == 0xC000_0000 { + // CopyOffset [2368, ]: bits 110 + 16 bits + copy_offset = ((accumulator >> 13) & 0xFFFF) as usize + 2368; + bs.shift(19); + } else { + return Err(BulkError::InvalidCompressedData("invalid RDP5 CopyOffset encoding")); + } + } else { + // RDP4 + if (accumulator & 0xF000_0000) == 0xF000_0000 { + // CopyOffset [0, 63]: bits 1111 + 6 bits + copy_offset = ((accumulator >> 22) & 0x3F) as usize; + bs.shift(10); + } else if (accumulator & 0xF000_0000) == 0xE000_0000 { + // CopyOffset [64, 319]: bits 1110 + 8 bits + copy_offset = ((accumulator >> 20) & 0xFF) as usize + 64; + bs.shift(12); + } else if (accumulator & 0xE000_0000) == 0xC000_0000 { + // CopyOffset [320, 8191]: bits 110 + 13 bits + copy_offset = ((accumulator >> 16) & 0x1FFF) as usize + 320; + bs.shift(16); + } else { + return Err(BulkError::InvalidCompressedData("invalid RDP4 CopyOffset encoding")); + } + } + + // --- LengthOfMatch Encoding --- + // Re-read accumulator after shifting for CopyOffset + let accumulator = bs.accumulator(); + let length_of_match: usize; + + if (accumulator & 0x8000_0000) == 0x0000_0000 { + // LengthOfMatch [3]: bit 0 + length_of_match = 3; + bs.shift(1); + } else if (accumulator & 0xC000_0000) == 0x8000_0000 { + // LengthOfMatch [4, 7]: bits 10 + 2 bits + length_of_match = ((accumulator >> 28) & 0x0003) as usize + 4; + bs.shift(4); + } else if (accumulator & 0xE000_0000) == 0xC000_0000 { + // LengthOfMatch [8, 15]: bits 110 + 3 bits + length_of_match = ((accumulator >> 26) & 0x0007) as usize + 8; + bs.shift(6); + } else if (accumulator & 0xF000_0000) == 0xE000_0000 { + // LengthOfMatch [16, 31]: bits 1110 + 4 bits + length_of_match = ((accumulator >> 24) & 0x000F) as usize + 16; + bs.shift(8); + } else if (accumulator & 0xF800_0000) == 0xF000_0000 { + // LengthOfMatch [32, 63]: bits 11110 + 5 bits + length_of_match = ((accumulator >> 22) & 0x001F) as usize + 32; + bs.shift(10); + } else if (accumulator & 0xFC00_0000) == 0xF800_0000 { + // LengthOfMatch [64, 127]: bits 111110 + 6 bits + length_of_match = ((accumulator >> 20) & 0x003F) as usize + 64; + bs.shift(12); + } else if (accumulator & 0xFE00_0000) == 0xFC00_0000 { + // LengthOfMatch [128, 255]: bits 1111110 + 7 bits + length_of_match = ((accumulator >> 18) & 0x007F) as usize + 128; + bs.shift(14); + } else if (accumulator & 0xFF00_0000) == 0xFE00_0000 { + // LengthOfMatch [256, 511]: bits 11111110 + 8 bits + length_of_match = ((accumulator >> 16) & 0x00FF) as usize + 256; + bs.shift(16); + } else if (accumulator & 0xFF80_0000) == 0xFF00_0000 { + // LengthOfMatch [512, 1023]: bits 111111110 + 9 bits + length_of_match = ((accumulator >> 14) & 0x01FF) as usize + 512; + bs.shift(18); + } else if (accumulator & 0xFFC0_0000) == 0xFF80_0000 { + // LengthOfMatch [1024, 2047]: bits 1111111110 + 10 bits + length_of_match = ((accumulator >> 12) & 0x03FF) as usize + 1024; + bs.shift(20); + } else if (accumulator & 0xFFE0_0000) == 0xFFC0_0000 { + // LengthOfMatch [2048, 4095]: bits 11111111110 + 11 bits + length_of_match = ((accumulator >> 10) & 0x07FF) as usize + 2048; + bs.shift(22); + } else if (accumulator & 0xFFF0_0000) == 0xFFE0_0000 { + // LengthOfMatch [4096, 8191]: bits 111111111110 + 12 bits + length_of_match = ((accumulator >> 8) & 0x0FFF) as usize + 4096; + bs.shift(24); + } else if (accumulator & 0xFFF8_0000) == 0xFFF0_0000 && compression_level != 0 { + // RDP5 only: LengthOfMatch [8192, 16383]: bits 1111111111110 + 13 bits + length_of_match = ((accumulator >> 6) & 0x1FFF) as usize + 8192; + bs.shift(26); + } else if (accumulator & 0xFFFC_0000) == 0xFFF8_0000 && compression_level != 0 { + // RDP5 only: LengthOfMatch [16384, 32767]: bits 11111111111110 + 14 bits + length_of_match = ((accumulator >> 4) & 0x3FFF) as usize + 16384; + bs.shift(28); + } else if (accumulator & 0xFFFE_0000) == 0xFFFC_0000 && compression_level != 0 { + // RDP5 only: LengthOfMatch [32768, 65535]: bits 111111111111110 + 15 bits + length_of_match = ((accumulator >> 2) & 0x7FFF) as usize + 32768; + bs.shift(30); + } else { + return Err(BulkError::InvalidCompressedData("invalid LengthOfMatch encoding")); + } + + // Check that the copy won't overflow the history buffer + if history_ptr + length_of_match - 1 > history_buffer_end { + return Err(BulkError::HistoryBufferOverflow); + } + + // Copy from history buffer at (current - copy_offset) with wrapping. + let src_start = (history_ptr.wrapping_sub(copy_offset)) & history_mask; + + if copy_offset >= length_of_match && src_start + length_of_match <= history_buffer_size { + // Fast path: no overlap and no wrap-around — bulk copy. + self.history_buffer + .copy_within(src_start..src_start + length_of_match, history_ptr); + history_ptr += length_of_match; + } else { + // Slow path: overlapping (LZ77 repeat) or wrapping around the ring buffer. + let mut src_index = src_start; + for _ in 0..length_of_match { + self.history_buffer[history_ptr] = self.history_buffer[src_index]; + history_ptr += 1; + src_index = (src_index + 1) & history_mask; + } + } + } + + let output_end = history_ptr; + self.history_ptr = history_ptr; + + Ok(&self.history_buffer[output_start..output_end]) + } + + /// Compresses data using MPPC (LZ77 with 3-byte hash matching). + /// + /// + /// # Arguments + /// + /// * `src_data` — input data to compress. + /// * `output_buffer` — caller-provided buffer for compressed output + /// (should be at least `src_data.len()` bytes). + /// + /// # Returns + /// + /// `Ok((output_size, flags))`: + /// - If `flags & PACKET_COMPRESSED != 0`: compressed data is in + /// `output_buffer[..output_size]`. + /// - If `flags & PACKET_FLUSHED != 0` and `flags & PACKET_COMPRESSED == 0`: + /// compression overflowed; caller should send `src_data` uncompressed + /// with the returned flags. `output_size` equals `src_data.len()`. + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "narrowing casts are bounded: local_ptr < 65536 (fits u16), \ + copy_offset < history_buffer_size (fits u32), \ + length_of_match < 65536 (fits u32)" + )] + pub(crate) fn compress(&mut self, src_data: &[u8], output_buffer: &mut [u8]) -> Result<(usize, u32), BulkError> { + let history_buffer_size = self.history_buffer_size; + let compression_level = self.compression_level; + let mut history_offset = self.history_offset; + let mut result_flags: u32 = 0; + let mut packet_flushed = false; + + // Determine whether the history buffer has room for this data. + // If not (or first call), reset to position 0. + let packet_at_front = + if history_offset != 0 && (history_offset + src_data.len()) < history_buffer_size.saturating_sub(3) { + false + } else { + // Sentinel value from reset(flush=true) means prior call flushed + if history_offset == history_buffer_size + 1 { + packet_flushed = true; + } + history_offset = 0; + true + }; + + let mut local_ptr = history_offset; // local write position in history buffer + + // Cap destination size: compressed output should not exceed source size + let dst_size = core::cmp::min(output_buffer.len(), src_data.len()); + + if src_data.is_empty() || dst_size == 0 { + result_flags |= flags::PACKET_COMPRESSED | compression_level; + if packet_at_front { + result_flags |= flags::PACKET_AT_FRONT; + } + if packet_flushed { + result_flags |= flags::PACKET_FLUSHED; + } + self.history_ptr = local_ptr; + self.history_offset = local_ptr; + return Ok((0, result_flags)); + } + + let mut bs = BitStreamWriter::new(&mut output_buffer[..dst_size]); + + let src_len = src_data.len(); + let mut src_idx: usize = 0; + + // --- Main compression loop --- + // Need at least 3 lookahead bytes for hash matching. + // C: while (pSrcPtr < (pSrcEnd - 2)) where pSrcEnd = &pSrcData[SrcSize-1] + while src_idx + 3 < src_len { + let sym1 = src_data[src_idx]; + let sym2 = src_data[src_idx + 1]; + let sym3 = src_data[src_idx + 2]; + + // Copy Sym1 to history and advance + self.history_buffer[local_ptr] = sym1; + local_ptr += 1; + src_idx += 1; + + // Hash the 3-byte window + let match_index = tables::mppc_match_index(sym1, sym2, sym3); + let match_pos = usize::from(self.match_buffer[match_index]); + + // Update hash table if it doesn't already point here + if match_pos != local_ptr - 1 { + self.match_buffer[match_index] = local_ptr as u16; + } + + // Update high-water mark + if self.history_ptr < local_ptr { + self.history_ptr = local_ptr; + } + + // Validate the match (order matters: check match_pos == 0 first to avoid underflow) + let no_match = match_pos == 0 + || match_pos == local_ptr - 1 + || match_pos == local_ptr + || match_pos + 1 > self.history_ptr + || self.history_buffer[match_pos - 1] != sym1 + || self.history_buffer[match_pos] != sym2 + || self.history_buffer[match_pos + 1] != sym3; + + if no_match { + // --- Encode as literal --- + + // Overflow check: literal needs at most 9 bits (~2 bytes) + if (bs.bits_written() / 8) + 2 > dst_size - 1 { + self.reset(true); + return Ok((src_data.len(), flags::PACKET_FLUSHED | compression_level)); + } + + let accumulator = u32::from(sym1); + if accumulator < 0x80 { + // 8 bits: literal as-is + bs.write_bits(accumulator, 8); + } else { + // 9 bits: prefix 10 + lower 7 bits + bs.write_bits(0x100 | (accumulator & 0x7F), 9); + } + } else { + // --- Found a match --- + + let copy_offset = (history_buffer_size - 1) & local_ptr.wrapping_sub(match_pos); + + // Copy Sym2, Sym3 to history + self.history_buffer[local_ptr] = sym2; + local_ptr += 1; + self.history_buffer[local_ptr] = sym3; + local_ptr += 1; + src_idx += 2; + + let mut length_of_match: usize = 3; + let mut match_ptr = match_pos + 2; + + // Extend match (up to but not including the last source byte) + while src_idx < src_len - 1 + && match_ptr <= self.history_ptr + && src_data[src_idx] == self.history_buffer[match_ptr] + { + self.history_buffer[local_ptr] = src_data[src_idx]; + local_ptr += 1; + src_idx += 1; + match_ptr += 1; + length_of_match += 1; + } + + // Overflow check: match encoding can use up to ~51 bits (~7 bytes) + if (bs.bits_written() / 8) + 7 > dst_size - 1 { + self.reset(true); + return Ok((src_data.len(), flags::PACKET_FLUSHED | compression_level)); + } + + // --- Encode CopyOffset --- + let co = copy_offset as u32; + if compression_level != 0 { + // RDP5 + if copy_offset < 64 { + bs.write_bits(0x07C0 | (co & 0x003F), 11); + } else if copy_offset < 320 { + bs.write_bits(0x1E00 | ((co - 64) & 0x00FF), 13); + } else if copy_offset < 2368 { + bs.write_bits(0x7000 | ((co - 320) & 0x07FF), 15); + } else { + bs.write_bits(0x060000 | ((co - 2368) & 0xFFFF), 19); + } + } else { + // RDP4 + if copy_offset < 64 { + bs.write_bits(0x03C0 | (co & 0x003F), 10); + } else if copy_offset < 320 { + bs.write_bits(0x0E00 | ((co - 64) & 0x00FF), 12); + } else if copy_offset < 8192 { + bs.write_bits(0xC000 | ((co - 320) & 0x1FFF), 16); + } + } + + // --- Encode LengthOfMatch --- + let lom = length_of_match as u32; + if length_of_match == 3 { + bs.write_bits(0, 1); + } else if length_of_match < 8 { + bs.write_bits(0x0008 | (lom & 0x0003), 4); + } else if length_of_match < 16 { + bs.write_bits(0x0030 | (lom & 0x0007), 6); + } else if length_of_match < 32 { + bs.write_bits(0x00E0 | (lom & 0x000F), 8); + } else if length_of_match < 64 { + bs.write_bits(0x03C0 | (lom & 0x001F), 10); + } else if length_of_match < 128 { + bs.write_bits(0x0F80 | (lom & 0x003F), 12); + } else if length_of_match < 256 { + bs.write_bits(0x3F00 | (lom & 0x007F), 14); + } else if length_of_match < 512 { + bs.write_bits(0xFE00 | (lom & 0x00FF), 16); + } else if length_of_match < 1024 { + bs.write_bits(0x3FC00 | (lom & 0x01FF), 18); + } else if length_of_match < 2048 { + bs.write_bits(0xFF800 | (lom & 0x03FF), 20); + } else if length_of_match < 4096 { + bs.write_bits(0x3FF000 | (lom & 0x07FF), 22); + } else if length_of_match < 8192 { + bs.write_bits(0xFFE000 | (lom & 0x0FFF), 24); + } else if length_of_match < 16384 && compression_level != 0 { + bs.write_bits(0x3FFC000 | (lom & 0x1FFF), 26); + } else if length_of_match < 32768 && compression_level != 0 { + bs.write_bits(0xFFF8000 | (lom & 0x3FFF), 28); + } else if length_of_match < 65536 && compression_level != 0 { + bs.write_bits(0x3FFF0000 | (lom & 0x7FFF), 30); + } + } + } + + // --- Encode trailing symbols as literals --- + while src_idx < src_len { + if (bs.bits_written() / 8) + 2 > dst_size - 1 { + self.reset(true); + return Ok((src_data.len(), flags::PACKET_FLUSHED | compression_level)); + } + + let lit = u32::from(src_data[src_idx]); + if lit < 0x80 { + bs.write_bits(lit, 8); + } else { + bs.write_bits(0x100 | (lit & 0x7F), 9); + } + + self.history_buffer[local_ptr] = src_data[src_idx]; + local_ptr += 1; + src_idx += 1; + } + + // Flush remaining bits in the accumulator + bs.flush(); + + result_flags |= flags::PACKET_COMPRESSED | compression_level; + + if packet_at_front { + result_flags |= flags::PACKET_AT_FRONT; + } + if packet_flushed { + result_flags |= flags::PACKET_FLUSHED; + } + + let output_size = bs.byte_length(); + self.history_ptr = local_ptr; + self.history_offset = local_ptr; + + Ok((output_size, result_flags)) + } +} + +#[cfg(test)] +mod test_data; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decompress_uncompressed_passthrough() { + let mut ctx = MppcContext::new(0); + let data = b"hello world"; + // No PACKET_COMPRESSED flag → should return source data directly + let result = ctx.decompress(data, flags::PACKET_AT_FRONT).unwrap(); + assert_eq!(result, b"hello world"); + } + + #[test] + fn test_decompress_flushed_resets_history() { + let mut ctx = MppcContext::new(0); + // Write something into the history buffer first + ctx.history_buffer[0] = 0xAA; + ctx.history_buffer[1] = 0xBB; + ctx.history_ptr = 100; + ctx.history_offset = 50; + + let data = b"test"; + // PACKET_FLUSHED | PACKET_AT_FRONT without PACKET_COMPRESSED + let flags_value = flags::PACKET_FLUSHED | flags::PACKET_AT_FRONT; + let result = ctx.decompress(data, flags_value).unwrap(); + // Should return source data (not compressed) + assert_eq!(result, b"test"); + // History should be zeroed and pointers reset + assert_eq!(ctx.history_ptr, 0); + assert_eq!(ctx.history_offset, 0); + assert_eq!(ctx.history_buffer[0], 0); + assert_eq!(ctx.history_buffer[1], 0); + } + + #[test] + fn test_decompress_at_front_resets_pointer() { + let mut ctx = MppcContext::new(1); + ctx.history_ptr = 500; + ctx.history_offset = 200; + + let data = b"data"; + let flags_value = flags::PACKET_AT_FRONT; + let result = ctx.decompress(data, flags_value).unwrap(); + assert_eq!(result, b"data"); + assert_eq!(ctx.history_ptr, 0); + assert_eq!(ctx.history_offset, 0); + } + + /// + /// Decompresses `TEST_MPPC_BELLS_RDP4` using RDP4 (8K history) + /// and verifies the output matches "for.whom.the.bell.tolls,.the.bell.tolls.for.thee!". + #[test] + fn test_mppc_decompress_bells_rdp4() { + let mut ctx = MppcContext::new(0); + // Flags: PACKET_AT_FRONT | PACKET_COMPRESSED (RDP4 — compression level 0) + let flags_value = flags::PACKET_AT_FRONT | flags::PACKET_COMPRESSED; + let result = ctx.decompress(test_data::TEST_MPPC_BELLS_RDP4, flags_value).unwrap(); + assert_eq!( + result.len(), + test_data::TEST_MPPC_BELLS.len(), + "output size mismatch: actual={}, expected={}", + result.len(), + test_data::TEST_MPPC_BELLS.len() + ); + assert_eq!( + result, + test_data::TEST_MPPC_BELLS, + "MppcDecompressBellsRdp4: output mismatch" + ); + } + + /// + /// Decompresses `TEST_MPPC_BELLS_RDP5` using RDP5 (64K history) + /// and verifies the output matches "for.whom.the.bell.tolls,.the.bell.tolls.for.thee!". + #[test] + fn test_mppc_decompress_bells_rdp5() { + let mut ctx = MppcContext::new(1); + // Flags: PACKET_AT_FRONT | PACKET_COMPRESSED | 1 (RDP5) + let flags_value = flags::PACKET_AT_FRONT | flags::PACKET_COMPRESSED | 1; + let result = ctx.decompress(test_data::TEST_MPPC_BELLS_RDP5, flags_value).unwrap(); + assert_eq!( + result.len(), + test_data::TEST_MPPC_BELLS.len(), + "output size mismatch: actual={}, expected={}", + result.len(), + test_data::TEST_MPPC_BELLS.len() + ); + assert_eq!( + result, + test_data::TEST_MPPC_BELLS, + "MppcDecompressBellsRdp5: output mismatch" + ); + } + + /// + /// Decompresses a large binary buffer using RDP5 (64K history) + /// and verifies byte-for-byte match with the expected uncompressed data. + #[test] + fn test_mppc_decompress_buffer_rdp5() { + let mut ctx = MppcContext::new(1); + // Flags: PACKET_AT_FRONT | PACKET_COMPRESSED | 1 (RDP5) + let flags_value = flags::PACKET_AT_FRONT | flags::PACKET_COMPRESSED | 1; + let result = ctx + .decompress(test_data::TEST_RDP5_COMPRESSED_DATA, flags_value) + .unwrap(); + assert_eq!( + result.len(), + test_data::TEST_RDP5_UNCOMPRESSED_DATA.len(), + "output size mismatch: actual={}, expected={}", + result.len(), + test_data::TEST_RDP5_UNCOMPRESSED_DATA.len() + ); + assert_eq!( + result, + test_data::TEST_RDP5_UNCOMPRESSED_DATA, + "MppcDecompressBufferRdp5: output mismatch" + ); + } + + // ======================== + // Compression tests + // ======================== + + /// + /// Compresses the "bells" text with RDP4 (8K) and verifies the output + /// matches the expected compressed bytes byte-for-byte. + #[test] + fn test_mppc_compress_bells_rdp4() { + let mut ctx = MppcContext::new(0); + let mut output = [0u8; 65536]; + let (size, result_flags) = ctx.compress(test_data::TEST_MPPC_BELLS, &mut output).unwrap(); + assert!( + result_flags & flags::PACKET_COMPRESSED != 0, + "expected PACKET_COMPRESSED flag, got 0x{result_flags:08X}" + ); + assert_eq!( + size, + test_data::TEST_MPPC_BELLS_RDP4.len(), + "MppcCompressBellsRdp4: output size mismatch: actual={size}, expected={}", + test_data::TEST_MPPC_BELLS_RDP4.len() + ); + assert_eq!( + &output[..size], + test_data::TEST_MPPC_BELLS_RDP4, + "MppcCompressBellsRdp4: output mismatch" + ); + } + + /// + /// Compresses the "bells" text with RDP5 (64K) and verifies the output + /// matches the expected compressed bytes byte-for-byte. + #[test] + fn test_mppc_compress_bells_rdp5() { + let mut ctx = MppcContext::new(1); + let mut output = [0u8; 65536]; + let (size, result_flags) = ctx.compress(test_data::TEST_MPPC_BELLS, &mut output).unwrap(); + assert!( + result_flags & flags::PACKET_COMPRESSED != 0, + "expected PACKET_COMPRESSED flag, got 0x{result_flags:08X}" + ); + assert_eq!( + size, + test_data::TEST_MPPC_BELLS_RDP5.len(), + "MppcCompressBellsRdp5: output size mismatch: actual={size}, expected={}", + test_data::TEST_MPPC_BELLS_RDP5.len() + ); + assert_eq!( + &output[..size], + test_data::TEST_MPPC_BELLS_RDP5, + "MppcCompressBellsRdp5: output mismatch" + ); + } + + /// + /// Compresses the "island" text (John Donne excerpt) with RDP5 (64K) + /// and verifies byte-for-byte match with the expected compressed output. + #[test] + fn test_mppc_compress_island_rdp5() { + let mut ctx = MppcContext::new(1); + let mut output = [0u8; 65536]; + let (size, result_flags) = ctx.compress(test_data::TEST_ISLAND_DATA, &mut output).unwrap(); + assert!( + result_flags & flags::PACKET_COMPRESSED != 0, + "expected PACKET_COMPRESSED flag, got 0x{result_flags:08X}" + ); + assert_eq!( + size, + test_data::TEST_ISLAND_DATA_RDP5.len(), + "MppcCompressIslandRdp5: output size mismatch: actual={size}, expected={}", + test_data::TEST_ISLAND_DATA_RDP5.len() + ); + assert_eq!( + &output[..size], + test_data::TEST_ISLAND_DATA_RDP5, + "MppcCompressIslandRdp5: output mismatch" + ); + } + + /// + /// Compresses a large binary buffer with RDP5 (64K) and verifies + /// byte-for-byte match with the expected compressed output. + #[test] + fn test_mppc_compress_buffer_rdp5() { + let mut ctx = MppcContext::new(1); + let mut output = [0u8; 65536]; + let (size, result_flags) = ctx + .compress(test_data::TEST_RDP5_UNCOMPRESSED_DATA, &mut output) + .unwrap(); + assert!( + result_flags & flags::PACKET_COMPRESSED != 0, + "expected PACKET_COMPRESSED flag, got 0x{result_flags:08X}" + ); + assert_eq!( + size, + test_data::TEST_RDP5_COMPRESSED_DATA.len(), + "MppcCompressBufferRdp5: output size mismatch: actual={size}, expected={}", + test_data::TEST_RDP5_COMPRESSED_DATA.len() + ); + assert_eq!( + &output[..size], + test_data::TEST_RDP5_COMPRESSED_DATA, + "MppcCompressBufferRdp5: output mismatch" + ); + } + + // ======================== + // Round-trip tests + // ======================== + + /// Helper: compress data, then decompress, and assert the round-trip matches. + fn assert_roundtrip(compression_level: u32, input: &[u8], label: &str) { + let mut compressor = MppcContext::new(compression_level); + let mut compressed_buf = vec![0u8; 65536]; + + let (compressed_size, compress_flags) = compressor + .compress(input, &mut compressed_buf) + .unwrap_or_else(|e| panic!("{label}: compress failed: {e:?}")); + + // If flushed without compression, the caller would send the data uncompressed. + // For round-trip validation, we require actual compression. + assert!( + compress_flags & flags::PACKET_COMPRESSED != 0, + "{label}: expected PACKET_COMPRESSED, got flags=0x{compress_flags:08X}" + ); + assert!( + compressed_size < input.len(), + "{label}: compressed size ({compressed_size}) should be smaller than input ({})", + input.len() + ); + + let mut decompressor = MppcContext::new(compression_level); + let decompressed = decompressor + .decompress(&compressed_buf[..compressed_size], compress_flags) + .unwrap_or_else(|e| panic!("{label}: decompress failed: {e:?}")); + + assert_eq!( + decompressed.len(), + input.len(), + "{label}: round-trip size mismatch: decompressed={}, original={}", + decompressed.len(), + input.len() + ); + assert_eq!(decompressed, input, "{label}: round-trip data mismatch"); + } + + /// Round-trip test: small text input with RDP4. + #[test] + fn test_roundtrip_small_rdp4() { + // The "bells" test string (49 bytes) — has repetition + assert_roundtrip(0, test_data::TEST_MPPC_BELLS, "roundtrip_small_rdp4"); + } + + /// Round-trip test: small text input with RDP5. + #[test] + fn test_roundtrip_small_rdp5() { + assert_roundtrip(1, test_data::TEST_MPPC_BELLS, "roundtrip_small_rdp5"); + } + + /// Round-trip test: medium text input (~386 bytes) with RDP5. + #[test] + fn test_roundtrip_medium_rdp5() { + assert_roundtrip(1, test_data::TEST_ISLAND_DATA, "roundtrip_medium_rdp5"); + } + + /// Round-trip test: large binary input (~6.5 KB) with RDP5. + #[test] + fn test_roundtrip_large_rdp5() { + assert_roundtrip(1, test_data::TEST_RDP5_UNCOMPRESSED_DATA, "roundtrip_large_rdp5"); + } + + /// Round-trip test: ~16 KB repetitive pattern with RDP5. + /// + /// Uses a synthetic pattern that exercises match extension. + #[test] + fn test_roundtrip_16kb_pattern_rdp5() { + // Create a 16KB buffer with a repeating pattern + let pattern = b"The quick brown fox jumps over the lazy dog. "; + let mut data = Vec::with_capacity(16384); + while data.len() < 16384 { + let remaining = 16384 - data.len(); + let chunk = core::cmp::min(remaining, pattern.len()); + data.extend_from_slice(&pattern[..chunk]); + } + assert_roundtrip(1, &data, "roundtrip_16kb_pattern_rdp5"); + } + + /// Round-trip test: ~16 KB repetitive pattern with RDP4. + #[test] + fn test_roundtrip_16kb_pattern_rdp4() { + let pattern = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 "; + let mut data = Vec::with_capacity(8000); + while data.len() < 8000 { + let remaining = 8000 - data.len(); + let chunk = core::cmp::min(remaining, pattern.len()); + data.extend_from_slice(&pattern[..chunk]); + } + assert_roundtrip(0, &data, "roundtrip_16kb_pattern_rdp4"); + } +} diff --git a/crates/ironrdp-bulk/src/mppc/tables.rs b/crates/ironrdp-bulk/src/mppc/tables.rs new file mode 100644 index 0000000000..6a8b0a3573 --- /dev/null +++ b/crates/ironrdp-bulk/src/mppc/tables.rs @@ -0,0 +1,71 @@ +//! MPPC constants and lookup tables. + +/// RDP4 history buffer size (8 KB). +pub(crate) const HISTORY_BUFFER_SIZE_RDP4: usize = 8192; + +/// RDP5 history buffer size (64 KB). +pub(crate) const HISTORY_BUFFER_SIZE_RDP5: usize = 65536; + +/// Match buffer size used for compression hash table. +pub(crate) const MATCH_BUFFER_SIZE: usize = 32768; + +/// History buffer wrapping mask for RDP4 (8K - 1 = 0x1FFF). +pub(crate) const HISTORY_MASK_RDP4: usize = 0x1FFF; + +/// History buffer wrapping mask for RDP5 (64K - 1 = 0xFFFF). +pub(crate) const HISTORY_MASK_RDP5: usize = 0xFFFF; + +/// Computes a 15-bit hash index from three consecutive bytes. +/// +/// Equivalent to: +/// ```c +/// #define MPPC_MATCH_INDEX(_sym1, _sym2, _sym3) +/// ((((MPPC_MATCH_TABLE[_sym3] << 16) + (MPPC_MATCH_TABLE[_sym2] << 8) + +/// MPPC_MATCH_TABLE[_sym1]) & 0x07FFF000) >> 12) +/// ``` +#[expect(clippy::as_conversions, reason = "result masked to 15 bits, always fits in usize")] +pub(crate) fn mppc_match_index(sym1: u8, sym2: u8, sym3: u8) -> usize { + let val = MPPC_MATCH_TABLE[usize::from(sym3)] + .wrapping_shl(16) + .wrapping_add(MPPC_MATCH_TABLE[usize::from(sym2)].wrapping_shl(8)) + .wrapping_add(MPPC_MATCH_TABLE[usize::from(sym1)]); + ((val & 0x07FFF000) >> 12) as usize +} + +/// 256-entry lookup table used for 3-byte hash computation. +/// +#[rustfmt::skip] +pub(crate) static MPPC_MATCH_TABLE: [u32; 256] = [ + 0x00000000, 0x009CCF93, 0x01399F26, 0x01D66EB9, 0x02733E4C, 0x03100DDF, 0x03ACDD72, 0x0449AD05, + 0x04E67C98, 0x05834C2B, 0x06201BBE, 0x06BCEB51, 0x0759BAE4, 0x07F68A77, 0x08935A0A, 0x0930299D, + 0x09CCF930, 0x0A69C8C3, 0x0B069856, 0x0BA367E9, 0x0C40377C, 0x0CDD070F, 0x0D79D6A2, 0x0E16A635, + 0x0EB375C8, 0x0F50455B, 0x0FED14EE, 0x1089E481, 0x1126B414, 0x11C383A7, 0x1260533A, 0x12FD22CD, + 0x1399F260, 0x1436C1F3, 0x14D39186, 0x15706119, 0x160D30AC, 0x16AA003F, 0x1746CFD2, 0x17E39F65, + 0x18806EF8, 0x191D3E8B, 0x19BA0E1E, 0x1A56DDB1, 0x1AF3AD44, 0x1B907CD7, 0x1C2D4C6A, 0x1CCA1BFD, + 0x1D66EB90, 0x1E03BB23, 0x1EA08AB6, 0x1F3D5A49, 0x1FDA29DC, 0x2076F96F, 0x2113C902, 0x21B09895, + 0x224D6828, 0x22EA37BB, 0x2387074E, 0x2423D6E1, 0x24C0A674, 0x255D7607, 0x25FA459A, 0x2697152D, + 0x2733E4C0, 0x27D0B453, 0x286D83E6, 0x290A5379, 0x29A7230C, 0x2A43F29F, 0x2AE0C232, 0x2B7D91C5, + 0x2C1A6158, 0x2CB730EB, 0x2D54007E, 0x2DF0D011, 0x2E8D9FA4, 0x2F2A6F37, 0x2FC73ECA, 0x30640E5D, + 0x3100DDF0, 0x319DAD83, 0x323A7D16, 0x32D74CA9, 0x33741C3C, 0x3410EBCF, 0x34ADBB62, 0x354A8AF5, + 0x35E75A88, 0x36842A1B, 0x3720F9AE, 0x37BDC941, 0x385A98D4, 0x38F76867, 0x399437FA, 0x3A31078D, + 0x3ACDD720, 0x3B6AA6B3, 0x3C077646, 0x3CA445D9, 0x3D41156C, 0x3DDDE4FF, 0x3E7AB492, 0x3F178425, + 0x3FB453B8, 0x4051234B, 0x40EDF2DE, 0x418AC271, 0x42279204, 0x42C46197, 0x4361312A, 0x43FE00BD, + 0x449AD050, 0x45379FE3, 0x45D46F76, 0x46713F09, 0x470E0E9C, 0x47AADE2F, 0x4847ADC2, 0x48E47D55, + 0x49814CE8, 0x4A1E1C7B, 0x4ABAEC0E, 0x4B57BBA1, 0x4BF48B34, 0x4C915AC7, 0x4D2E2A5A, 0x4DCAF9ED, + 0x4E67C980, 0x4F049913, 0x4FA168A6, 0x503E3839, 0x50DB07CC, 0x5177D75F, 0x5214A6F2, 0x52B17685, + 0x534E4618, 0x53EB15AB, 0x5487E53E, 0x5524B4D1, 0x55C18464, 0x565E53F7, 0x56FB238A, 0x5797F31D, + 0x5834C2B0, 0x58D19243, 0x596E61D6, 0x5A0B3169, 0x5AA800FC, 0x5B44D08F, 0x5BE1A022, 0x5C7E6FB5, + 0x5D1B3F48, 0x5DB80EDB, 0x5E54DE6E, 0x5EF1AE01, 0x5F8E7D94, 0x602B4D27, 0x60C81CBA, 0x6164EC4D, + 0x6201BBE0, 0x629E8B73, 0x633B5B06, 0x63D82A99, 0x6474FA2C, 0x6511C9BF, 0x65AE9952, 0x664B68E5, + 0x66E83878, 0x6785080B, 0x6821D79E, 0x68BEA731, 0x695B76C4, 0x69F84657, 0x6A9515EA, 0x6B31E57D, + 0x6BCEB510, 0x6C6B84A3, 0x6D085436, 0x6DA523C9, 0x6E41F35C, 0x6EDEC2EF, 0x6F7B9282, 0x70186215, + 0x70B531A8, 0x7152013B, 0x71EED0CE, 0x728BA061, 0x73286FF4, 0x73C53F87, 0x74620F1A, 0x74FEDEAD, + 0x759BAE40, 0x76387DD3, 0x76D54D66, 0x77721CF9, 0x780EEC8C, 0x78ABBC1F, 0x79488BB2, 0x79E55B45, + 0x7A822AD8, 0x7B1EFA6B, 0x7BBBC9FE, 0x7C589991, 0x7CF56924, 0x7D9238B7, 0x7E2F084A, 0x7ECBD7DD, + 0x7F68A770, 0x80057703, 0x80A24696, 0x813F1629, 0x81DBE5BC, 0x8278B54F, 0x831584E2, 0x83B25475, + 0x844F2408, 0x84EBF39B, 0x8588C32E, 0x862592C1, 0x86C26254, 0x875F31E7, 0x87FC017A, 0x8898D10D, + 0x8935A0A0, 0x89D27033, 0x8A6F3FC6, 0x8B0C0F59, 0x8BA8DEEC, 0x8C45AE7F, 0x8CE27E12, 0x8D7F4DA5, + 0x8E1C1D38, 0x8EB8ECCB, 0x8F55BC5E, 0x8FF28BF1, 0x908F5B84, 0x912C2B17, 0x91C8FAAA, 0x9265CA3D, + 0x930299D0, 0x939F6963, 0x943C38F6, 0x94D90889, 0x9575D81C, 0x9612A7AF, 0x96AF7742, 0x974C46D5, + 0x97E91668, 0x9885E5FB, 0x9922B58E, 0x99BF8521, 0x9A5C54B4, 0x9AF92447, 0x9B95F3DA, 0x9C32C36D, +]; diff --git a/crates/ironrdp-bulk/src/mppc/test_data.rs b/crates/ironrdp-bulk/src/mppc/test_data.rs new file mode 100644 index 0000000000..fbdb5d58fc --- /dev/null +++ b/crates/ironrdp-bulk/src/mppc/test_data.rs @@ -0,0 +1,651 @@ +//! MPPC test vectors. + +/// Expected decompressed text: "for.whom.the.bell.tolls,.the.bell.tolls.for.thee!" +pub(super) const TEST_MPPC_BELLS: &[u8] = b"for.whom.the.bell.tolls,.the.bell.tolls.for.thee!"; + +/// RDP4 (MPPC 8K) compressed form of TEST_MPPC_BELLS. Flags: 0x0060, Length: 33 +#[rustfmt::skip] +pub(super) const TEST_MPPC_BELLS_RDP4: &[u8] = &[ + 0x66, 0x6f, 0x72, 0x2e, 0x77, 0x68, 0x6f, 0x6d, 0x2e, 0x74, 0x68, 0x65, 0x2e, 0x62, 0x65, 0x6c, + 0x6c, 0x2e, 0x74, 0x6f, 0x6c, 0x6c, 0x73, 0x2c, 0xf4, 0x37, 0x2e, 0x66, 0xfa, 0x1f, 0x19, 0x94, + 0x84, +]; + +/// RDP5 (MPPC 64K) compressed form of TEST_MPPC_BELLS. Flags: 0x0061, Length: 34 +#[rustfmt::skip] +pub(super) const TEST_MPPC_BELLS_RDP5: &[u8] = &[ + 0x66, 0x6f, 0x72, 0x2e, 0x77, 0x68, 0x6f, 0x6d, 0x2e, 0x74, 0x68, 0x65, 0x2e, 0x62, 0x65, 0x6c, + 0x6c, 0x2e, 0x74, 0x6f, 0x6c, 0x6c, 0x73, 0x2c, 0xfa, 0x1b, 0x97, 0x33, 0x7e, 0x87, 0xe3, 0x32, + 0x90, 0x80, +]; + +#[rustfmt::skip] +pub(super) const TEST_RDP5_COMPRESSED_DATA: &[u8] = &[ + 0x24, 0x02, 0x03, 0x09, 0x00, 0x20, 0x0c, 0x05, 0x10, 0x01, 0x40, 0x0a, 0xbf, 0xdf, 0xc3, 0x20, + 0x80, 0x00, 0x1f, 0x0a, 0x00, 0x00, 0x07, 0x43, 0x4e, 0x00, 0x68, 0x02, 0x00, 0x22, 0x00, 0x34, + 0xcb, 0xfb, 0xf8, 0x18, 0x40, 0x01, 0x00, 0x27, 0xe2, 0x90, 0x0f, 0xc3, 0x91, 0xa8, 0x00, 0x08, + 0x00, 0x00, 0x68, 0x50, 0x60, 0x65, 0xfc, 0x0e, 0xfe, 0x04, 0x00, 0x08, 0x00, 0x06, 0x0c, 0x00, + 0x01, 0x00, 0xf8, 0x40, 0x20, 0x00, 0x00, 0x90, 0x00, 0xcf, 0x95, 0x1f, 0x44, 0x90, 0x00, 0x6e, + 0x03, 0xf4, 0x40, 0x21, 0x9f, 0x26, 0x01, 0xbf, 0x88, 0x10, 0x90, 0x00, 0x08, 0x04, 0x00, 0x04, + 0x30, 0x03, 0xe4, 0xc7, 0xea, 0x05, 0x1e, 0x87, 0xf8, 0x20, 0x1c, 0x00, 0x10, 0x84, 0x22, 0x1f, + 0x71, 0x0d, 0x0e, 0xb9, 0x88, 0x9f, 0x5c, 0xee, 0x41, 0x97, 0xfb, 0xf8, 0x88, 0x68, 0x08, 0x6d, + 0xd0, 0x44, 0xfc, 0x34, 0x06, 0xe6, 0x16, 0x21, 0x04, 0x11, 0x0f, 0xb9, 0x85, 0x86, 0x5d, 0x44, + 0x4f, 0xae, 0xb7, 0x40, 0xa8, 0xcd, 0x5b, 0xed, 0x02, 0xee, 0xc2, 0x21, 0x40, 0x21, 0x21, 0x23, + 0x17, 0xb7, 0x00, 0x60, 0x00, 0x3b, 0xfd, 0xfc, 0x00, 0x0c, 0x00, 0x08, 0x00, 0x34, 0x00, 0x33, + 0xc7, 0xe0, 0xc0, 0x0f, 0x07, 0x12, 0x42, 0x01, 0xe8, 0x6c, 0xc7, 0x83, 0x07, 0x8c, 0xd4, 0x30, + 0x07, 0x20, 0x01, 0x90, 0xa3, 0xf1, 0xdb, 0xf5, 0xd4, 0x13, 0xc2, 0x4f, 0x0f, 0xe5, 0xe2, 0xc7, + 0x87, 0xf2, 0xf0, 0x93, 0xc3, 0xf9, 0x78, 0xb0, 0x1a, 0x03, 0xe1, 0xf1, 0xd0, 0x08, 0x4c, 0x66, + 0xac, 0x32, 0x31, 0x70, 0x60, 0x11, 0x01, 0x11, 0x01, 0x01, 0x01, 0xf0, 0x36, 0x1f, 0xe5, 0xe0, + 0x6c, 0xbc, 0x26, 0xf0, 0x36, 0x5f, 0xe5, 0xe0, 0x6c, 0xbc, 0x26, 0xf0, 0x34, 0xf9, 0x94, 0x32, + 0x31, 0x74, 0x20, 0x10, 0x00, 0x00, 0x0f, 0xbf, 0x87, 0xdf, 0xef, 0xfe, 0x4b, 0xbf, 0x02, 0xfa, + 0xde, 0xa7, 0x79, 0x32, 0x44, 0x7c, 0x20, 0x82, 0x00, 0x5f, 0xef, 0xff, 0x09, 0xe1, 0x05, 0x74, + 0x32, 0xea, 0x09, 0xe1, 0x0f, 0x55, 0x83, 0x85, 0x2a, 0xa0, 0x1d, 0x50, 0x0e, 0x0e, 0x0b, 0x01, + 0x01, 0x43, 0x06, 0x02, 0xbe, 0x5f, 0x00, 0x00, 0x0c, 0x3d, 0x4d, 0x87, 0xa6, 0x5e, 0xa6, 0xcb, + 0xc3, 0xcf, 0x53, 0x65, 0xe9, 0x97, 0xa9, 0xb2, 0xf5, 0x9b, 0xd4, 0xd3, 0xee, 0xcd, 0xc0, 0x7c, + 0xae, 0xe0, 0x65, 0x1f, 0xe5, 0xe0, 0x6c, 0xbc, 0x26, 0xf0, 0x36, 0x5f, 0xe5, 0xe0, 0x6c, 0xbc, + 0x26, 0xf0, 0x34, 0xfb, 0xb3, 0xf2, 0x41, 0x30, 0x20, 0x04, 0xa0, 0x80, 0x93, 0xf3, 0xf2, 0x1b, + 0xed, 0xf6, 0x0f, 0x04, 0x82, 0x7b, 0xcc, 0x00, 0x65, 0xef, 0x4f, 0x86, 0x02, 0xf7, 0xa7, 0xe0, + 0x0a, 0x88, 0x1c, 0x34, 0x02, 0x02, 0x02, 0x60, 0x60, 0x49, 0x40, 0xc1, 0x2f, 0x14, 0xca, 0x60, + 0xc1, 0x81, 0x80, 0x07, 0xc3, 0x00, 0x00, 0x39, 0xfa, 0x86, 0x38, 0x93, 0x47, 0x08, 0x27, 0x08, + 0xfc, 0xb8, 0x4e, 0x38, 0x47, 0xe5, 0xc2, 0x09, 0xc2, 0x3f, 0x2e, 0x13, 0x8e, 0x11, 0xf3, 0xc3, + 0x57, 0x1a, 0x88, 0x7d, 0x44, 0x3c, 0x3c, 0x04, 0x0f, 0xd4, 0x3f, 0x83, 0x8d, 0x82, 0x00, 0x25, + 0x04, 0x84, 0xdf, 0xe0, 0x17, 0xf8, 0x04, 0x03, 0xe1, 0x47, 0xc4, 0xaf, 0x9c, 0x00, 0x00, 0x31, + 0xf5, 0x4c, 0x71, 0x78, 0x8f, 0x54, 0xfb, 0x1c, 0x97, 0xa4, 0x04, 0x13, 0xd5, 0x2f, 0x77, 0xc7, + 0xb8, 0x9e, 0xef, 0xcb, 0xc2, 0x6f, 0x77, 0xe5, 0xee, 0x27, 0xbb, 0xf2, 0xf7, 0xe3, 0xdd, 0xf3, + 0xc6, 0xfb, 0x2a, 0x78, 0x6d, 0x3c, 0x34, 0x37, 0xc0, 0xaf, 0x25, 0xc7, 0x81, 0x7d, 0x6e, 0x5d, + 0x5c, 0xd6, 0xe3, 0x43, 0xc0, 0x82, 0xd0, 0x95, 0x90, 0xd8, 0xbd, 0xfc, 0x00, 0x09, 0xc0, 0x34, + 0x39, 0x46, 0x84, 0x20, 0x40, 0x38, 0xa3, 0x42, 0x12, 0xb0, 0x55, 0xbe, 0x28, 0xc0, 0x70, 0x64, + 0x28, 0xc8, 0x48, 0x42, 0x08, 0xb2, 0x1b, 0x46, 0xa6, 0x09, 0x54, 0x2e, 0x5f, 0x73, 0x84, 0xfc, + 0x28, 0x4a, 0x73, 0x79, 0xf2, 0x6c, 0x5d, 0x82, 0x82, 0x6e, 0xc2, 0x27, 0xd7, 0x6b, 0xb8, 0x4f, + 0xa4, 0xa4, 0x22, 0xee, 0x22, 0x7e, 0x10, 0x03, 0x78, 0x08, 0xf4, 0x94, 0x5e, 0x02, 0x01, 0xef, + 0x02, 0x27, 0xd7, 0x8b, 0xc8, 0x3f, 0xa4, 0xa4, 0x1a, 0xf3, 0xd1, 0x84, 0x0c, 0x32, 0x31, 0x75, + 0x60, 0x05, 0xe2, 0x30, 0xb7, 0xad, 0x5b, 0x15, 0xd5, 0xc3, 0xc0, 0x00, 0x11, 0x81, 0x81, 0x69, + 0x8f, 0x06, 0x0f, 0x14, 0xcf, 0xa6, 0xe8, 0xb1, 0x22, 0x77, 0xeb, 0xd7, 0x45, 0x89, 0xf0, 0xb6, + 0x3e, 0x23, 0x06, 0x80, 0xf8, 0x5b, 0x0f, 0x04, 0x83, 0xfc, 0x2d, 0x8f, 0x88, 0xc1, 0xa0, 0x3e, + 0x16, 0x1d, 0x00, 0x83, 0x74, 0x58, 0xa0, 0xc0, 0x10, 0xce, 0x8b, 0x17, 0xe0, 0x68, 0xff, 0x20, + 0xff, 0x03, 0x63, 0xe5, 0xcf, 0x1f, 0xa0, 0x40, 0x00, 0x00, 0x2a, 0xff, 0xd6, 0xd1, 0xc0, 0xb9, + 0xe0, 0x5f, 0x6b, 0x81, 0x73, 0xc9, 0x93, 0xd1, 0x63, 0x50, 0xf0, 0x9b, 0xf0, 0x48, 0x4f, 0xaf, + 0xe0, 0x1b, 0xef, 0x82, 0x6f, 0xc2, 0x40, 0xe0, 0xe4, 0x60, 0xa0, 0x69, 0xa1, 0xa1, 0xbe, 0xba, + 0x04, 0x00, 0x08, 0x00, 0x10, 0x00, 0x20, 0x00, 0x42, 0x00, 0x44, 0x00, 0x88, 0x01, 0x10, 0x02, + 0x21, 0x02, 0x22, 0x04, 0x44, 0x08, 0x9c, 0x8f, 0xcd, 0xe0, 0x02, 0x20, 0x88, 0x02, 0x10, 0x40, + 0x01, 0xf0, 0x60, 0x44, 0xc0, 0xce, 0xb1, 0x8f, 0xd0, 0x30, 0x00, 0x60, 0x00, 0xa0, 0x00, 0xc4, + 0x00, 0xcc, 0x01, 0x98, 0x03, 0x28, 0x03, 0x31, 0x03, 0x33, 0x06, 0x66, 0x07, 0x0e, 0x2c, 0xe3, + 0x7b, 0x18, 0x85, 0xc7, 0xd6, 0x51, 0x71, 0x0f, 0x0e, 0xb8, 0x88, 0x9f, 0x5c, 0x6e, 0x41, 0xde, + 0xeb, 0x71, 0x20, 0x5c, 0xba, 0xf7, 0xc8, 0x6f, 0xba, 0xc1, 0xf7, 0x30, 0xd0, 0xce, 0xc1, 0x31, + 0x74, 0xec, 0x13, 0x41, 0x77, 0x41, 0x13, 0xa0, 0x10, 0xbf, 0x7c, 0x45, 0xd3, 0xa5, 0xbc, 0x55, + 0x84, 0xaa, 0x41, 0xc1, 0xc1, 0xe0, 0xe0, 0x29, 0x01, 0x20, 0x81, 0x00, 0x03, 0x80, 0x07, 0xc0, + 0x0f, 0xe0, 0x06, 0xbe, 0x16, 0x75, 0xe7, 0x9f, 0xfb, 0x1e, 0x17, 0x90, 0xef, 0x0b, 0xbb, 0x15, + 0x03, 0x7c, 0x2b, 0x7e, 0x22, 0x78, 0x56, 0x83, 0xae, 0x77, 0x40, 0xcf, 0xb0, 0xf0, 0x98, 0x28, + 0x04, 0x2f, 0xaf, 0x0e, 0x40, 0xfc, 0x01, 0x1c, 0x5c, 0xb1, 0xf2, 0xbf, 0xa5, 0xd7, 0x8f, 0x97, + 0xc0, 0xfe, 0x9f, 0x02, 0xe7, 0x24, 0x79, 0xe0, 0x9b, 0xa9, 0xfd, 0x74, 0x3b, 0xaf, 0x2d, 0xf8, + 0x4b, 0xd2, 0xf7, 0x84, 0x54, 0x04, 0x2a, 0x02, 0x02, 0x01, 0xe1, 0x1e, 0xf0, 0x87, 0xff, 0x77, + 0x07, 0x00, 0x02, 0x00, 0x0d, 0xbd, 0xe1, 0xf0, 0x01, 0x1e, 0xf0, 0xfd, 0x80, 0x4c, 0x24, 0x11, + 0x2c, 0x10, 0x24, 0x02, 0x01, 0x40, 0xb0, 0x5c, 0x2c, 0x14, 0x08, 0x07, 0x1b, 0x80, 0x01, 0xa7, + 0xbd, 0x3e, 0x00, 0x27, 0xde, 0x9f, 0xb0, 0x85, 0x01, 0xfb, 0xd2, 0x04, 0x0c, 0x1c, 0x2e, 0x0e, + 0x06, 0x18, 0x03, 0xd4, 0x00, 0x00, 0x67, 0xef, 0x4f, 0x80, 0x0a, 0xf7, 0xa7, 0xe3, 0x94, 0xe0, + 0xe0, 0x10, 0x1b, 0xfd, 0xfc, 0x74, 0x62, 0xe8, 0xc0, 0x1d, 0x62, 0x00, 0x0b, 0x00, 0xb7, 0x70, + 0xe6, 0x8a, 0x68, 0x75, 0x38, 0x3c, 0x3c, 0x4c, 0x2f, 0x87, 0xef, 0x01, 0xc7, 0xb2, 0x40, 0x21, + 0xa3, 0x23, 0x0a, 0x08, 0x01, 0xa1, 0xa1, 0xe1, 0x80, 0x69, 0x40, 0xe1, 0x00, 0x00, 0x40, 0xd0, + 0xea, 0xe5, 0xe1, 0xc0, 0x81, 0x87, 0xed, 0x68, 0x1a, 0x08, 0x94, 0x0c, 0x0c, 0xf1, 0x7c, 0xbe, + 0x5f, 0x2f, 0x8f, 0x00, 0x00, 0x0d, 0x1f, 0x68, 0x7a, 0x1a, 0x04, 0x05, 0xce, 0xe6, 0x2a, 0x0c, + 0x01, 0xc2, 0x00, 0x40, 0x42, 0x61, 0xc0, 0x49, 0x41, 0x60, 0xa0, 0x80, 0x01, 0xc0, 0x03, 0xe0, + 0x07, 0xf0, 0x07, 0xfa, 0x00, 0x07, 0x3b, 0x99, 0x01, 0x0f, 0x19, 0x18, 0x54, 0x40, 0xe0, 0x60, + 0xee, 0xd0, 0x0e, 0x19, 0x0a, 0x03, 0xa5, 0x7d, 0x05, 0xd0, 0x83, 0x98, 0x5a, 0x96, 0x21, 0x4b, + 0x10, 0x10, 0xe6, 0x17, 0xaf, 0xeb, 0xaf, 0x34, 0x3c, 0xc8, 0x0f, 0xf0, 0x64, 0x3f, 0xd0, 0x0f, + 0xe0, 0x03, 0xfe, 0x10, 0x02, 0x7d, 0x47, 0x2d, 0x58, 0xfc, 0x35, 0xe0, 0xca, 0x0f, 0x19, 0x0a, + 0xf9, 0xf1, 0xe0, 0xb9, 0xc0, 0x81, 0x10, 0x03, 0xe0, 0xbd, 0x4f, 0xea, 0x61, 0xf7, 0xeb, 0xf6, + 0x02, 0xd4, 0x7a, 0xf9, 0xff, 0x15, 0x30, 0xfa, 0x88, 0x68, 0x68, 0xd8, 0x80, 0x12, 0x60, 0x50, + 0x50, 0xf0, 0x03, 0xfc, 0x01, 0xfe, 0x01, 0x7f, 0xa0, 0x7c, 0x28, 0xbf, 0xd0, 0x3e, 0x64, 0x0f, + 0x00, 0x37, 0x00, 0x08, 0x80, 0x20, 0x0b, 0x88, 0x81, 0xa5, 0x04, 0x84, 0x60, 0x40, 0x36, 0x04, + 0x1b, 0x8f, 0x88, 0x01, 0x00, 0xa1, 0x80, 0x1e, 0x00, 0x36, 0xfd, 0xb9, 0x12, 0x02, 0x4c, 0x09, + 0x08, 0x1e, 0x00, 0x61, 0x80, 0x20, 0x60, 0x44, 0x17, 0xdc, 0x7c, 0x62, 0x00, 0x03, 0x67, 0xdb, + 0x81, 0xb1, 0x30, 0x34, 0xb0, 0xa0, 0xaf, 0xa0, 0x80, 0x75, 0x35, 0x20, 0x7c, 0x49, 0xfc, 0x0f, + 0xf5, 0x0d, 0x7f, 0x7e, 0x45, 0x00, 0x53, 0x42, 0x82, 0x83, 0xc0, 0x0c, 0x28, 0x1f, 0x72, 0x3e, + 0xd3, 0xf5, 0x62, 0xd4, 0x00, 0x22, 0xa8, 0x81, 0xec, 0x67, 0x96, 0x02, 0xa0, 0x49, 0x7d, 0xfd, + 0x6b, 0xbf, 0xcc, 0x7c, 0x4a, 0xf8, 0xd0, 0x00, 0x00, 0xcf, 0xd5, 0xd2, 0x23, 0x35, 0x60, 0x01, + 0xf1, 0x60, 0x14, 0xc0, 0xb0, 0xbe, 0xb3, 0x02, 0x0f, 0x89, 0x5f, 0x1b, 0x00, 0x02, 0x0b, 0xfd, + 0x80, 0x00, 0x01, 0x9b, 0xf3, 0x40, 0x42, 0x10, 0x00, 0xd8, 0xb8, 0x0f, 0xa8, 0x17, 0xfe, 0x59, + 0xef, 0x14, 0x61, 0xf2, 0x30, 0x65, 0xfc, 0x51, 0xe2, 0xc1, 0x18, 0xc0, 0x07, 0x5e, 0x68, 0x08, + 0xe8, 0x46, 0xf8, 0x95, 0xf1, 0xb0, 0xf9, 0x13, 0x7f, 0xbc, 0x00, 0x00, 0x32, 0x7e, 0xa8, 0xeb, + 0xcd, 0x03, 0x20, 0x09, 0xa1, 0x81, 0x97, 0xfb, 0x87, 0x80, 0xb0, 0xf9, 0x19, 0x7c, 0xa8, 0x63, + 0xf3, 0xe6, 0x20, 0x22, 0xbd, 0x85, 0x9e, 0x62, 0x00, 0x8b, 0x7c, 0x87, 0x91, 0x00, 0x22, 0xff, + 0x21, 0xe2, 0xa0, 0x08, 0xc7, 0xc8, 0x78, 0x20, 0x02, 0x33, 0xf2, 0x1c, 0x10, 0x41, 0xe3, 0x40, + 0x69, 0x7c, 0x45, 0x72, 0x62, 0xf0, 0x04, 0x7f, 0x60, 0x68, 0x6f, 0x80, 0x00, 0x08, 0x1f, 0xf7, + 0xad, 0x51, 0x03, 0xf3, 0xf8, 0xa0, 0x9d, 0xa8, 0x40, 0x00, 0x23, 0x42, 0x37, 0x46, 0x0f, 0xde, + 0xa6, 0x06, 0xd3, 0x3c, 0x33, 0xe1, 0x78, 0xd8, 0x34, 0x32, 0x14, 0x67, 0xdb, 0xd2, 0x38, 0xaf, + 0xc7, 0x9c, 0xdf, 0xd0, 0x21, 0xe6, 0xd7, 0x80, 0x40, 0x22, 0x3f, 0x21, 0xe8, 0xd8, 0x12, 0xf9, + 0x0f, 0xb4, 0x01, 0x13, 0xf9, 0x0f, 0x46, 0xc0, 0xa7, 0x13, 0x37, 0x1e, 0x67, 0x07, 0x8b, 0x01, + 0xfd, 0xfe, 0x0f, 0xf7, 0x7a, 0xf0, 0x16, 0x36, 0x0a, 0x92, 0x08, 0x08, 0xc1, 0x70, 0xb8, 0x30, + 0x34, 0xf1, 0xf3, 0x72, 0x27, 0x8f, 0x4b, 0x60, 0x21, 0xc4, 0xdd, 0xe2, 0xdf, 0x0b, 0xca, 0x4f, + 0x2e, 0x4f, 0x9c, 0xde, 0x59, 0xe9, 0xf1, 0x55, 0x00, 0x8d, 0xf2, 0x20, 0x53, 0x3c, 0xc4, 0xf6, + 0x46, 0x7e, 0x24, 0xee, 0xf2, 0x0c, 0x0d, 0x81, 0x83, 0xf9, 0x98, 0x0e, 0x00, 0x02, 0x10, 0x11, + 0x01, 0x08, 0x95, 0x2a, 0xfc, 0x28, 0x95, 0x2a, 0x84, 0x80, 0xbf, 0x81, 0x06, 0x80, 0x0d, 0x00, + 0x86, 0xe0, 0x6b, 0xa5, 0xc3, 0xd8, 0x8f, 0x22, 0xa0, 0x3e, 0xe9, 0x8f, 0x90, 0xf2, 0x6b, 0x85, + 0x77, 0x57, 0x99, 0x43, 0x5c, 0x66, 0x5f, 0x9e, 0x85, 0x7c, 0x3f, 0x1f, 0xb3, 0xce, 0xc0, 0x0e, + 0x64, 0x20, 0x0e, 0x20, 0xdc, 0x7e, 0x18, 0x81, 0x90, 0xa3, 0x13, 0x4e, 0x52, 0x71, 0x81, 0x03, + 0xa4, 0x30, 0x30, 0x6c, 0x73, 0x8f, 0xc4, 0x50, 0x60, 0x16, 0x38, 0x03, 0xbf, 0x6f, 0x89, 0x3e, + 0x00, 0x77, 0x00, 0xb1, 0xc0, 0x28, 0x3d, 0x73, 0x98, 0x06, 0xfe, 0x00, 0xe9, 0x81, 0xa3, 0xb8, + 0x1c, 0x85, 0x20, 0x45, 0x45, 0xe1, 0xa1, 0x23, 0x63, 0xa0, 0x29, 0x61, 0x41, 0x27, 0xf4, 0x03, + 0xfa, 0x01, 0x02, 0x05, 0xff, 0xe1, 0x20, 0x34, 0x08, 0x08, 0x04, 0x04, 0x02, 0xff, 0xeb, 0x96, + 0x05, 0x24, 0x8e, 0x0a, 0xb1, 0xce, 0xf2, 0x06, 0xc7, 0xb9, 0x01, 0xd7, 0x20, 0x52, 0x04, 0x03, + 0xe1, 0x47, 0xc4, 0xa4, 0x0b, 0xfd, 0x03, 0x01, 0xc0, 0x47, 0xe6, 0xc0, 0x2c, 0x7c, 0x09, 0x10, + 0x1c, 0x0a, 0xfd, 0x7e, 0xc0, 0xd2, 0x94, 0x7a, 0x1a, 0x06, 0x07, 0xcf, 0x12, 0x2a, 0x8c, 0x1e, + 0xe7, 0x07, 0x08, 0x81, 0x81, 0x91, 0x90, 0x72, 0x26, 0x9e, 0x55, 0x44, 0x0e, 0x4d, 0x21, 0x00, + 0x08, 0x40, 0x02, 0x20, 0x01, 0x17, 0x2c, 0xd4, 0x22, 0x00, 0x88, 0x80, 0x44, 0x40, 0x23, 0xcd, + 0xf8, 0xf1, 0xc8, 0x9b, 0x02, 0x10, 0x0c, 0x02, 0x99, 0x30, 0x00, 0x0a, 0x06, 0x01, 0x4b, 0x18, + 0x00, 0x46, 0x00, 0x29, 0x9c, 0xa3, 0x86, 0x60, 0x11, 0x98, 0x05, 0x32, 0x80, 0xcc, 0xc0, 0xf3, + 0xc3, 0xb8, 0x7a, 0x21, 0x7d, 0xbe, 0xfa, 0xce, 0x2a, 0x9d, 0xfa, 0xa0, 0x3c, 0x32, 0xfb, 0x7d, + 0x13, 0x22, 0x05, 0xeb, 0x0b, 0xbb, 0xb8, 0x00, 0x15, 0xfe, 0xfe, 0x1a, 0x14, 0x7e, 0x1c, 0x00, + 0x01, 0x82, 0x3a, 0xa7, 0xd2, 0x6c, 0x11, 0xdd, 0x00, 0x00, 0x00, 0xc0, 0x40, 0x18, 0x23, 0x5a, + 0x00, 0x80, 0xb0, 0x47, 0x84, 0x7c, 0xa8, 0x03, 0xa7, 0x82, 0x48, 0x83, 0x01, 0x50, 0x11, 0x2a, + 0x37, 0xfb, 0xfc, 0x03, 0x03, 0xd1, 0xa3, 0x35, 0x68, 0xcd, 0x58, 0x40, 0x03, 0xe3, 0x47, 0xc4, + 0xaf, 0x8d, 0x1f, 0x42, 0x84, 0x20, 0x81, 0x08, 0x57, 0xfb, 0xff, 0xd0, 0x98, 0x27, 0xc8, 0xaf, + 0x99, 0x1f, 0x12, 0x04, 0x3e, 0x84, 0xfe, 0x08, 0x1c, 0xc1, 0x31, 0x58, 0x80, 0x3a, 0xd1, 0x99, + 0x8a, 0x40, 0x02, 0x5a, 0x04, 0x00, 0x02, 0x1a, 0x38, 0xf3, 0x08, 0x00, 0x01, 0xda, 0xe3, 0x35, + 0x60, 0x5f, 0x88, 0x00, 0x03, 0x6e, 0xbf, 0xdf, 0xc0, 0xbe, 0x20, 0x00, 0x42, 0x80, 0x01, 0x77, + 0x9e, 0x80, 0xd0, 0x30, 0x4a, 0x32, 0x81, 0xe3, 0x94, 0x04, 0x21, 0x0a, 0x9c, 0xcc, 0x52, 0x03, + 0x7d, 0xa7, 0x0c, 0x51, 0x80, 0x6f, 0xa5, 0xc0, 0x3f, 0x3e, 0x80, 0xa0, 0x22, 0x10, 0x40, 0x68, + 0x17, 0x9f, 0x60, 0x1e, 0x9b, 0x09, 0x52, 0x03, 0x2d, 0x03, 0x81, 0x88, 0x41, 0x3c, 0x65, 0x14, + 0x98, 0xcd, 0x58, 0x6a, 0x04, 0x21, 0x80, 0x9b, 0x81, 0x45, 0x21, 0x24, 0xe1, 0x8c, 0xf1, 0x9a, + 0xb0, 0xa9, 0x38, 0xef, 0xe7, 0x90, 0xdf, 0x98, 0x00, 0x19, 0xa8, 0x18, 0x42, 0x6a, 0xc0, 0x7f, + 0xda, 0x00, 0x00, 0x2b, 0x1e, 0x36, 0x7c, 0xaa, 0xa0, 0x00, 0xc0, 0xf8, 0xa0, 0xbe, 0x60, 0x2e, + 0xb1, 0x09, 0xab, 0x60, 0x3e, 0x38, 0xf9, 0x6f, 0xa9, 0x3e, 0x08, 0x81, 0xa6, 0x8c, 0x13, 0xae, + 0x83, 0x7e, 0x0a, 0xfb, 0x0f, 0x60, 0x86, 0x3e, 0x90, 0x6d, 0xa2, 0x33, 0x56, 0x06, 0xfa, 0xcf, + 0xc5, 0x1f, 0x12, 0x38, 0x49, 0x3d, 0x04, 0x03, 0xa6, 0x42, 0x54, 0x82, 0x3e, 0xd3, 0xd1, 0xd0, + 0x08, 0x58, 0x06, 0xdc, 0x10, 0x85, 0xe8, 0xf8, 0xf8, 0x94, 0x10, 0x84, 0x21, 0xe7, 0xa3, 0x85, + 0xfe, 0xfe, 0xc1, 0xe9, 0x77, 0xa3, 0x27, 0xe7, 0xbd, 0x31, 0x98, 0x17, 0xa1, 0xe2, 0x13, 0xe8, + 0x5a, 0xf1, 0x44, 0x7c, 0x4a, 0x00, 0x00, 0x07, 0x2d, 0x03, 0x2d, 0x05, 0xa3, 0x46, 0x6a, 0xc1, + 0x9e, 0x9f, 0x9f, 0x51, 0xc0, 0x55, 0x1a, 0x13, 0x56, 0x0e, 0xf4, 0xa4, 0x85, 0xfd, 0x4c, 0x47, + 0x10, 0x0d, 0x70, 0x24, 0x9b, 0xfa, 0x45, 0x41, 0x3a, 0x33, 0xea, 0x28, 0x60, 0x00, 0x80, 0x00, + 0xbc, 0x00, 0x80, 0x7b, 0x2e, 0x43, 0x10, 0x0b, 0x00, 0xec, 0x1e, 0x98, 0x8a, 0xb4, 0x26, 0xac, + 0x5f, 0xf9, 0x20, 0x03, 0xf2, 0xc1, 0xdf, 0xca, 0x14, 0x40, 0x07, 0x40, 0x1e, 0x00, 0x3d, 0x10, + 0xe1, 0x37, 0x90, 0x64, 0x17, 0xec, 0x3d, 0x4c, 0xf5, 0x94, 0x20, 0x15, 0x80, 0xdc, 0x3e, 0x74, + 0x7f, 0x87, 0x87, 0xa9, 0xa6, 0x33, 0x56, 0x16, 0xfd, 0xcf, 0xa9, 0x1f, 0x12, 0x23, 0x35, 0x60, + 0xaf, 0xa4, 0x04, 0xf5, 0xb0, 0x1f, 0xe4, 0x3d, 0x75, 0x1c, 0x20, 0xeb, 0xd7, 0x19, 0x00, 0xb8, + 0x04, 0x21, 0x7a, 0xd3, 0xbe, 0x15, 0xeb, 0x4a, 0xf1, 0x84, 0x78, 0x52, 0x3e, 0x25, 0x03, 0x16, + 0x81, 0xc3, 0x7d, 0x59, 0x1f, 0x12, 0x30, 0x50, 0xe3, 0xe1, 0xcf, 0xc5, 0x8f, 0xa1, 0x1c, 0x0e, + 0x9e, 0xd0, 0x0d, 0x7b, 0x18, 0x14, 0xcc, 0x21, 0x04, 0x1b, 0x6a, 0x8c, 0xd5, 0x86, 0xe0, 0x31, + 0x9a, 0xb0, 0x4f, 0xc8, 0x0b, 0x7c, 0x40, 0x37, 0xc4, 0x5c, 0x22, 0x80, 0x3e, 0x54, 0x71, 0x10, + 0xbf, 0x26, 0xf9, 0xa2, 0x1c, 0x0b, 0x82, 0xf0, 0x8f, 0x22, 0x47, 0x8a, 0xab, 0xca, 0xd4, 0x31, + 0x08, 0xf1, 0xe6, 0x51, 0x9a, 0xb7, 0xcc, 0x80, 0x7f, 0xc9, 0xc2, 0x13, 0x08, 0xfd, 0x95, 0xfe, + 0x23, 0xc0, 0x14, 0x0f, 0x08, 0xe1, 0xb5, 0x5f, 0x4a, 0x38, 0x10, 0x47, 0x1b, 0x17, 0x0a, 0x07, + 0x1d, 0x38, 0xe3, 0xcb, 0x42, 0x10, 0x4f, 0x5d, 0x40, 0x3f, 0xf8, 0xe1, 0x0a, 0xe0, 0x45, 0xa8, + 0x47, 0xe0, 0x78, 0x23, 0x0f, 0x91, 0x5f, 0x4a, 0x7f, 0xe3, 0xc9, 0x11, 0xe0, 0x4a, 0x09, 0xfe, + 0x5a, 0xf0, 0xea, 0x8f, 0x21, 0x57, 0x82, 0xa3, 0xfa, 0x47, 0xc4, 0x8e, 0x0d, 0x8f, 0xcc, 0xfe, + 0x11, 0xf1, 0x22, 0x33, 0x56, 0xe1, 0xf9, 0x1f, 0x9a, 0x83, 0x79, 0x2d, 0xe3, 0xf5, 0x23, 0xf6, + 0x50, 0x64, 0x17, 0xce, 0x4f, 0x12, 0x58, 0x5f, 0xe0, 0xc4, 0x32, 0x0d, 0xfc, 0xab, 0xd5, 0x54, + 0x15, 0x04, 0xfd, 0x91, 0xf1, 0x20, 0x32, 0x0d, 0xe1, 0x48, 0xf8, 0x91, 0xe5, 0x48, 0x09, 0xfc, + 0xdb, 0x7b, 0xab, 0x84, 0x22, 0x0d, 0xfd, 0x23, 0xda, 0xd1, 0xf2, 0x20, 0x2a, 0x11, 0xfe, 0x23, + 0xe7, 0x4f, 0x8c, 0x2f, 0x80, 0xe7, 0x1f, 0x09, 0x40, 0x2f, 0x00, 0xee, 0x7f, 0xf5, 0x1f, 0x12, + 0x3c, 0x0d, 0x40, 0xff, 0xa9, 0xc3, 0x1b, 0x01, 0x42, 0xce, 0x18, 0x5b, 0x52, 0xd9, 0x8a, 0x79, + 0xa7, 0xbc, 0xc5, 0x01, 0x08, 0x41, 0x21, 0xb5, 0xfc, 0x1b, 0x93, 0x1e, 0x8f, 0x60, 0x02, 0x98, + 0xf8, 0xe0, 0x0c, 0x1c, 0x2e, 0x15, 0x00, 0xe7, 0x61, 0x08, 0x02, 0xfd, 0x16, 0x5c, 0xdb, 0xf2, + 0xb8, 0x4f, 0x03, 0xfd, 0x81, 0x8a, 0x88, 0x52, 0x05, 0x20, 0x0e, 0xe9, 0xf9, 0xaa, 0xed, 0x7f, + 0xbf, 0xd0, 0x0b, 0x0b, 0x42, 0x60, 0x85, 0xa1, 0x3f, 0x0a, 0x0b, 0x42, 0x40, 0x08, 0xa8, 0x02, + 0x04, 0xa9, 0x60, 0x46, 0x00, 0x45, 0x40, 0x5c, 0xa7, 0xa6, 0xfa, 0x5c, 0x07, 0xf0, 0xe0, 0xa4, + 0x0f, 0x94, 0xc4, 0x16, 0x82, 0x96, 0x82, 0x94, 0x83, 0x71, 0x76, 0x04, 0x94, 0x8f, 0xa1, 0xf3, + 0x40, 0x00, 0x93, 0x85, 0xa2, 0x50, 0xc0, 0x00, 0x28, 0x1c, 0xbb, 0x03, 0x09, 0x12, 0x5e, 0x91, + 0xaf, 0x21, 0x42, 0x05, 0x09, 0x6b, 0xe5, 0x59, 0x27, 0xcf, 0x8f, 0x88, 0x24, 0x00, 0x90, 0x7c, + 0x60, 0x00, 0x00, 0x17, 0x1a, 0x02, 0x40, 0x2c, 0x03, 0x94, 0x1a, 0xf8, 0x02, 0xa0, 0x80, 0xd2, + 0x15, 0xf5, 0x64, 0x00, 0xc0, 0x32, 0x01, 0x83, 0xa4, 0xc0, 0x5e, 0xb2, 0x0e, 0x70, 0x9a, 0x7b, + 0x12, 0x23, 0x35, 0x6f, 0x26, 0x43, 0x7f, 0x40, 0x6a, 0x04, 0xe8, 0x14, 0x04, 0xa4, 0xb3, 0x14, + 0x81, 0x30, 0x2f, 0x16, 0x84, 0xd0, 0x0c, 0x0b, 0x42, 0x6e, 0x14, 0x00, 0x9a, 0x00, 0x87, 0x76, + 0x80, 0x07, 0x98, 0x2c, 0x03, 0x99, 0x9c, 0xf3, 0xbb, 0x7f, 0xb8, 0xa4, 0xdb, 0xde, 0xfc, 0x4a, + 0x00, 0x05, 0xa4, 0xc2, 0x6a, 0xc0, 0xed, 0x3d, 0x15, 0xc1, 0x04, 0xe1, 0x30, 0x2e, 0x2c, 0xf1, + 0x50, 0x69, 0x84, 0xa9, 0x0f, 0xf8, 0xc2, 0xbe, 0x35, 0xa8, 0x87, 0x50, 0x10, 0x0e, 0x00, 0xe5, + 0x1e, 0xc6, 0xa9, 0x55, 0xfe, 0xff, 0x48, 0xf5, 0xe0, 0x53, 0xdc, 0x78, 0x80, 0x10, 0x51, 0x89, + 0x52, 0xc0, 0x06, 0xab, 0x03, 0x14, 0x6f, 0xed, 0x85, 0xde, 0x80, 0x03, 0x09, 0x52, 0xe5, 0xff, + 0x5e, 0x02, 0xbf, 0x8f, 0x8f, 0xc9, 0xcf, 0xe5, 0xeb, 0xf3, 0x72, 0xbb, 0x80, 0x00, 0xc6, 0x6a, + 0xd8, 0x08, 0x95, 0xf4, 0xb2, 0xf9, 0x4f, 0xa1, 0xc1, 0xc2, 0x5a, 0xef, 0xf7, 0xfa, 0x81, 0xdd, + 0xbd, 0xef, 0xee, 0xe0, 0xd1, 0xe5, 0x72, 0xc5, 0xcd, 0xf0, 0x2c, 0x00, 0x03, 0xcb, 0x98, 0xf0, + 0x7f, 0x52, 0x00, +]; + +/// Large RDP5 uncompressed data (6496 bytes) — expected output for TEST_RDP5_COMPRESSED_DATA. +#[rustfmt::skip] +pub(super) const TEST_RDP5_UNCOMPRESSED_DATA: &[u8] = &[ + 0x24, 0x02, 0x03, 0x09, 0x00, 0x20, 0x0c, 0x05, 0x10, 0x01, 0x40, 0x0a, 0xff, 0xff, 0x0c, 0x84, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1d, 0x0d, 0x38, 0x01, 0xc0, 0x10, 0x01, 0x10, + 0x01, 0xcc, 0xff, 0x7f, 0x03, 0x08, 0x00, 0x20, 0x04, 0x05, 0x10, 0x01, 0x40, 0x0a, 0x00, 0x0c, + 0x84, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x35, 0x00, 0x01, 0x00, 0x00, 0x0d, 0x0a, + 0x0c, 0x0c, 0xff, 0x03, 0xff, 0x02, 0x00, 0x04, 0x00, 0x03, 0x06, 0x00, 0x00, 0x80, 0x00, 0x80, + 0x00, 0x02, 0x00, 0x00, 0x09, 0x00, 0x0c, 0x80, 0x00, 0x80, 0x00, 0x06, 0x00, 0x00, 0x48, 0x00, + 0x37, 0x01, 0x02, 0x00, 0x00, 0x01, 0x0c, 0x48, 0x00, 0x37, 0x01, 0x06, 0x01, 0x00, 0x00, 0x04, + 0x24, 0x00, 0x02, 0x01, 0x00, 0x01, 0x0c, 0x00, 0x04, 0x24, 0x00, 0x02, 0x00, 0x00, 0x09, 0x0a, + 0x3d, 0x0f, 0x00, 0x01, 0x00, 0x0e, 0x00, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0d, 0x0e, 0xf3, 0x11, + 0x3e, 0xf3, 0xf2, 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x0d, 0xf4, 0x11, 0x3f, 0x0d, 0x01, 0xf3, + 0x0b, 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0b, 0x0c, 0xf5, 0x11, 0x3e, 0xf5, 0xf4, 0x0a, 0x99, 0xd6, + 0x11, 0x0d, 0x01, 0x0b, 0xf6, 0x11, 0x0a, 0x01, 0x09, 0x09, 0x18, 0xfb, 0x70, 0x06, 0x00, 0x03, + 0xff, 0xff, 0x00, 0x03, 0x00, 0x02, 0x00, 0x0d, 0x00, 0x0c, 0x00, 0x00, 0x80, 0x0c, 0x00, 0x0f, + 0x00, 0x01, 0x49, 0x08, 0x07, 0xc3, 0x66, 0x3c, 0x18, 0x3c, 0x66, 0xc3, 0x00, 0x72, 0x00, 0x19, + 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0xff, 0xff, 0x11, 0x0f, 0xf1, 0x0d, 0x0e, 0xf3, 0x11, 0x3e, + 0xf3, 0xf2, 0x0c, 0x08, 0x42, 0x11, 0x0d, 0x01, 0x0d, 0xf4, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0b, + 0x99, 0xd6, 0x11, 0x0f, 0xf3, 0x0b, 0x0c, 0xf5, 0x11, 0x3e, 0xf5, 0xf4, 0x0a, 0x10, 0x84, 0x11, + 0x0d, 0x01, 0x0b, 0xf6, 0x11, 0x3a, 0x01, 0x09, 0x99, 0xd6, 0x19, 0x18, 0xf0, 0x60, 0x11, 0x01, + 0x11, 0x01, 0x01, 0x01, 0x00, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0x08, 0x42, 0x11, 0x0f, + 0xf1, 0x0d, 0x0e, 0xf3, 0x11, 0x3e, 0xf3, 0xf2, 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x0d, 0xf4, + 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0b, 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0b, 0x0c, 0xf5, 0x11, 0x3e, + 0xf5, 0xf4, 0x0a, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0b, 0xf6, 0x11, 0x0a, 0x01, 0x09, 0x19, 0x18, + 0xf4, 0x20, 0x10, 0x00, 0x00, 0x0f, 0xff, 0x0f, 0xff, 0xff, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, + 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0d, 0x0e, 0xf3, 0x11, 0x3e, 0xf3, 0xf2, 0x0c, 0xff, 0xff, + 0x11, 0x0d, 0x01, 0x0d, 0xf4, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0b, 0x10, 0x84, 0x11, 0x0f, 0xf3, + 0x0b, 0x0c, 0xf5, 0x11, 0x3e, 0xf5, 0xf4, 0x0a, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0b, 0xf6, 0x11, + 0x0a, 0x01, 0x09, 0x19, 0x18, 0xf4, 0x20, 0xff, 0xff, 0x00, 0x11, 0x01, 0x11, 0x01, 0x01, 0x11, + 0xf4, 0x20, 0x10, 0x84, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x19, 0x0a, 0x3f, 0xdd, 0x0c, 0xf5, + 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0d, 0x0e, 0xf3, 0x11, 0x3e, 0xf3, 0xf2, 0x0c, 0xff, 0xff, + 0x11, 0x0d, 0x01, 0x0d, 0xf4, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0b, 0x10, 0x84, 0x11, 0x0f, 0xf3, + 0x0b, 0x0c, 0xf5, 0x11, 0x3e, 0xf5, 0xf4, 0x0a, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0b, 0xf6, 0x11, + 0x0a, 0x01, 0x09, 0x19, 0x18, 0xf4, 0x60, 0x00, 0x00, 0x00, 0xd0, 0x0e, 0xd0, 0x0e, 0x0e, 0x0b, + 0x01, 0x01, 0x43, 0x06, 0x02, 0xfc, 0xfc, 0x00, 0x00, 0x30, 0x00, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, + 0xf5, 0x04, 0xff, 0xff, 0x11, 0x0f, 0xf1, 0x0d, 0x0e, 0xf3, 0x11, 0x3e, 0xf3, 0xf2, 0x0c, 0x08, + 0x42, 0x11, 0x0d, 0x01, 0x0d, 0xf4, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0b, 0x99, 0xd6, 0x11, 0x0f, + 0xf3, 0x0b, 0x0c, 0xf5, 0x11, 0x3e, 0xf5, 0xf4, 0x0a, 0x10, 0x84, 0x11, 0x0d, 0x01, 0x0b, 0xf6, + 0x11, 0x3a, 0x01, 0x09, 0x99, 0xd6, 0x19, 0x18, 0xf0, 0x60, 0x11, 0x01, 0x11, 0x01, 0x01, 0x01, + 0x01, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0d, 0x0e, 0xf3, + 0x11, 0x3e, 0xf3, 0xf2, 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x0d, 0xf4, 0x11, 0x3f, 0x0d, 0x01, + 0xf3, 0x0b, 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0b, 0x0c, 0xf5, 0x11, 0x3e, 0xf5, 0xf4, 0x0a, 0x99, + 0xd6, 0x11, 0x0d, 0x01, 0x0b, 0xf6, 0x11, 0x0a, 0x01, 0x09, 0x19, 0x18, 0xf4, 0x20, 0x10, 0x00, + 0x00, 0x0f, 0xff, 0x0f, 0xff, 0xff, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0x08, 0x42, 0x11, + 0x0f, 0xf1, 0x0d, 0x0e, 0xf3, 0x11, 0x3e, 0xf3, 0xf2, 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x0d, + 0xf4, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0b, 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0b, 0x0c, 0xf5, 0x11, + 0x3e, 0xf5, 0xf4, 0x0a, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0b, 0xf6, 0x11, 0x0a, 0x01, 0x09, 0x19, + 0x18, 0xf4, 0x20, 0xff, 0xff, 0x00, 0x11, 0x01, 0x11, 0x01, 0x01, 0x11, 0xf4, 0x20, 0x10, 0x84, + 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x19, 0x0a, 0x3f, 0xdd, 0x0c, 0xf5, 0x04, 0x08, 0x42, 0x11, + 0x0f, 0xf1, 0x0d, 0x0e, 0xf3, 0x11, 0x3e, 0xf3, 0xf2, 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x0d, + 0xf4, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0b, 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0b, 0x0c, 0xf5, 0x11, + 0x3e, 0xf5, 0xf4, 0x0a, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0b, 0xf6, 0x11, 0x0a, 0x01, 0x09, 0x19, + 0x18, 0xf4, 0x60, 0x00, 0x00, 0x00, 0xd0, 0x0e, 0xd0, 0x0e, 0x0e, 0x13, 0x02, 0x00, 0x4a, 0x08, + 0x09, 0x3f, 0x3f, 0x21, 0xfd, 0xfd, 0x87, 0x84, 0x84, 0xfc, 0x00, 0x00, 0x00, 0x32, 0x00, 0x19, + 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0xff, 0xff, 0x11, 0x0f, 0xf1, 0x0d, 0x0e, 0xf3, 0x11, 0x3e, + 0xf3, 0xf2, 0x0c, 0x08, 0x42, 0x11, 0x0d, 0x01, 0x0d, 0xf4, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0b, + 0x99, 0xd6, 0x11, 0x0f, 0xf3, 0x0b, 0x0c, 0xf5, 0x11, 0x3e, 0xf5, 0xf4, 0x0a, 0x10, 0x84, 0x11, + 0x0d, 0x01, 0x0b, 0xf6, 0x11, 0x3a, 0x01, 0x09, 0x99, 0xd6, 0x19, 0x18, 0xf0, 0x60, 0x11, 0x01, + 0x11, 0x01, 0x01, 0x01, 0x02, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0x08, 0x42, 0x11, 0x0f, + 0xf1, 0x0d, 0x0e, 0xf3, 0x11, 0x3e, 0xf3, 0xf2, 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x0d, 0xf4, + 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0b, 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0b, 0x0c, 0xf5, 0x11, 0x3e, + 0xf5, 0xf4, 0x0a, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0b, 0xf6, 0x11, 0x0a, 0x01, 0x09, 0x19, 0x18, + 0xf4, 0x20, 0x10, 0x00, 0x00, 0x0f, 0xff, 0x0f, 0xff, 0xff, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, + 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0d, 0x0e, 0xf3, 0x11, 0x3e, 0xf3, 0xf2, 0x0c, 0xff, 0xff, + 0x11, 0x0d, 0x01, 0x0d, 0xf4, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0b, 0x10, 0x84, 0x11, 0x0f, 0xf3, + 0x0b, 0x0c, 0xf5, 0x11, 0x3e, 0xf5, 0xf4, 0x0a, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0b, 0xf6, 0x11, + 0x0a, 0x01, 0x09, 0x19, 0x18, 0x54, 0x40, 0x00, 0x00, 0x00, 0x10, 0x10, 0x13, 0x03, 0x02, 0x4a, + 0x06, 0x09, 0x78, 0xcc, 0xcc, 0x18, 0x30, 0x30, 0x00, 0x30, 0x30, 0x00, 0x00, 0x00, 0x73, 0x00, + 0x19, 0x0a, 0x3f, 0xdd, 0x0c, 0xf5, 0x04, 0xff, 0xff, 0x11, 0x0f, 0xf1, 0x0d, 0x0e, 0xf3, 0x11, + 0x3e, 0xf3, 0xf2, 0x0c, 0x08, 0x42, 0x11, 0x0d, 0x01, 0x0d, 0xf4, 0x11, 0x3f, 0x0d, 0x01, 0xf3, + 0x0b, 0x99, 0xd6, 0x11, 0x0f, 0xf3, 0x0b, 0x0c, 0xf5, 0x11, 0x3e, 0xf5, 0xf4, 0x0a, 0x10, 0x84, + 0x11, 0x0d, 0x01, 0x0b, 0xf6, 0x11, 0x3a, 0x01, 0x09, 0x99, 0xd6, 0x19, 0x18, 0xf0, 0x60, 0xd1, + 0x0f, 0xd1, 0x0f, 0x0f, 0x01, 0x03, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0x08, 0x42, 0x11, + 0x0f, 0xf1, 0x0d, 0x0e, 0xf3, 0x11, 0x3e, 0xf3, 0xf2, 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x0d, + 0xf4, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0b, 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0b, 0x0c, 0xf5, 0x11, + 0x3e, 0xf5, 0xf4, 0x0a, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0b, 0xf6, 0x11, 0x0a, 0x01, 0x09, 0x19, + 0x18, 0xf4, 0x20, 0x10, 0x00, 0x00, 0x0f, 0xff, 0x0f, 0xff, 0xff, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, + 0xf5, 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0d, 0x0e, 0xf3, 0x11, 0x3e, 0xf3, 0xf2, 0x0c, 0xff, + 0xff, 0x11, 0x0d, 0x01, 0x0d, 0xf4, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0b, 0x10, 0x84, 0x11, 0x0f, + 0xf3, 0x0b, 0x0c, 0xf5, 0x11, 0x3e, 0xf5, 0xf4, 0x0a, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0b, 0xf6, + 0x11, 0x0a, 0x01, 0x09, 0x19, 0x18, 0x54, 0x40, 0x00, 0x00, 0x00, 0x10, 0x10, 0x1b, 0x04, 0x00, + 0x4a, 0x09, 0x09, 0xff, 0x80, 0xff, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, + 0x80, 0x80, 0x80, 0xff, 0x80, 0x00, 0x00, 0x31, 0x00, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, 0x04, + 0xff, 0xff, 0x11, 0x0f, 0xf1, 0x0d, 0x0e, 0xf3, 0x11, 0x3e, 0xf3, 0xf2, 0x0c, 0x08, 0x42, 0x11, + 0x0d, 0x01, 0x0d, 0xf4, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0b, 0x99, 0xd6, 0x11, 0x0f, 0xf3, 0x0b, + 0x0c, 0xf5, 0x11, 0x3e, 0xf5, 0xf4, 0x0a, 0x10, 0x84, 0x11, 0x0d, 0x01, 0x0b, 0xf6, 0x11, 0x3a, + 0x01, 0x09, 0x99, 0xd6, 0x19, 0x18, 0xf0, 0x60, 0x11, 0x01, 0x11, 0x01, 0x01, 0x01, 0x04, 0x19, + 0x0a, 0x3f, 0xdd, 0x0c, 0xf5, 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0d, 0x0e, 0xf3, 0x11, 0x3e, + 0xf3, 0xf2, 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x0d, 0xf4, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0b, + 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0b, 0x0c, 0xf5, 0x11, 0x3e, 0xf5, 0xf4, 0x0a, 0x99, 0xd6, 0x11, + 0x0d, 0x01, 0x0b, 0xf6, 0x11, 0x0a, 0x01, 0x09, 0x19, 0x18, 0xf4, 0x20, 0x10, 0x00, 0x00, 0xcf, + 0x0d, 0xcf, 0x0d, 0x0d, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf1, + 0x0d, 0x0e, 0xf3, 0x11, 0x3e, 0xf3, 0xf2, 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x0d, 0xf4, 0x11, + 0x3f, 0x0d, 0x01, 0xf3, 0x0b, 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0b, 0x0c, 0xf5, 0x11, 0x3e, 0xf5, + 0xf4, 0x0a, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0b, 0xf6, 0x11, 0x0a, 0x01, 0x09, 0x19, 0x18, 0xf4, + 0x20, 0xff, 0xff, 0x00, 0x11, 0x01, 0x11, 0x01, 0x01, 0x11, 0xf4, 0x20, 0x10, 0x84, 0x00, 0xff, + 0xff, 0xff, 0xff, 0xff, 0x19, 0x0a, 0x3f, 0xee, 0x34, 0x3c, 0x08, 0x2d, 0x09, 0x59, 0x0d, 0x97, + 0xff, 0x00, 0x02, 0x70, 0x0d, 0x0e, 0x51, 0xc2, 0x10, 0x20, 0x1c, 0x51, 0xc2, 0x12, 0xe0, 0xd6, + 0x51, 0xc2, 0x12, 0x30, 0x1c, 0x19, 0x0a, 0x32, 0x12, 0x10, 0x84, 0x59, 0x0d, 0xc6, 0xcc, 0x12, + 0xd0, 0xf2, 0x51, 0xc2, 0x10, 0x20, 0x1c, 0x51, 0xc2, 0x12, 0xe0, 0xd6, 0x51, 0xc2, 0x12, 0x30, + 0x1c, 0x19, 0x0a, 0x3f, 0x0a, 0x12, 0xb9, 0xf9, 0x08, 0x42, 0x11, 0x0f, 0xf6, 0x0a, 0x09, 0xf6, + 0x11, 0x3e, 0xf6, 0xf7, 0x09, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x08, 0xf7, 0x11, 0x3f, 0x08, 0x01, + 0xf8, 0x08, 0x10, 0x84, 0x11, 0x0f, 0xf8, 0x08, 0x07, 0xf8, 0x11, 0x3e, 0xf8, 0xf9, 0x07, 0x99, + 0xd6, 0x11, 0x0d, 0x01, 0x06, 0xf9, 0x11, 0x0a, 0x01, 0x06, 0x19, 0x18, 0xf5, 0x60, 0x05, 0x00, + 0x00, 0x00, 0xef, 0x5a, 0xec, 0x57, 0x57, 0x0f, 0x00, 0x00, 0x46, 0x06, 0x05, 0xcc, 0x78, 0x30, + 0x78, 0xcc, 0x00, 0x00, 0x00, 0x72, 0x00, 0x19, 0x0a, 0x3f, 0x13, 0xfe, 0xfa, 0x04, 0xff, 0xff, + 0x11, 0x0f, 0xf6, 0x0a, 0x09, 0xf6, 0x11, 0x3e, 0xf6, 0xf7, 0x09, 0x08, 0x42, 0x11, 0x0d, 0x01, + 0x08, 0xf7, 0x11, 0x3f, 0x08, 0x01, 0xf8, 0x08, 0x99, 0xd6, 0x11, 0x0f, 0xf8, 0x08, 0x07, 0xf8, + 0x11, 0x3e, 0xf8, 0xf9, 0x07, 0x10, 0x84, 0x11, 0x0d, 0x01, 0x06, 0xf9, 0x11, 0x3a, 0x01, 0x06, + 0x99, 0xd6, 0x19, 0x18, 0xf0, 0x60, 0x0c, 0x01, 0x0c, 0x01, 0x01, 0x01, 0x00, 0x19, 0x0a, 0x3f, + 0x13, 0xfe, 0xfa, 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf6, 0x0a, 0x09, 0xf6, 0x11, 0x3e, 0xf6, 0xf7, + 0x09, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x08, 0xf7, 0x11, 0x3f, 0x08, 0x01, 0xf8, 0x08, 0x10, 0x84, + 0x11, 0x0f, 0xf8, 0x08, 0x07, 0xf8, 0x11, 0x3e, 0xf8, 0xf9, 0x07, 0x99, 0xd6, 0x11, 0x0d, 0x01, + 0x06, 0xf9, 0x11, 0x0a, 0x01, 0x06, 0x19, 0x18, 0xf4, 0x20, 0x10, 0x00, 0x00, 0x0a, 0xff, 0x0a, + 0xff, 0xff, 0x19, 0x0a, 0x3f, 0x13, 0xfe, 0xfa, 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf6, 0x0a, 0x09, + 0xf6, 0x11, 0x3e, 0xf6, 0xf7, 0x09, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x08, 0xf7, 0x11, 0x3f, 0x08, + 0x01, 0xf8, 0x08, 0x10, 0x84, 0x11, 0x0f, 0xf8, 0x08, 0x07, 0xf8, 0x11, 0x3e, 0xf8, 0xf9, 0x07, + 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x06, 0xf9, 0x11, 0x0a, 0x01, 0x06, 0x19, 0x18, 0xf4, 0x20, 0xff, + 0xff, 0x00, 0x0c, 0x01, 0x0c, 0x01, 0x01, 0x11, 0xf4, 0x20, 0x10, 0x84, 0x00, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x19, 0x0a, 0x0f, 0x09, 0xfe, 0x09, 0x09, 0x19, 0x18, 0xf5, 0x60, 0x06, 0xff, 0xff, + 0x00, 0x09, 0xfe, 0x12, 0x07, 0x07, 0x23, 0x05, 0x03, 0x4d, 0x0d, 0x0d, 0x00, 0x00, 0x00, 0x08, + 0x00, 0x10, 0x00, 0x20, 0x00, 0x40, 0x00, 0x88, 0x01, 0x10, 0x02, 0x20, 0x04, 0x40, 0x08, 0x88, + 0x11, 0x10, 0x22, 0x20, 0x44, 0x40, 0x00, 0x00, 0x6f, 0x00, 0x11, 0x04, 0x40, 0x10, 0x84, 0x00, + 0x1f, 0x06, 0x04, 0x4c, 0x0c, 0x0c, 0x00, 0x00, 0x00, 0x10, 0x00, 0x30, 0x00, 0x60, 0x00, 0xc0, + 0x01, 0x90, 0x03, 0x30, 0x06, 0x60, 0x0c, 0xc0, 0x19, 0x90, 0x33, 0x30, 0x66, 0x60, 0x70, 0x00, + 0x19, 0x0a, 0x37, 0xe3, 0x10, 0xf1, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0f, 0x0e, 0xf1, 0x11, 0x3e, + 0xf1, 0xf2, 0x0e, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0d, 0xf2, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0d, + 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0d, 0x0c, 0xf3, 0x11, 0x3e, 0xf3, 0xf4, 0x0c, 0xff, 0xff, 0x11, + 0x0d, 0x01, 0x0b, 0xf4, 0x11, 0x3a, 0x01, 0x0b, 0x99, 0xd6, 0x19, 0x18, 0xf4, 0x60, 0x00, 0x00, + 0x00, 0xd6, 0x12, 0xd2, 0x0e, 0x0e, 0x0f, 0x07, 0x01, 0x48, 0x09, 0x04, 0x08, 0x00, 0x1c, 0x00, + 0x3e, 0x00, 0x7f, 0x00, 0x35, 0x00, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0x10, 0x84, 0x11, + 0x0f, 0xf1, 0x0f, 0x0e, 0xf1, 0x11, 0x0e, 0xf1, 0xf2, 0x0e, 0x11, 0x0d, 0x01, 0x0d, 0xf2, 0x11, + 0x3f, 0x0d, 0x01, 0xf3, 0x0d, 0x99, 0xd6, 0x11, 0x0f, 0xf3, 0x0d, 0x0c, 0xf3, 0x11, 0x0e, 0xf3, + 0xf4, 0x0c, 0x11, 0x0d, 0x01, 0x0b, 0xf4, 0x11, 0x0a, 0x01, 0x0b, 0x19, 0x18, 0xf0, 0x60, 0x11, + 0x01, 0x11, 0x01, 0x01, 0x01, 0x07, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0x08, 0x42, 0x11, + 0x0f, 0xf1, 0x0f, 0x0e, 0xf1, 0x11, 0x3e, 0xf1, 0xf2, 0x0e, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0d, + 0xf2, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0d, 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0d, 0x0c, 0xf3, 0x11, + 0x3e, 0xf3, 0xf4, 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x0b, 0xf4, 0x11, 0x3a, 0x01, 0x0b, 0x99, + 0xd6, 0x19, 0x18, 0xf4, 0x20, 0x10, 0x00, 0x00, 0x0f, 0xff, 0x0f, 0xff, 0xff, 0x19, 0x0a, 0x3f, + 0x1d, 0xfe, 0xf5, 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0f, 0x0e, 0xf1, 0x11, 0x3e, 0xf1, 0xf2, + 0x0e, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0d, 0xf2, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0d, 0x10, 0x84, + 0x11, 0x0f, 0xf3, 0x0d, 0x0c, 0xf3, 0x11, 0x3e, 0xf3, 0xf4, 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, + 0x0b, 0xf4, 0x11, 0x3a, 0x01, 0x0b, 0x99, 0xd6, 0x19, 0x18, 0xf4, 0x20, 0xff, 0xff, 0x00, 0x11, + 0x01, 0x11, 0x01, 0x01, 0x11, 0xf4, 0x20, 0x10, 0x84, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x19, + 0x0a, 0x3f, 0xdd, 0x0e, 0xf5, 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0f, 0x0e, 0xf1, 0x11, 0x3e, + 0xf1, 0xf2, 0x0e, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0d, 0xf2, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0d, + 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0d, 0x0c, 0xf3, 0x11, 0x3e, 0xf3, 0xf4, 0x0c, 0xff, 0xff, 0x11, + 0x0d, 0x01, 0x0b, 0xf4, 0x11, 0x3a, 0x01, 0x0b, 0x99, 0xd6, 0x19, 0x18, 0xf4, 0x60, 0x00, 0x00, + 0x00, 0xd0, 0x10, 0xd0, 0x10, 0x10, 0x0f, 0x08, 0x01, 0x48, 0x09, 0x04, 0x7f, 0x00, 0x3e, 0x00, + 0x1c, 0x00, 0x08, 0x00, 0x36, 0x00, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0x10, 0x84, 0x11, + 0x0f, 0xf1, 0x0f, 0x0e, 0xf1, 0x11, 0x0e, 0xf1, 0xf2, 0x0e, 0x11, 0x0d, 0x01, 0x0d, 0xf2, 0x11, + 0x3f, 0x0d, 0x01, 0xf3, 0x0d, 0x99, 0xd6, 0x11, 0x0f, 0xf3, 0x0d, 0x0c, 0xf3, 0x11, 0x0e, 0xf3, + 0xf4, 0x0c, 0x11, 0x0d, 0x01, 0x0b, 0xf4, 0x11, 0x0a, 0x01, 0x0b, 0x19, 0x18, 0xf0, 0x60, 0x11, + 0x01, 0x11, 0x01, 0x01, 0x01, 0x08, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0x08, 0x42, 0x11, + 0x0f, 0xf1, 0x0f, 0x0e, 0xf1, 0x11, 0x3e, 0xf1, 0xf2, 0x0e, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0d, + 0xf2, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0d, 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0d, 0x0c, 0xf3, 0x11, + 0x3e, 0xf3, 0xf4, 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x0b, 0xf4, 0x11, 0x3a, 0x01, 0x0b, 0x99, + 0xd6, 0x19, 0x18, 0xf4, 0x20, 0x10, 0x00, 0x00, 0x0f, 0xff, 0x0f, 0xff, 0xff, 0x19, 0x0a, 0x3f, + 0x1d, 0xfe, 0xf5, 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0f, 0x0e, 0xf1, 0x11, 0x3e, 0xf1, 0xf2, + 0x0e, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0d, 0xf2, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0d, 0x10, 0x84, + 0x11, 0x0f, 0xf3, 0x0d, 0x0c, 0xf3, 0x11, 0x3e, 0xf3, 0xf4, 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, + 0x0b, 0xf4, 0x11, 0x3a, 0x01, 0x0b, 0x99, 0xd6, 0x19, 0x18, 0xf4, 0x20, 0xff, 0xff, 0x00, 0x11, + 0x01, 0x11, 0x01, 0x01, 0x11, 0xf4, 0x20, 0x10, 0x84, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x19, + 0x0a, 0x3f, 0xdd, 0x0e, 0xf5, 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0f, 0x0e, 0xf1, 0x11, 0x3e, + 0xf1, 0xf2, 0x0e, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0d, 0xf2, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0d, + 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0d, 0x0c, 0xf3, 0x11, 0x3e, 0xf3, 0xf4, 0x0c, 0xff, 0xff, 0x11, + 0x0d, 0x01, 0x0b, 0xf4, 0x11, 0x3a, 0x01, 0x0b, 0x99, 0xd6, 0x19, 0x18, 0xf4, 0x60, 0x00, 0x00, + 0x00, 0xd0, 0x10, 0xd0, 0x10, 0x10, 0x13, 0x09, 0x04, 0x4b, 0x04, 0x09, 0x00, 0x80, 0xc0, 0xe0, + 0xf0, 0xe0, 0xc0, 0x80, 0x00, 0x00, 0x00, 0x00, 0x34, 0x00, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, + 0x04, 0x10, 0x84, 0x11, 0x0f, 0xf1, 0x0f, 0x0e, 0xf1, 0x11, 0x0e, 0xf1, 0xf2, 0x0e, 0x11, 0x0d, + 0x01, 0x0d, 0xf2, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0d, 0x99, 0xd6, 0x11, 0x0f, 0xf3, 0x0d, 0x0c, + 0xf3, 0x11, 0x0e, 0xf3, 0xf4, 0x0c, 0x11, 0x0d, 0x01, 0x0b, 0xf4, 0x11, 0x0a, 0x01, 0x0b, 0x19, + 0x18, 0xf0, 0x60, 0x11, 0x01, 0x11, 0x01, 0x01, 0x01, 0x09, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, + 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0f, 0x0e, 0xf1, 0x11, 0x3e, 0xf1, 0xf2, 0x0e, 0x99, 0xd6, + 0x11, 0x0d, 0x01, 0x0d, 0xf2, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0d, 0x10, 0x84, 0x11, 0x0f, 0xf3, + 0x0d, 0x0c, 0xf3, 0x11, 0x3e, 0xf3, 0xf4, 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x0b, 0xf4, 0x11, + 0x3a, 0x01, 0x0b, 0x99, 0xd6, 0x19, 0x18, 0xf4, 0x20, 0x10, 0x00, 0x00, 0x0f, 0xff, 0x0f, 0xff, + 0xff, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0f, 0x0e, 0xf1, + 0x11, 0x3e, 0xf1, 0xf2, 0x0e, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0d, 0xf2, 0x11, 0x3f, 0x0d, 0x01, + 0xf3, 0x0d, 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0d, 0x0c, 0xf3, 0x11, 0x3e, 0xf3, 0xf4, 0x0c, 0xff, + 0xff, 0x11, 0x0d, 0x01, 0x0b, 0xf4, 0x11, 0x3a, 0x01, 0x0b, 0x99, 0xd6, 0x19, 0x18, 0xf4, 0x20, + 0xff, 0xff, 0x00, 0x11, 0x01, 0x11, 0x01, 0x01, 0x11, 0xf4, 0x20, 0x10, 0x84, 0x00, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x19, 0x0a, 0x3f, 0xdd, 0x0e, 0xf5, 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0f, + 0x0e, 0xf1, 0x11, 0x3e, 0xf1, 0xf2, 0x0e, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0d, 0xf2, 0x11, 0x3f, + 0x0d, 0x01, 0xf3, 0x0d, 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0d, 0x0c, 0xf3, 0x11, 0x3e, 0xf3, 0xf4, + 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x0b, 0xf4, 0x11, 0x3a, 0x01, 0x0b, 0x99, 0xd6, 0x19, 0x18, + 0xf4, 0x60, 0x00, 0x00, 0x00, 0xd0, 0x10, 0xd0, 0x10, 0x10, 0x13, 0x0a, 0x03, 0x4b, 0x04, 0x09, + 0x00, 0x10, 0x30, 0x70, 0xf0, 0x70, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x33, 0x00, 0x19, 0x0a, + 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0x10, 0x84, 0x11, 0x0f, 0xf1, 0x0f, 0x0e, 0xf1, 0x11, 0x0e, 0xf1, + 0xf2, 0x0e, 0x11, 0x0d, 0x01, 0x0d, 0xf2, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0d, 0x99, 0xd6, 0x11, + 0x0f, 0xf3, 0x0d, 0x0c, 0xf3, 0x11, 0x0e, 0xf3, 0xf4, 0x0c, 0x11, 0x0d, 0x01, 0x0b, 0xf4, 0x11, + 0x0a, 0x01, 0x0b, 0x19, 0x18, 0xf0, 0x60, 0x11, 0x01, 0x11, 0x01, 0x01, 0x01, 0x0a, 0x19, 0x0a, + 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0x08, 0x42, 0x11, 0x0f, 0xf1, 0x0f, 0x0e, 0xf1, 0x11, 0x3e, 0xf1, + 0xf2, 0x0e, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0d, 0xf2, 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0d, 0x10, + 0x84, 0x11, 0x0f, 0xf3, 0x0d, 0x0c, 0xf3, 0x11, 0x3e, 0xf3, 0xf4, 0x0c, 0xff, 0xff, 0x11, 0x0d, + 0x01, 0x0b, 0xf4, 0x11, 0x3a, 0x01, 0x0b, 0x99, 0xd6, 0x19, 0x18, 0xf4, 0x20, 0x10, 0x00, 0x00, + 0x0f, 0xff, 0x0f, 0xff, 0xff, 0x19, 0x0a, 0x3f, 0x1d, 0xfe, 0xf5, 0x04, 0x08, 0x42, 0x11, 0x0f, + 0xf1, 0x0f, 0x0e, 0xf1, 0x11, 0x3e, 0xf1, 0xf2, 0x0e, 0x99, 0xd6, 0x11, 0x0d, 0x01, 0x0d, 0xf2, + 0x11, 0x3f, 0x0d, 0x01, 0xf3, 0x0d, 0x10, 0x84, 0x11, 0x0f, 0xf3, 0x0d, 0x0c, 0xf3, 0x11, 0x3e, + 0xf3, 0xf4, 0x0c, 0xff, 0xff, 0x11, 0x0d, 0x01, 0x0b, 0xf4, 0x11, 0x3a, 0x01, 0x0b, 0x99, 0xd6, + 0x19, 0x18, 0xf4, 0x20, 0xff, 0xff, 0x00, 0x11, 0x01, 0x11, 0x01, 0x01, 0x11, 0xf4, 0x20, 0x10, + 0x84, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x19, 0x0a, 0x3f, 0xce, 0x0e, 0x01, 0x01, 0xff, 0xff, + 0x1d, 0x18, 0xf4, 0x60, 0x0e, 0xe2, 0x00, 0x0b, 0x00, 0xee, 0x00, 0x00, 0x00, 0x00, 0xcd, 0x0e, + 0xce, 0x0f, 0x0f, 0x13, 0x0b, 0x04, 0x4b, 0x04, 0x09, 0x00, 0x80, 0xc0, 0xe0, 0xf0, 0xe0, 0xc0, + 0x80, 0x00, 0x00, 0x00, 0x00, 0x38, 0x00, 0x19, 0x0a, 0x01, 0x0d, 0x19, 0x18, 0x50, 0x40, 0x0d, + 0x0d, 0x0f, 0x0c, 0x03, 0x4a, 0x07, 0x08, 0x00, 0x02, 0x06, 0x8e, 0xdc, 0xf8, 0x70, 0x20, 0x61, + 0x00, 0x19, 0x0a, 0x01, 0x0d, 0x19, 0x18, 0x50, 0x40, 0x0d, 0x0d, 0x0f, 0x0d, 0x04, 0x4a, 0x06, + 0x06, 0x78, 0xfc, 0xfc, 0xfc, 0xfc, 0x78, 0x00, 0x00, 0x68, 0x00, 0x19, 0x0a, 0x3d, 0x0d, 0x02, + 0x02, 0x99, 0xd6, 0x19, 0x18, 0xd0, 0x60, 0x0e, 0x10, 0x02, 0x02, 0x13, 0x0e, 0x02, 0x4a, 0x0b, + 0x05, 0x04, 0x00, 0x0e, 0x00, 0x1f, 0x00, 0x3f, 0x80, 0x7f, 0xc0, 0x00, 0x00, 0x35, 0x00, 0x19, + 0x0a, 0x01, 0x0f, 0x19, 0x18, 0x54, 0x40, 0x10, 0x00, 0x00, 0x0f, 0x0f, 0x01, 0x0e, 0x19, 0x0a, + 0x03, 0xca, 0x0f, 0x19, 0x18, 0xf4, 0x20, 0xff, 0xff, 0x00, 0xcb, 0x10, 0xcb, 0x10, 0x10, 0x11, + 0xf4, 0x20, 0x10, 0x84, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0x19, 0x0a, 0x01, 0x0f, 0x19, 0x18, + 0x54, 0x40, 0x00, 0x00, 0x00, 0x0f, 0x0f, 0x13, 0x0f, 0x02, 0x4a, 0x0b, 0x05, 0x7f, 0xc0, 0x3f, + 0x80, 0x1f, 0x00, 0x0e, 0x00, 0x04, 0x00, 0x00, 0x00, 0x36, 0x00, 0x19, 0x0a, 0x01, 0x0f, 0x19, + 0x18, 0x54, 0x40, 0x10, 0x00, 0x00, 0x0f, 0x0f, 0x01, 0x0f, 0x19, 0x0a, 0x01, 0x0f, 0x19, 0x18, + 0xf4, 0x20, 0xff, 0xff, 0x00, 0x10, 0x01, 0x10, 0x01, 0x01, 0x11, 0xf4, 0x20, 0x10, 0x84, 0x00, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x19, 0x0a, 0x3f, 0xd3, 0x0f, 0xfe, 0xfe, 0xff, 0xff, 0x19, 0x18, + 0xf4, 0x60, 0x00, 0x00, 0x00, 0xd3, 0x0f, 0xd1, 0x0d, 0x0d, 0x1b, 0x10, 0x02, 0x4c, 0x0a, 0x0a, + 0x1e, 0x00, 0x7f, 0x80, 0x7f, 0x80, 0xff, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0xff, 0xc0, 0x7f, 0x80, + 0x7f, 0x80, 0x1e, 0x00, 0x6e, 0x00, 0x11, 0x00, 0x40, 0x17, 0x11, 0x03, 0x4a, 0x09, 0x08, 0x01, + 0x00, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x01, 0x00, 0xc3, 0x00, 0x3c, 0x00, 0x6d, + 0x00, 0x11, 0x00, 0x40, 0x17, 0x12, 0x02, 0x4c, 0x09, 0x08, 0x1e, 0x00, 0x61, 0x80, 0x40, 0x00, + 0x80, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00, 0x40, 0x00, 0x6c, 0x00, 0x11, 0x00, 0x40, 0x1b, + 0x13, 0x03, 0x4b, 0x0a, 0x0a, 0x00, 0x80, 0x00, 0x80, 0x00, 0x40, 0x00, 0x40, 0x00, 0x40, 0x00, + 0x40, 0x00, 0x80, 0x00, 0x80, 0xc3, 0x00, 0x3c, 0x00, 0x6b, 0x00, 0x11, 0x00, 0x40, 0x1b, 0x14, + 0x01, 0x4d, 0x0a, 0x0a, 0x0f, 0x00, 0x30, 0xc0, 0x40, 0x00, 0x40, 0x00, 0x80, 0x00, 0x80, 0x00, + 0x80, 0x00, 0x80, 0x00, 0x40, 0x00, 0x40, 0x00, 0x6a, 0x00, 0x11, 0x54, 0x40, 0xff, 0xff, 0x00, + 0x0d, 0x0d, 0x1b, 0x15, 0x02, 0x4b, 0x09, 0x09, 0xff, 0x80, 0xff, 0x80, 0xff, 0x80, 0xff, 0x80, + 0xff, 0x80, 0xff, 0x80, 0xff, 0x80, 0xff, 0x80, 0xff, 0x80, 0x00, 0x00, 0x67, 0x00, 0x11, 0x04, + 0x40, 0x99, 0xd6, 0x00, 0x1f, 0x16, 0x01, 0x4c, 0x0b, 0x0b, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, + 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0x00, 0x20, 0xff, 0xe0, + 0x00, 0x00, 0x66, 0x00, 0x11, 0x04, 0x40, 0x08, 0x42, 0x00, 0x1b, 0x17, 0x01, 0x4c, 0x0a, 0x0a, + 0xff, 0xc0, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00, + 0x80, 0x00, 0x80, 0x00, 0x65, 0x00, 0x11, 0x04, 0x40, 0xff, 0xff, 0x00, 0x23, 0x18, 0x00, 0x4d, + 0x0d, 0x0d, 0x00, 0x08, 0x00, 0x08, 0x00, 0x08, 0x00, 0x08, 0x00, 0x08, 0x00, 0x08, 0x00, 0x08, + 0x00, 0x08, 0x00, 0x08, 0x00, 0x08, 0x00, 0x08, 0x00, 0x08, 0xff, 0xf8, 0x00, 0x00, 0x64, 0x00, + 0x11, 0x04, 0x40, 0x10, 0x84, 0x00, 0x1f, 0x19, 0x00, 0x4d, 0x0c, 0x0c, 0xff, 0xf0, 0x80, 0x00, + 0x80, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x00, + 0x80, 0x00, 0x80, 0x00, 0x63, 0x00, 0x11, 0x54, 0x40, 0xff, 0xff, 0x00, 0x0d, 0x0d, 0x01, 0x15, + 0x11, 0x04, 0x40, 0x99, 0xd6, 0x00, 0x01, 0x16, 0x11, 0x04, 0x40, 0x08, 0x42, 0x00, 0x01, 0x17, + 0x11, 0x04, 0x40, 0xff, 0xff, 0x00, 0x01, 0x18, 0x11, 0x04, 0x40, 0x10, 0x84, 0x00, 0x01, 0x19, + 0x11, 0x04, 0x40, 0x00, 0x00, 0x00, 0x0f, 0x1a, 0x03, 0x4b, 0x07, 0x08, 0x00, 0x02, 0x06, 0x8e, + 0xdc, 0xf8, 0x70, 0x20, 0x62, 0x00, 0x11, 0x54, 0x40, 0x99, 0xd6, 0x00, 0x0d, 0x0d, 0x01, 0x15, + 0x11, 0x00, 0x40, 0x01, 0x16, 0x11, 0x04, 0x40, 0x08, 0x42, 0x00, 0x01, 0x17, 0x11, 0x04, 0x40, + 0xff, 0xff, 0x00, 0x01, 0x18, 0x11, 0x04, 0x40, 0x10, 0x84, 0x00, 0x01, 0x19, 0x11, 0x54, 0x40, + 0x99, 0xd6, 0x00, 0x0d, 0x0d, 0x01, 0x15, 0x11, 0x00, 0x40, 0x01, 0x16, 0x11, 0x04, 0x40, 0x08, + 0x42, 0x00, 0x01, 0x17, 0x11, 0x04, 0x40, 0xff, 0xff, 0x00, 0x01, 0x18, 0x11, 0x04, 0x40, 0x10, + 0x84, 0x00, 0x01, 0x19, 0x11, 0x04, 0x40, 0x00, 0x00, 0x00, 0x01, 0x1a, 0x11, 0xf4, 0x60, 0x99, + 0xd6, 0x00, 0xcc, 0x0d, 0xcc, 0x0d, 0x0d, 0x01, 0x15, 0x11, 0x00, 0x40, 0x01, 0x16, 0x11, 0x04, + 0x40, 0x08, 0x42, 0x00, 0x01, 0x17, 0x11, 0x04, 0x40, 0xff, 0xff, 0x00, 0x01, 0x18, 0x11, 0x04, + 0x40, 0x10, 0x84, 0x00, 0x01, 0x19, 0x11, 0x00, 0x40, 0x01, 0x1a, 0x19, 0x0a, 0x33, 0x0d, 0x0d, + 0x00, 0x00, 0x19, 0x18, 0x54, 0x40, 0xff, 0xff, 0x00, 0x0d, 0x0d, 0x01, 0x10, 0x11, 0x04, 0x40, + 0x99, 0xd6, 0x00, 0x01, 0x11, 0x11, 0x04, 0x40, 0x08, 0x42, 0x00, 0x01, 0x12, 0x11, 0x04, 0x40, + 0xff, 0xff, 0x00, 0x01, 0x13, 0x11, 0x04, 0x40, 0x10, 0x84, 0x00, 0x01, 0x14, 0x19, 0x0a, 0x01, + 0x0d, 0x19, 0x18, 0x54, 0x40, 0xff, 0xff, 0x00, 0x0d, 0x0d, 0x01, 0x10, 0x11, 0x04, 0x40, 0x99, + 0xd6, 0x00, 0x01, 0x11, 0x11, 0x04, 0x40, 0x08, 0x42, 0x00, 0x01, 0x12, 0x11, 0x04, 0x40, 0xff, + 0xff, 0x00, 0x01, 0x13, 0x11, 0x04, 0x40, 0x10, 0x84, 0x00, 0x01, 0x14, 0x11, 0x04, 0x40, 0x00, + 0x00, 0x00, 0x0b, 0x1b, 0x05, 0x49, 0x04, 0x04, 0x60, 0xf0, 0xf0, 0x60, 0x69, 0x00, 0x19, 0x0a, + 0x01, 0x0d, 0x19, 0x18, 0x54, 0x40, 0x99, 0xd6, 0x00, 0x0d, 0x0d, 0x01, 0x10, 0x11, 0x00, 0x40, + 0x01, 0x11, 0x11, 0x04, 0x40, 0x08, 0x42, 0x00, 0x01, 0x12, 0x11, 0x04, 0x40, 0xff, 0xff, 0x00, + 0x01, 0x13, 0x11, 0x04, 0x40, 0x10, 0x84, 0x00, 0x01, 0x14, 0x19, 0x0a, 0x01, 0x0d, 0x19, 0x18, + 0x54, 0x40, 0x99, 0xd6, 0x00, 0x0d, 0x0d, 0x01, 0x10, 0x11, 0x00, 0x40, 0x01, 0x11, 0x11, 0x04, + 0x40, 0x08, 0x42, 0x00, 0x01, 0x12, 0x11, 0x04, 0x40, 0xff, 0xff, 0x00, 0x01, 0x13, 0x11, 0x04, + 0x40, 0x10, 0x84, 0x00, 0x01, 0x14, 0x11, 0x04, 0x40, 0x00, 0x00, 0x00, 0x01, 0x1b, 0x19, 0x0a, + 0x03, 0xcc, 0x0d, 0x19, 0x18, 0xf4, 0x60, 0x99, 0xd6, 0x00, 0xcc, 0x0d, 0xcc, 0x0d, 0x0d, 0x01, + 0x10, 0x11, 0x00, 0x40, 0x01, 0x11, 0x11, 0x04, 0x40, 0x08, 0x42, 0x00, 0x01, 0x12, 0x11, 0x04, + 0x40, 0xff, 0xff, 0x00, 0x01, 0x13, 0x11, 0x04, 0x40, 0x10, 0x84, 0x00, 0x01, 0x14, 0x11, 0x00, + 0x40, 0x01, 0x1b, 0x03, 0x07, 0x00, 0x00, 0x00, 0x07, 0x00, 0x01, 0x08, 0x08, 0x81, 0x08, 0xaa, + 0x55, 0xaa, 0x55, 0xaa, 0x55, 0xaa, 0x55, 0x09, 0x01, 0x7f, 0x02, 0x0d, 0x00, 0x1a, 0x01, 0x0d, + 0x00, 0x0d, 0x00, 0xf0, 0xff, 0xff, 0x00, 0x99, 0xd6, 0x00, 0x81, 0x19, 0x18, 0x54, 0x40, 0x99, + 0xd6, 0x00, 0x0d, 0x0d, 0x01, 0x16, 0x11, 0x04, 0x40, 0x08, 0x42, 0x00, 0x01, 0x17, 0x11, 0x04, + 0x40, 0xff, 0xff, 0x00, 0x01, 0x18, 0x11, 0x04, 0x40, 0x10, 0x84, 0x00, 0x01, 0x19, 0x11, 0x00, + 0x40, 0x01, 0x1a, 0x11, 0x54, 0x40, 0x99, 0xd6, 0x00, 0x0d, 0x0d, 0x01, 0x15, 0x11, 0x00, 0x40, + 0x01, 0x16, 0x11, 0x04, 0x40, 0x08, 0x42, 0x00, 0x01, 0x17, 0x11, 0x04, 0x40, 0xff, 0xff, 0x00, + 0x01, 0x18, 0x11, 0x04, 0x40, 0x10, 0x84, 0x00, 0x01, 0x19, 0x11, 0x00, 0x40, 0x01, 0x1a, 0x11, + 0x54, 0x40, 0x99, 0xd6, 0x00, 0x0d, 0x0d, 0x01, 0x15, 0x11, 0x00, 0x40, 0x01, 0x16, 0x11, 0x04, + 0x40, 0x08, 0x42, 0x00, 0x01, 0x17, 0x11, 0x04, 0x40, 0xff, 0xff, 0x00, 0x01, 0x18, 0x11, 0x04, + 0x40, 0x10, 0x84, 0x00, 0x01, 0x19, 0x11, 0x00, 0x40, 0x01, 0x1a, 0x19, 0x0a, 0x31, 0x34, 0xff, + 0xff, 0x19, 0x18, 0x54, 0x40, 0x00, 0x00, 0x00, 0x0c, 0x0c, 0x1b, 0x1c, 0x02, 0x4b, 0x09, 0x09, + 0xc1, 0x80, 0xe3, 0x80, 0x77, 0x00, 0x3e, 0x00, 0x1c, 0x00, 0x3e, 0x00, 0x77, 0x00, 0xe3, 0x80, + 0xc1, 0x80, 0x00, 0x00, 0x72, 0x00, 0x19, 0x0a, 0x03, 0xcc, 0x0d, 0x1d, 0x18, 0xf0, 0x60, 0xa0, + 0x45, 0x45, 0xcc, 0x0d, 0xcc, 0x0d, 0x0d, 0x1b, 0x1d, 0x01, 0x4b, 0x0a, 0x09, 0x3f, 0xc0, 0x3f, + 0xc0, 0x20, 0x40, 0xff, 0x40, 0xff, 0x40, 0x81, 0xc0, 0x81, 0x00, 0x81, 0x00, 0xff, 0x00, 0x00, + 0x00, 0x32, 0x00, 0x19, 0x0a, 0x01, 0x0d, 0x19, 0x18, 0x50, 0x40, 0x0d, 0x0d, 0x1b, 0x1e, 0x01, + 0x4c, 0x0a, 0x0a, 0xff, 0xc0, 0xff, 0xc0, 0x80, 0x40, 0x80, 0x40, 0x80, 0x40, 0x80, 0x40, 0x80, + 0x40, 0x80, 0x40, 0x80, 0x40, 0xff, 0xc0, 0x31, 0x00, 0x19, 0x0a, 0x01, 0x0d, 0x19, 0x18, 0x50, + 0x40, 0x0d, 0x0d, 0x0b, 0x1f, 0x02, 0x44, 0x07, 0x02, 0xfe, 0xfe, 0x00, 0x00, 0x30, 0x00, 0x19, + 0x0a, 0x3d, 0x0d, 0x03, 0x03, 0x99, 0xd6, 0x19, 0x18, 0xd4, 0x60, 0xff, 0xff, 0x00, 0x0e, 0x11, + 0x03, 0x03, 0x23, 0x20, 0x00, 0x4d, 0x0d, 0x0d, 0x00, 0x00, 0x80, 0x00, 0x40, 0x00, 0x20, 0x00, + 0x10, 0x00, 0x88, 0x00, 0x44, 0x00, 0x22, 0x00, 0x11, 0x00, 0x88, 0x80, 0x44, 0x40, 0x22, 0x20, + 0x11, 0x10, 0x00, 0x00, 0x78, 0x00, 0x11, 0x04, 0x40, 0x10, 0x84, 0x00, 0x1f, 0x21, 0x00, 0x4c, + 0x0c, 0x0c, 0x00, 0x00, 0x80, 0x00, 0xc0, 0x00, 0x60, 0x00, 0x30, 0x00, 0x98, 0x00, 0xcc, 0x00, + 0x66, 0x00, 0x33, 0x00, 0x99, 0x80, 0xcc, 0xc0, 0x66, 0x60, 0x79, 0x00, 0x19, 0x0a, 0x3d, 0x10, + 0xfd, 0xfd, 0xff, 0xff, 0x19, 0x18, 0xd4, 0x60, 0x00, 0x00, 0x00, 0x0f, 0x0c, 0xfd, 0xfd, 0x13, + 0x22, 0x05, 0x4b, 0x04, 0x09, 0x00, 0x10, 0x30, 0x70, 0xf0, 0x70, 0x30, 0x10, 0x00, 0x00, 0x00, + 0x00, 0x77, 0x00, 0x02, 0xff, 0xff, 0x0d, 0x0a, 0x3f, 0x0e, 0x00, 0x00, 0xff, 0x03, 0xff, 0x02, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x03, 0x00, 0x00, 0x06, 0x02, 0x00, 0x48, 0x00, 0x37, + 0x01, 0x02, 0x02, 0x00, 0x09, 0x00, 0x0c, 0x48, 0x00, 0x37, 0x01, 0x03, 0xcf, 0x04, 0xa2, 0x0c, + 0x05, 0x40, 0x44, 0xd1, 0xff, 0xff, 0x80, 0x00, 0xff, 0xff, 0x99, 0xd6, 0x99, 0xd6, 0x10, 0x00, + 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x10, 0x00, + 0x99, 0xd6, 0x99, 0xd6, 0x10, 0x84, 0x08, 0x42, 0xff, 0xff, 0x99, 0xd6, 0x99, 0xd6, 0x10, 0x84, + 0xff, 0xff, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x10, 0x84, + 0xff, 0xff, 0x99, 0xd6, 0x10, 0x84, 0x08, 0x42, 0x1c, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x75, 0xc6, 0x66, 0x29, 0x00, 0x09, 0x68, 0x10, 0x00, 0x08, 0x68, 0x10, 0x84, + 0x00, 0x20, 0x00, 0x07, 0x6b, 0x99, 0xd6, 0x05, 0x6b, 0x99, 0xd6, 0x00, 0x03, 0x6e, 0xff, 0xff, + 0x02, 0x6e, 0xff, 0xff, 0x00, 0x10, 0xc0, 0x00, 0xf7, 0xbd, 0x01, 0xc0, 0x00, 0x08, 0x42, 0xc0, + 0x00, 0xff, 0xff, 0x81, 0x08, 0x42, 0xce, 0x66, 0x29, 0x01, 0xfd, 0xce, 0x18, 0xc6, 0x01, 0xfd, + 0x2e, 0x01, 0x81, 0x08, 0x42, 0xce, 0x66, 0x29, 0x02, 0x81, 0x10, 0x84, 0x06, 0x82, 0x00, 0x00, + 0x00, 0x00, 0x07, 0xcd, 0x89, 0x52, 0x03, 0x2d, 0x03, 0x83, 0x10, 0x84, 0x99, 0xd6, 0x99, 0xd6, + 0xc9, 0x99, 0xd6, 0x1a, 0x82, 0x10, 0x00, 0x10, 0x00, 0x0a, 0x29, 0x09, 0x27, 0x0c, 0x67, 0x99, + 0xd6, 0x15, 0x27, 0x1d, 0x82, 0x00, 0x00, 0x00, 0x00, 0x0d, 0x67, 0x99, 0xd6, 0x00, 0x19, 0xd0, + 0x30, 0x89, 0xd6, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xd8, 0x99, 0xd6, 0x05, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xc0, 0x01, 0x83, 0x99, 0xd6, 0x00, 0x00, 0x00, 0x00, 0x0b, 0xd8, 0x89, + 0xd6, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x01, 0x83, 0x99, 0xd6, 0x10, 0x00, 0x10, + 0x00, 0x1a, 0x68, 0x00, 0x00, 0x09, 0x86, 0x99, 0xd6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x99, 0xd6, 0x18, 0x68, 0x00, 0x00, 0x1b, 0x68, 0x99, 0xd6, 0x06, 0x86, 0x99, 0xd6, 0x10, + 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x99, 0xd6, 0x19, 0x6b, 0x99, 0xd6, 0x03, 0xcc, 0x89, + 0x52, 0x08, 0x68, 0x99, 0xd6, 0x05, 0x6b, 0x99, 0xd6, 0x04, 0x2c, 0x03, 0x6e, 0x08, 0x42, 0x02, + 0x6e, 0xff, 0xff, 0x02, 0x6e, 0xff, 0xff, 0x02, 0x6e, 0x08, 0x42, 0x10, 0x81, 0x08, 0x42, 0x70, + 0xff, 0xff, 0x60, 0x00, 0x08, 0x42, 0xfd, 0xce, 0x18, 0xc6, 0x01, 0x81, 0x08, 0x42, 0xce, 0x66, + 0x29, 0x01, 0xfd, 0xce, 0x18, 0xc6, 0x01, 0xfd, 0x2e, 0x02, 0xcd, 0x89, 0x52, 0x03, 0x89, 0x10, + 0x84, 0x99, 0xd6, 0x99, 0xd6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x07, 0x2d, 0x03, 0x2d, 0x05, 0xc6, 0x99, 0xd6, 0x0c, 0x84, 0x99, 0xd6, 0x99, 0xd6, 0x99, + 0xd6, 0x99, 0xd6, 0x0a, 0xc6, 0x89, 0xd6, 0x0e, 0x82, 0x00, 0x00, 0x00, 0x00, 0x0b, 0x84, 0x99, + 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x1c, 0x40, 0x35, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xd0, 0x4e, 0x99, 0xd6, 0x03, 0x00, 0x00, 0x60, 0x00, 0x80, 0x01, 0x78, 0x01, 0x00, 0x82, + 0x10, 0x00, 0x10, 0x00, 0x0c, 0x40, 0x2c, 0x03, 0xe0, 0x05, 0x00, 0x00, 0x00, 0xd6, 0x89, 0xd6, + 0x2f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xd0, 0x4e, 0x99, 0xd6, 0x3b, 0x00, 0x00, 0x00, 0x28, 0x80, + 0x1d, 0x00, 0x78, 0x00, 0x86, 0x99, 0xd6, 0x99, 0xd6, 0x10, 0x00, 0x99, 0xd6, 0x99, 0xd6, 0x99, + 0xd6, 0x0c, 0x85, 0x00, 0x00, 0x00, 0x00, 0x99, 0xd6, 0x99, 0xd6, 0x00, 0x00, 0x40, 0x2b, 0x01, + 0xf0, 0x00, 0x00, 0x00, 0x00, 0xd6, 0x89, 0xd6, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x69, 0x99, + 0xd6, 0x16, 0x86, 0x99, 0xd6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x99, 0xd6, 0x0a, + 0x69, 0x99, 0xd6, 0x04, 0xcc, 0x89, 0x52, 0x07, 0x69, 0x99, 0xd6, 0x08, 0x68, 0x99, 0xd6, 0x03, + 0x6e, 0xff, 0xff, 0x02, 0x6e, 0x08, 0x42, 0x02, 0x6e, 0xff, 0xff, 0x02, 0x6e, 0xff, 0xff, 0x01, + 0x70, 0x08, 0x42, 0x70, 0xff, 0xff, 0x60, 0x00, 0x08, 0x42, 0xfd, 0xce, 0x18, 0xc6, 0x01, 0x81, + 0x08, 0x42, 0xce, 0x66, 0x29, 0x01, 0xfd, 0xce, 0x18, 0xc6, 0x01, 0xfd, 0x2e, 0x02, 0xcd, 0x89, + 0x52, 0x03, 0x8a, 0x10, 0x84, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x2d, 0x03, 0x8d, 0x99, 0xd6, 0x99, 0xd6, 0x99, + 0xd6, 0x99, 0xd6, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x99, + 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x06, 0xc6, 0x99, 0xd6, 0x1a, 0xc6, 0x89, 0xd6, 0x0a, 0x66, 0x10, + 0x84, 0x1b, 0x6a, 0x99, 0xd6, 0x1b, 0x81, 0x99, 0xd6, 0x09, 0x6a, 0x99, 0xd6, 0x16, 0x6a, 0x99, + 0xd6, 0x06, 0x6a, 0x99, 0xd6, 0xf0, 0x94, 0x01, 0xcc, 0x89, 0x52, 0x00, 0x03, 0x6e, 0xff, 0xff, + 0x02, 0x6e, 0x08, 0x42, 0x02, 0x6e, 0xff, 0xff, 0x02, 0x6e, 0xff, 0xff, 0x01, 0x70, 0x08, 0x42, + 0x70, 0xff, 0xff, 0x60, 0x00, 0x08, 0x42, 0xfd, 0xce, 0x18, 0xc6, 0x01, 0x81, 0x08, 0x42, 0xce, + 0x66, 0x29, 0x01, 0xfd, 0xce, 0x18, 0xc6, 0x01, 0xfd, 0x2e, 0x02, 0xcd, 0x89, 0x52, 0x03, 0x10, + 0x2d, 0x03, 0x2d, 0x17, 0x88, 0x00, 0x00, 0x00, 0x00, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x99, + 0xd6, 0x00, 0x00, 0x00, 0x00, 0x18, 0x88, 0xff, 0xff, 0xff, 0xff, 0x99, 0xd6, 0x99, 0xd6, 0x99, + 0xd6, 0x99, 0xd6, 0xff, 0xff, 0xff, 0xff, 0x07, 0x88, 0x00, 0x00, 0x00, 0x00, 0x99, 0xd6, 0x99, + 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x00, 0x00, 0x00, 0x00, 0x09, 0x88, 0x99, 0xd6, 0x00, 0x00, 0x00, + 0x00, 0x99, 0xd6, 0x99, 0xd6, 0x00, 0x00, 0x00, 0x00, 0x99, 0xd6, 0x07, 0x88, 0x10, 0x00, 0x10, + 0x00, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x10, 0x00, 0x10, 0x00, 0x08, 0x89, 0x10, + 0x84, 0x10, 0x84, 0xff, 0xff, 0xff, 0xff, 0x99, 0xd6, 0x99, 0xd6, 0x10, 0x84, 0x10, 0x84, 0x99, + 0xd6, 0x07, 0x88, 0x99, 0xd6, 0x00, 0x00, 0x00, 0x00, 0x99, 0xd6, 0x99, 0xd6, 0x00, 0x00, 0x00, + 0x00, 0x99, 0xd6, 0x0a, 0x86, 0x99, 0xd6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x99, + 0xd6, 0x08, 0x88, 0x99, 0xd6, 0x10, 0x00, 0x10, 0x00, 0x99, 0xd6, 0x99, 0xd6, 0x10, 0x00, 0x10, + 0x00, 0x99, 0xd6, 0x08, 0x88, 0x99, 0xd6, 0x10, 0x84, 0x10, 0x84, 0xff, 0xff, 0xff, 0xff, 0x10, + 0x84, 0x10, 0x84, 0x99, 0xd6, 0x09, 0x86, 0x99, 0xd6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x99, 0xd6, 0x0c, 0x84, 0x99, 0xd6, 0x00, 0x00, 0x00, 0x00, 0x99, 0xd6, 0x0a, 0x86, 0x99, + 0xd6, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x10, 0x00, 0x99, 0xd6, 0x0a, 0x86, 0x99, 0xd6, 0x10, + 0x84, 0x10, 0x84, 0x10, 0x84, 0x10, 0x84, 0x99, 0xd6, 0x0b, 0x84, 0x99, 0xd6, 0x00, 0x00, 0x00, + 0x00, 0x99, 0xd6, 0x0d, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0b, 0x84, 0x99, + 0xd6, 0x10, 0x00, 0x10, 0x00, 0x99, 0xd6, 0x0c, 0x85, 0x99, 0xd6, 0x10, 0x84, 0x10, 0x84, 0xff, + 0xff, 0xff, 0xff, 0x0b, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x86, 0x00, + 0x00, 0x00, 0x00, 0x99, 0xd6, 0x99, 0xd6, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x84, 0x10, 0x00, 0x10, + 0x00, 0x10, 0x00, 0x10, 0x00, 0x0c, 0x86, 0x10, 0x84, 0x10, 0x84, 0x10, 0x84, 0x10, 0x84, 0xff, + 0xff, 0xff, 0xff, 0x09, 0x86, 0x00, 0x00, 0x00, 0x00, 0x99, 0xd6, 0x99, 0xd6, 0x00, 0x00, 0x00, + 0x00, 0x0a, 0x88, 0x00, 0x00, 0x00, 0x00, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x00, + 0x00, 0x00, 0x00, 0x08, 0x86, 0x10, 0x00, 0x10, 0x00, 0x99, 0xd6, 0x99, 0xd6, 0x10, 0x00, 0x10, + 0x00, 0x0a, 0x88, 0x10, 0x84, 0x10, 0x84, 0x99, 0xd6, 0x99, 0xd6, 0x10, 0x84, 0x10, 0x84, 0xff, + 0xff, 0xff, 0xff, 0x07, 0x88, 0x00, 0x00, 0x00, 0x00, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x99, + 0xd6, 0x00, 0x00, 0x00, 0x00, 0x09, 0x6a, 0x99, 0xd6, 0x05, 0x88, 0x10, 0x00, 0x10, 0x00, 0x99, + 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x10, 0x00, 0x10, 0x00, 0x08, 0x89, 0x10, 0x84, 0x10, + 0x84, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x10, 0x84, 0x10, 0x84, 0x99, 0xd6, 0x07, + 0x6a, 0x99, 0xd6, 0x16, 0x6a, 0x99, 0xd6, 0x06, 0x6a, 0x99, 0xd6, 0x14, 0x2c, 0x00, 0x03, 0x6e, + 0xff, 0xff, 0x02, 0x6e, 0x08, 0x42, 0x02, 0x6e, 0xff, 0xff, 0x02, 0xcb, 0x66, 0x29, 0x84, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0x08, 0x42, 0x09, 0x0d, 0xdf, 0x01, 0x02, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x40, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f, 0x03, 0x15, 0x00, 0xa2, 0x0c, + 0x05, 0x40, 0x40, 0x17, 0xff, 0xff, 0x00, 0x1c, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xf0, 0xbc, 0x0f, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x51, 0x0a, 0x40, + 0xc8, 0x03, 0xf4, 0x00, 0xa2, 0x0c, 0x05, 0x40, 0x40, 0xf6, 0xff, 0xff, 0xc0, 0x2c, 0x2d, 0x09, + 0x84, 0x2d, 0x09, 0x2d, 0x09, 0x2d, 0x09, 0x2d, 0x09, 0x00, 0x22, 0xc0, 0x10, 0x25, 0x4b, 0x02, + 0x30, 0x02, 0x2a, 0x02, 0xfd, 0xce, 0x18, 0xc6, 0x01, 0xfd, 0x2e, 0x03, 0xfd, 0x2e, 0x03, 0xfd, + 0x29, 0x03, 0xcd, 0x89, 0x52, 0x03, 0x2d, 0x05, 0x2d, 0x05, 0x29, 0x06, 0xc6, 0x99, 0xd6, 0x09, + 0x29, 0x1f, 0x43, 0x03, 0x00, 0x00, 0x01, 0x27, 0x0b, 0x44, 0xc3, 0x00, 0x00, 0xc0, 0x68, 0x99, + 0xd6, 0x18, 0x48, 0xa5, 0x00, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0xc2, 0x5a, 0x00, 0x60, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x24, 0x00, 0xa0, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x81, 0x99, 0xd6, 0x48, 0x05, 0x80, 0x05, 0x00, 0x00, 0xfc, + 0x01, 0x50, 0x40, 0x69, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x0c, 0x80, 0x06, 0x00, + 0x00, 0x02, 0x6a, 0x99, 0xd6, 0x1c, 0x86, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0x2d, + 0x09, 0x2d, 0x09, 0x6f, 0xff, 0xff, 0x02, 0x6e, 0xff, 0xff, 0x04, 0x6e, 0xff, 0xff, 0x04, 0xc9, + 0x66, 0x29, 0x02, 0x60, 0x5e, 0x2d, 0x09, 0xc0, 0x30, 0x2d, 0x09, 0xf0, 0xc0, 0x09, 0xc0, 0x10, + 0x08, 0x42, 0x00, 0x00, 0xfd, 0xce, 0x18, 0xc6, 0x01, 0xfd, 0x2e, 0x00, 0x02, 0xcd, 0x89, 0x52, + 0x03, 0x83, 0x99, 0xd6, 0x99, 0xd6, 0x99, 0xd6, 0xc9, 0xef, 0x7b, 0x81, 0x99, 0xd6, 0x00, 0x05, + 0xc9, 0x89, 0xd6, 0x07, 0x69, 0x10, 0x84, 0x00, 0x08, 0x27, 0x09, 0x82, 0xff, 0xff, 0x99, 0xd6, + 0xd0, 0x69, 0x89, 0x52, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x51, 0x0e, 0xc0, 0x40, 0x38, 0x03, 0xa8, 0x00, 0xa2, 0x0c, 0x05, 0x40, 0x40, 0xaa, + 0xff, 0xff, 0xc8, 0x2d, 0x09, 0x00, 0x14, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x20, 0xc6, 0x25, 0x4b, 0x00, 0x1a, 0xd8, 0x18, 0xc6, 0x1f, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xf7, 0xc0, 0x01, 0x89, 0x52, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf7, + 0x00, 0x01, 0x99, 0xd6, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x84, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xd0, 0x3b, 0xef, + 0x7b, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x68, 0x2d, 0x09, 0x00, 0x58, 0xf3, 0x7c, + 0x0b, 0x00, 0x00, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x51, 0x0a, 0x40, 0xc8, +]; + +/// John Donne's "No Man Is an Island" excerpt used for compression test. +/// +/// Note: the test data concatenates "were, as" and "well as" +/// without a space (matching the exact C string literal concatenation). +pub(super) const TEST_ISLAND_DATA: &[u8] = b"No man is an island entire of itself; every man \ +is a piece of the continent, a part of the main; \ +if a clod be washed away by the sea, Europe \ +is the less, as well as if a promontory were, as\ +well as any manner of thy friends or of thine \ +own were; any man's death diminishes me, \ +because I am involved in mankind. \ +And therefore never send to know for whom \ +the bell tolls; it tolls for thee."; + +/// RDP5 (MPPC 64K) compressed form of TEST_ISLAND_DATA. 297 bytes. +#[rustfmt::skip] +pub(super) const TEST_ISLAND_DATA_RDP5: &[u8] = &[ + 0x4e, 0x6f, 0x20, 0x6d, 0x61, 0x6e, 0x20, 0x69, 0x73, 0x20, 0xf8, 0xd2, 0xd8, 0xc2, 0xdc, 0xc8, + 0x40, 0xca, 0xdc, 0xe8, 0xd2, 0xe4, 0xca, 0x40, 0xde, 0xcc, 0x40, 0xd2, 0xe8, 0xe6, 0xca, 0xd8, + 0xcc, 0x76, 0x40, 0xca, 0xec, 0xca, 0xe4, 0xf3, 0xfa, 0x71, 0x20, 0x70, 0x69, 0x65, 0x63, 0xfc, + 0x12, 0xe8, 0xd0, 0xca, 0x40, 0xc6, 0xdf, 0xfb, 0xcd, 0xdf, 0xd0, 0x58, 0x40, 0xc2, 0x40, 0xe0, + 0xc2, 0xe4, 0xe9, 0xfe, 0x63, 0xec, 0xc3, 0x6b, 0x0b, 0x4b, 0x71, 0xd9, 0x03, 0x4b, 0x37, 0xd7, + 0x31, 0xb6, 0x37, 0xb2, 0x10, 0x31, 0x32, 0x90, 0x3b, 0xb0, 0xb9, 0xb4, 0x32, 0xb2, 0x10, 0x30, + 0xbb, 0xb0, 0xbc, 0x90, 0x31, 0x3c, 0x90, 0x7e, 0x68, 0x73, 0x65, 0x61, 0x2c, 0x20, 0x45, 0x75, + 0x72, 0x6f, 0x70, 0x65, 0xf2, 0x34, 0x7d, 0x38, 0x6c, 0x65, 0x73, 0x73, 0xf0, 0x69, 0xcc, 0x81, + 0xdd, 0x95, 0xb1, 0xb0, 0x81, 0x85, 0xcf, 0xc0, 0x94, 0xe0, 0xe4, 0xde, 0xdb, 0xe2, 0xb3, 0x7f, + 0x92, 0x4e, 0xec, 0xae, 0x4c, 0xbf, 0x86, 0x3f, 0x06, 0x0c, 0x2d, 0xde, 0x5d, 0x96, 0xe6, 0x57, + 0x2f, 0x1e, 0x53, 0xc9, 0x03, 0x33, 0x93, 0x4b, 0x2b, 0x73, 0x23, 0x99, 0x03, 0x7f, 0xd2, 0xb6, + 0x96, 0xef, 0x38, 0x1d, 0xdb, 0xbc, 0x24, 0x72, 0x65, 0x3b, 0xf5, 0x5b, 0xf8, 0x49, 0x3b, 0x99, + 0x03, 0x23, 0x2b, 0x0b, 0xa3, 0x41, 0x03, 0x23, 0x4b, 0x6b, 0x4b, 0x73, 0x4f, 0x96, 0xce, 0x64, + 0x0d, 0xbe, 0x19, 0x31, 0x32, 0xb1, 0xb0, 0xba, 0xb9, 0xb2, 0x90, 0x24, 0x90, 0x30, 0xb6, 0x90, + 0x34, 0xb7, 0x3b, 0x37, 0xb6, 0x3b, 0x79, 0xd4, 0xd2, 0xdd, 0xec, 0x18, 0x6b, 0x69, 0x6e, 0x64, + 0x2e, 0x20, 0x41, 0xf7, 0x33, 0xcd, 0x47, 0x26, 0x56, 0x66, 0xff, 0x74, 0x9b, 0xbd, 0xbf, 0x04, + 0x0e, 0x7e, 0x31, 0x10, 0x3a, 0x37, 0x90, 0x35, 0xb7, 0x37, 0xbb, 0x90, 0x7d, 0x81, 0x03, 0xbb, + 0x43, 0x7b, 0x6f, 0xa8, 0xe5, 0x8b, 0xd0, 0xf0, 0xe8, 0xde, 0xd8, 0xd8, 0xe7, 0xec, 0xf3, 0xa7, + 0xe4, 0x7c, 0xa7, 0xe2, 0x9f, 0x01, 0x99, 0x4b, 0x80, +]; diff --git a/crates/ironrdp-bulk/src/ncrush/mod.rs b/crates/ironrdp-bulk/src/ncrush/mod.rs new file mode 100644 index 0000000000..e2f0f8b8db --- /dev/null +++ b/crates/ironrdp-bulk/src/ncrush/mod.rs @@ -0,0 +1,2267 @@ +//! NCRUSH (RDP 6.0) Huffman-based compression implementation. +//! +//! Uses Huffman coding with an LRU offset cache for LZ77-style +//! back-references. Operates on a 64 KB sliding-window history buffer. + +#[cfg(test)] +mod test_data; + +pub(crate) mod tables; + +#[cfg(not(feature = "std"))] +use alloc::{boxed::Box, vec, vec::Vec}; + +use crate::error::BulkError; + +/// LSB-first (little-endian) bit writer for NCRUSH compression. +/// +/// Bits are accumulated from the least-significant side. When the +/// accumulator reaches ≥ 16 bits, the lower 16 bits are flushed as +/// two little-endian bytes to the output buffer. +pub(crate) struct NCrushBitWriter<'a> { + /// Output byte buffer. + dst: &'a mut [u8], + /// Current write position in `dst`. + pos: usize, + /// Bit accumulator (bits are packed from LSB upward). + accumulator: u32, + /// Number of valid bits in the accumulator. + offset: u32, +} + +impl<'a> NCrushBitWriter<'a> { + /// Creates a new bit writer targeting the given output buffer. + pub(crate) fn new(dst: &'a mut [u8]) -> Self { + Self { + dst, + pos: 0, + accumulator: 0, + offset: 0, + } + } + + /// Writes `nbits` bits from the low end of `bits` into the stream. + /// + /// When the accumulator reaches ≥ 16 bits, the lower 16 bits are + /// flushed as 2 bytes (little-endian) to the output buffer. + /// + /// Returns `Err` if the output buffer overflows. + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "intentional truncation: lower 16 bits of u32 accumulator flushed as u16 LE" + )] + pub(crate) fn write_bits(&mut self, bits: u32, nbits: u32) -> Result<(), BulkError> { + self.accumulator |= bits << self.offset; + self.offset += nbits; + + if self.offset >= 16 { + if self.pos + 2 > self.dst.len() { + return Err(BulkError::OutputBufferTooSmall { + required: self.pos + 2, + available: self.dst.len(), + }); + } + let le_bytes = (self.accumulator as u16).to_le_bytes(); + self.dst[self.pos] = le_bytes[0]; + self.dst[self.pos + 1] = le_bytes[1]; + self.pos += 2; + self.accumulator >>= 16; + self.offset -= 16; + } + + Ok(()) + } + + /// Flushes any remaining bits in the accumulator to the output buffer. + /// + /// Always writes 2 bytes (the lower 16 bits of the accumulator). + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "intentional truncation: lower 16 bits of u32 accumulator flushed as u16 LE" + )] + pub(crate) fn finish(&mut self) -> Result<(), BulkError> { + if self.pos + 2 > self.dst.len() { + return Err(BulkError::OutputBufferTooSmall { + required: self.pos + 2, + available: self.dst.len(), + }); + } + let le_bytes = (self.accumulator as u16).to_le_bytes(); + self.dst[self.pos] = le_bytes[0]; + self.dst[self.pos + 1] = le_bytes[1]; + self.pos += 2; + Ok(()) + } + + /// Returns the number of bytes written so far (including any `finish` call). + pub(crate) fn bytes_written(&self) -> usize { + self.pos + } + + /// Returns `true` if writing `n` more bytes would overflow the buffer. + pub(crate) fn would_overflow(&self, n: usize) -> bool { + self.pos + n > self.dst.len() + } +} + +/// History buffer size for NCRUSH (64 KB). +pub(crate) const HISTORY_BUFFER_SIZE: usize = 65536; + +/// Hash table size (same as history buffer size). +pub(crate) const HASH_TABLE_SIZE: usize = 65536; + +/// Match table size (same as history buffer size). +pub(crate) const MATCH_TABLE_SIZE: usize = 65536; + +/// Huffman table for CopyOffset decoding (1024 entries). +pub(crate) const HUFF_TABLE_COPY_OFFSET_SIZE: usize = 1024; + +/// Huffman table for LengthOfMatch decoding (4096 entries). +pub(crate) const HUFF_TABLE_LOM_SIZE: usize = 4096; + +/// Number of offset cache entries (LRU cache of recent offsets). +pub(crate) const OFFSET_CACHE_SIZE: usize = 4; + +/// History buffer fence value used for integrity checking. +pub(crate) const HISTORY_BUFFER_FENCE: u32 = 0xABAB_ABAB; + +/// NCRUSH (RDP 6.0) compression/decompression context. +/// +/// Maintains a 64 KB sliding-window history buffer, hash/match tables for +/// LZ77 matching, an LRU offset cache, and runtime Huffman tables generated +/// from the static lookup tables. +/// +pub(crate) struct NCrushContext { + /// Current write position in the history buffer. + pub(crate) history_offset: usize, + /// End offset of valid data in the history buffer (HistoryBufferSize − 1). + pub(crate) history_end_offset: usize, + /// Total history buffer size (always 65536). + pub(crate) history_buffer_size: usize, + /// 64 KB sliding-window history buffer. + pub(crate) history_buffer: Box<[u8; HISTORY_BUFFER_SIZE]>, + /// Integrity fence value (always 0xABABABAB). + pub(crate) history_buffer_fence: u32, + /// LRU offset cache for the 4 most recent copy offsets. + pub(crate) offset_cache: [u32; OFFSET_CACHE_SIZE], + /// Hash table for 2-byte hash lookups during compression (maps hash → position). + pub(crate) hash_table: Box<[u16; HASH_TABLE_SIZE]>, + /// Match table for hash-chain traversal during compression. + pub(crate) match_table: Box<[u16; MATCH_TABLE_SIZE]>, + /// Runtime Huffman table for CopyOffset index decoding (generated from + /// `CopyOffsetBitsLUT`). + pub(crate) huff_table_copy_offset: Box<[u8; HUFF_TABLE_COPY_OFFSET_SIZE]>, + /// Runtime Huffman table for LengthOfMatch index decoding (generated from + /// `LOMBitsLUT`). + pub(crate) huff_table_lom: Box<[u8; HUFF_TABLE_LOM_SIZE]>, +} + +/// Helper to allocate a heap-zeroed boxed array. +#[expect( + clippy::unnecessary_box_returns, + reason = "Box return is intentional: arrays up to 64 KB must be heap-allocated to avoid stack overflow" +)] +fn heap_zeroed_array() -> Box<[T; N]> { + // Use vec to avoid stack allocation, then convert to boxed array + let v: Vec = vec![T::default(); N]; + v.into_boxed_slice().try_into().unwrap_or_else(|_| unreachable!()) +} + +impl NCrushContext { + /// Creates a new NCRUSH context. + /// + /// Allocates the history, hash, and match buffers on the heap, generates + /// the runtime Huffman tables, and calls `reset(false)`. + /// + pub(crate) fn new() -> Result { + let mut ctx = Self { + history_offset: 0, + history_end_offset: HISTORY_BUFFER_SIZE - 1, + history_buffer_size: HISTORY_BUFFER_SIZE, + history_buffer: heap_zeroed_array::(), + history_buffer_fence: HISTORY_BUFFER_FENCE, + offset_cache: [0u32; OFFSET_CACHE_SIZE], + hash_table: heap_zeroed_array::(), + match_table: heap_zeroed_array::(), + huff_table_copy_offset: heap_zeroed_array::(), + huff_table_lom: heap_zeroed_array::(), + }; + + ctx.generate_tables()?; + ctx.reset(false); + + Ok(ctx) + } + + /// Generates the runtime Huffman lookup tables for CopyOffset and + /// LengthOfMatch decoding. + /// + /// Populates `huff_table_lom` from `LOMBitsLUT`/`LOMBaseLUT` and + /// `huff_table_copy_offset` from `CopyOffsetBitsLUT`. + /// + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "table generation: k (usize ≤4096) safely cast to u32 for verification" + )] + fn generate_tables(&mut self) -> Result<(), BulkError> { + // --- Generate HuffTableLOM --- + // For each LOM index i (0..28), fill entries for all values that + // map to that index (based on LOMBitsLUT). + let mut cnt: usize = 0; + for i in 0u8..28 { + let bits = tables::LOMBitsLUT[usize::from(i)]; + let num_entries = 1usize << bits; + for _j in 0..num_entries { + let l = cnt + 2; + if l < HUFF_TABLE_LOM_SIZE { + self.huff_table_lom[l] = i; + } + cnt += 1; + } + } + + // Verify the generated LOM table: for each k in [2, 4096), ensure + // the round-trip: LOMBaseLUT[index] + (k-2) & mask == k. + for k in 2..HUFF_TABLE_LOM_SIZE { + let i = if (k - 2) < 768 { + usize::from(self.huff_table_lom[k]) + } else { + 28usize + }; + + if i >= tables::LOMBitsLUT.len() || i >= tables::LOMBaseLUT.len() { + return Err(BulkError::InvalidCompressedData( + "NCRUSH: generate_tables LOM index out of range", + )); + } + + let mask = (1u32 << tables::LOMBitsLUT[i]) - 1; + let base = tables::LOMBaseLUT[i]; + let reconstructed = (mask & (k as u32 - 2)) + base; + if reconstructed != k as u32 { + return Err(BulkError::InvalidCompressedData( + "NCRUSH: generate_tables LOM verification failed", + )); + } + } + + // --- Generate HuffTableCopyOffset --- + // First 16 indices: direct mapping (no shift) + let mut k: usize = 0; + for i in 0u8..16 { + let bits = tables::CopyOffsetBitsLUT[usize::from(i)]; + let num_entries = 1usize << bits; + for _j in 0..num_entries { + let l = k + 2; + if l < HUFF_TABLE_COPY_OFFSET_SIZE { + self.huff_table_copy_offset[l] = i; + } + k += 1; + } + } + + // Indices 16..32: shifted by 7 bits (>> 7) + k /= 128; + for i in 16u8..32 { + let bits = tables::CopyOffsetBitsLUT[usize::from(i)]; + // bits >= 7 for indices 16..32 + let shift = bits.saturating_sub(7); + let num_entries = 1usize << shift; + for _j in 0..num_entries { + let l = k + 2 + 256; + if l < HUFF_TABLE_COPY_OFFSET_SIZE { + self.huff_table_copy_offset[l] = i; + } + k += 1; + } + } + + if (k + 256) > HUFF_TABLE_COPY_OFFSET_SIZE { + return Err(BulkError::InvalidCompressedData( + "NCRUSH: generate_tables CopyOffset overflow", + )); + } + + Ok(()) + } + + /// Refills the bit accumulator from the source data. + /// + /// NCRUSH uses **LSB-first (little-endian)** bit packing — bits are consumed + /// from the least-significant end of the `bits` accumulator. When `nbits` + /// drops below 16, this function reads 1 or 2 bytes from `src[src_pos..]` + /// and appends them to the high end of the accumulator. + /// + /// Returns `false` if the source is exhausted AND `nbits` is negative + /// (irrecoverable underflow). Returns `true` otherwise. + /// + #[expect( + clippy::as_conversions, + clippy::cast_sign_loss, + reason = "*nbits (i32) cast to u32 for shift; always non-negative when used" + )] + fn fetch_bits(src: &[u8], src_pos: &mut usize, nbits: &mut i32, bits: &mut u32) -> bool { + if *nbits < 16 { + let remaining = src.len().saturating_sub(*src_pos); + match remaining { + 0 => { + // No more source bytes — only fail if we've consumed + // more bits than were available (negative nbits). + return *nbits >= 0; + } + 1 => { + // Single byte available + let byte_val = u32::from(src[*src_pos]); + *src_pos += 1; + if *nbits >= 0 { + *bits = bits.wrapping_add(byte_val << (*nbits as u32)); + } + *nbits += 8; + } + _ => { + // Two or more bytes available — read a 16-bit word (LE) + let lo = u32::from(src[*src_pos]); + *src_pos += 1; + let hi = u32::from(src[*src_pos]); + *src_pos += 1; + let word = lo | (hi << 8); + *bits = bits.wrapping_add(word << (*nbits as u32)); + *nbits += 16; + } + } + } + true + } + + /// Decompresses an NCRUSH-compressed packet. + /// + /// `src_data` contains the raw packet data (possibly compressed). + /// `flags_value` contains control flags (`PACKET_COMPRESSED`, + /// `PACKET_FLUSHED`, `PACKET_AT_FRONT`). + /// + /// Returns a slice of the decompressed data. For non-compressed packets, + /// returns a slice of the input. For compressed packets, returns a slice + /// into the internal history buffer. + /// + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + reason = "Huffman decode: masked u32 values safely narrowed to u8/usize; \ + bit_length/lom_bits (u32, ≤15) safely cast to i32; \ + copy_offset/length_of_match (u32 ≤65535) safely widen to usize" + )] + pub(crate) fn decompress<'a>(&'a mut self, src_data: &'a [u8], flags_value: u32) -> Result<&'a [u8], BulkError> { + use crate::flags; + + if self.history_end_offset != HISTORY_BUFFER_SIZE - 1 { + return Err(BulkError::InvalidCompressedData("NCRUSH: invalid history end offset")); + } + + let history_end = self.history_end_offset; // 65535 + + // Handle PACKET_AT_FRONT: slide window — move last 32 KB to the front + if flags_value & flags::PACKET_AT_FRONT != 0 { + if self.history_offset <= 32768 { + return Err(BulkError::InvalidCompressedData( + "NCRUSH: history offset too small for AT_FRONT", + )); + } + let src_start = self.history_offset - 32768; + self.history_buffer.copy_within(src_start..src_start + 32768, 0); + self.history_offset = 32768; + self.history_buffer[32768..HISTORY_BUFFER_SIZE].fill(0); + } + + // Handle PACKET_FLUSHED: reset history and offset cache + if flags_value & flags::PACKET_FLUSHED != 0 { + self.history_offset = 0; + self.history_buffer.fill(0); + self.offset_cache.fill(0); + } + + // If not compressed, return source data directly + if flags_value & flags::PACKET_COMPRESSED == 0 { + return Ok(src_data); + } + + if src_data.len() < 4 { + return Err(BulkError::InvalidCompressedData( + "NCRUSH: compressed input too short (< 4 bytes)", + )); + } + + let history_start = self.history_offset; + let mut history_ptr = self.history_offset; + + // --- Bit accumulator initialisation (first 4 bytes, little-endian) --- + let mut bits = u32::from_le_bytes([src_data[0], src_data[1], src_data[2], src_data[3]]); + let mut nbits: i32 = 32; + let mut src_pos: usize = 4; + + // Masks for Huffman table lookups + const LEC_MASK: u32 = 0x1FFF; // 13-bit mask for HuffTableLEC[8192] + const LOM_MASK: u32 = 0x01FF; // 9-bit mask for HuffTableLOM[512] + + let mut index_lec: u32; + + // ===== Main decompression loop ===== + loop { + // --- Inner loop: decode literals until a non-literal symbol --- + loop { + let masked_bits = (bits & LEC_MASK) as usize; + if masked_bits >= tables::HuffTableLEC.len() { + return Err(BulkError::InvalidCompressedData("NCRUSH: LEC masked bits out of range")); + } + + let lec_entry = tables::HuffTableLEC[masked_bits]; + index_lec = u32::from(lec_entry & 0xFFF); + let bit_length = u32::from(lec_entry >> 12); + bits >>= bit_length; + nbits -= bit_length as i32; + + if !Self::fetch_bits(src_data, &mut src_pos, &mut nbits, &mut bits) { + return Err(BulkError::UnexpectedEndOfInput); + } + + if index_lec >= 256 { + break; + } + + // Literal byte + if history_ptr >= history_end { + return Err(BulkError::HistoryBufferOverflow); + } + + self.history_buffer[history_ptr] = lec_entry as u8; // lower 8 bits of u16 + history_ptr += 1; + } + + // End-of-stream marker (symbol 256) + if index_lec == 256 { + break; + } + + // --- Decode CopyOffset and LengthOfMatch --- + let copy_offset_index = index_lec - 257; + + let copy_offset: u32; + let length_of_match_base: u32; + + if copy_offset_index >= 32 { + // --- Offset Cache Hit (LEC symbols 289–292) --- + let cache_index = (index_lec - 289) as usize; + if cache_index >= OFFSET_CACHE_SIZE { + return Err(BulkError::InvalidCompressedData( + "NCRUSH: offset cache index out of range", + )); + } + + copy_offset = self.offset_cache[cache_index]; + + // Decode LengthOfMatch from HuffTableLOM + let lom_masked = (bits & LOM_MASK) as usize; + if lom_masked >= tables::HuffTableLOM.len() { + return Err(BulkError::InvalidCompressedData( + "NCRUSH: LOM index out of range (cache path)", + )); + } + let lom_entry = tables::HuffTableLOM[lom_masked]; + let length_of_match_idx = usize::from(lom_entry & 0xFFF); + let bit_length = u32::from(lom_entry >> 12); + bits >>= bit_length; + nbits -= bit_length as i32; + + if !Self::fetch_bits(src_data, &mut src_pos, &mut nbits, &mut bits) { + return Err(BulkError::UnexpectedEndOfInput); + } + + if length_of_match_idx >= tables::LOMBitsLUT.len() || length_of_match_idx >= tables::LOMBaseLUT.len() { + return Err(BulkError::InvalidCompressedData("NCRUSH: LOM lookup out of range")); + } + + let lom_bits = tables::LOMBitsLUT[length_of_match_idx]; + let mut lom_base = tables::LOMBaseLUT[length_of_match_idx]; + + if lom_bits > 0 { + let extra_mask = (1u32 << lom_bits) - 1; + lom_base += bits & extra_mask; + bits >>= lom_bits; + nbits -= lom_bits as i32; + + if !Self::fetch_bits(src_data, &mut src_pos, &mut nbits, &mut bits) { + return Err(BulkError::UnexpectedEndOfInput); + } + } + + length_of_match_base = lom_base; + + // LRU cache update: swap cache_index entry to the front + self.offset_cache.swap(cache_index, 0); + } else { + // --- Regular CopyOffset (LEC symbols 257–288) --- + let coi = copy_offset_index as usize; + if coi >= tables::CopyOffsetBitsLUT.len() || coi >= tables::CopyOffsetBaseLUT.len() { + return Err(BulkError::InvalidCompressedData( + "NCRUSH: CopyOffset lookup out of range", + )); + } + + let co_bits = tables::CopyOffsetBitsLUT[coi]; + let co_base = tables::CopyOffsetBaseLUT[coi]; + + copy_offset = if co_bits > 0 { + let extra_mask = (1u32 << co_bits) - 1; + let extra = bits & extra_mask; + let tmp = co_base + extra; + if tmp < 1 { + return Err(BulkError::InvalidCompressedData("NCRUSH: CopyOffset underflow")); + } + bits >>= co_bits; + nbits -= co_bits as i32; + + if !Self::fetch_bits(src_data, &mut src_pos, &mut nbits, &mut bits) { + return Err(BulkError::UnexpectedEndOfInput); + } + + tmp - 1 + } else { + co_base - 1 + }; + + // Decode LengthOfMatch from HuffTableLOM + let lom_masked = (bits & LOM_MASK) as usize; + if lom_masked >= tables::HuffTableLOM.len() { + return Err(BulkError::InvalidCompressedData( + "NCRUSH: LOM index out of range (offset path)", + )); + } + let lom_entry = tables::HuffTableLOM[lom_masked]; + let length_of_match_idx = usize::from(lom_entry & 0xFFF); + let bit_length = u32::from(lom_entry >> 12); + bits >>= bit_length; + nbits -= bit_length as i32; + + if !Self::fetch_bits(src_data, &mut src_pos, &mut nbits, &mut bits) { + return Err(BulkError::UnexpectedEndOfInput); + } + + if length_of_match_idx >= tables::LOMBitsLUT.len() || length_of_match_idx >= tables::LOMBaseLUT.len() { + return Err(BulkError::InvalidCompressedData("NCRUSH: LOM lookup out of range")); + } + + let lom_bits = tables::LOMBitsLUT[length_of_match_idx]; + let mut lom_base = tables::LOMBaseLUT[length_of_match_idx]; + + if lom_bits > 0 { + let extra_mask = (1u32 << lom_bits) - 1; + lom_base += bits & extra_mask; + bits >>= lom_bits; + nbits -= lom_bits as i32; + + if !Self::fetch_bits(src_data, &mut src_pos, &mut nbits, &mut bits) { + return Err(BulkError::UnexpectedEndOfInput); + } + } + + length_of_match_base = lom_base; + + // Push new offset into cache (shift down, insert at front) + self.offset_cache[3] = self.offset_cache[2]; + self.offset_cache[2] = self.offset_cache[1]; + self.offset_cache[1] = self.offset_cache[0]; + self.offset_cache[0] = copy_offset; + } + + // --- Perform history buffer copy --- + let length_of_match = length_of_match_base as usize; + let copy_offset_usize = copy_offset as usize; + + if length_of_match < 2 { + return Err(BulkError::InvalidCompressedData("NCRUSH: match length < 2")); + } + + // The wrapped source address and the destination must both have + // enough room for the full match length within the buffer. + let copy_src_wrapped = history_ptr.wrapping_sub(copy_offset_usize) & 0xFFFF; + if length_of_match > history_end + || copy_src_wrapped >= (history_end - length_of_match) + || history_ptr >= (history_end - length_of_match) + { + return Err(BulkError::HistoryBufferOverflow); + } + + let copy_length = core::cmp::min(length_of_match, copy_offset_usize); + + if history_ptr >= copy_offset_usize { + // --- No-wrap case: source is within the current buffer --- + let src_start = history_ptr - copy_offset_usize; + + if length_of_match <= copy_offset_usize { + // Fast path: no overlap — bulk copy. + self.history_buffer + .copy_within(src_start..src_start + copy_length, history_ptr); + history_ptr += copy_length; + } else { + // Slow path: LZ77 overlap (length > offset). + // Must copy byte-by-byte: earlier output feeds later input. + for i in 0..copy_length { + self.history_buffer[history_ptr] = self.history_buffer[src_start + i]; + history_ptr += 1; + } + + // Handle repeating pattern (overlap). + let pattern_start = src_start + copy_offset_usize; + let mut idx = 0usize; + let mut remaining = length_of_match; + while remaining > copy_offset_usize { + if idx >= copy_offset_usize { + idx = 0; + } + self.history_buffer[history_ptr] = self.history_buffer[pattern_start + idx]; + history_ptr += 1; + idx += 1; + remaining -= 1; + } + } + } else { + // --- Wrap case: source wraps around the buffer boundary --- + // This path is reached when CopyOffset > history_ptr, + // meaning the reference reaches back past the start of + // the current write position (into data from a previous + // packet, placed by PACKET_AT_FRONT). + let wrap_src = history_end - (copy_offset_usize - history_ptr) + 1; + + let mut src_idx = wrap_src; + let mut cl = copy_length; + + // Copy from end of buffer until buffer end or copy_length + while cl > 0 && src_idx <= history_end { + self.history_buffer[history_ptr] = self.history_buffer[src_idx]; + history_ptr += 1; + src_idx += 1; + cl -= 1; + } + + // If copy_length wasn't exhausted (source wrapped around + // to the beginning), continue from position 0. + // NOTE: this continuation is folded into the + // repeat loop below. The bounds check guarantees this + // path is not reached when LengthOfMatch <= CopyOffset. + src_idx = 0; + while cl > 0 { + self.history_buffer[history_ptr] = self.history_buffer[src_idx]; + history_ptr += 1; + src_idx += 1; + cl -= 1; + } + + // Handle repeating pattern from beginning of buffer + if length_of_match > copy_offset_usize { + let mut idx = 0usize; + let mut remaining = length_of_match; + while remaining > copy_offset_usize { + if idx >= copy_offset_usize { + idx = 0; + } + self.history_buffer[history_ptr] = self.history_buffer[idx]; + history_ptr += 1; + idx += 1; + remaining -= 1; + } + } + } + } + + // Verify end-of-stream marker + if index_lec != 256 { + return Err(BulkError::InvalidCompressedData( + "NCRUSH: stream did not end with EOS marker", + )); + } + + // Verify history buffer fence (detects buffer overflows) + if self.history_buffer_fence != HISTORY_BUFFER_FENCE { + return Err(BulkError::InvalidCompressedData( + "NCRUSH: history buffer fence overwritten", + )); + } + + self.history_offset = history_ptr; + Ok(&self.history_buffer[history_start..history_ptr]) + } + + /// Adds source data positions to the hash table and match table. + /// + /// For each position in `[history_offset, history_offset + src_size - 8)`: + /// - Computes a 2-byte hash from the source data (little-endian u16). + /// - Stores the old hash table entry into `match_table[position]` + /// (creating a chain of positions with the same hash). + /// - Updates `hash_table[hash]` with the new position. + /// + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "offset bounded by 65536 (fits u16); hash from u16::from_le_bytes widens to usize" + )] + pub(crate) fn hash_table_add(&mut self, src_data: &[u8], src_size: usize, history_offset: usize) { + if src_size < 8 { + return; + } + let end_offset = history_offset + src_size - 8; + let mut offset = history_offset; + let mut src_idx = 0usize; + + while offset < end_offset { + let hash = usize::from(u16::from_le_bytes([src_data[src_idx], src_data[src_idx + 1]])); + let old_entry = self.hash_table[hash]; + self.hash_table[hash] = offset as u16; + self.match_table[offset] = old_entry; + src_idx += 1; + offset += 1; + } + } + + /// Computes the match length between two positions in the history buffer. + /// + /// Compares bytes starting at `offset1` and `offset2`, stopping when a + /// mismatch is found or `offset1` exceeds `limit`. Returns the number + /// of matching bytes (may be negative if the limit is exceeded + /// immediately, indicating no valid comparison was possible). + /// + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + reason = "usize→i32: offsets bounded by 64KB history buffer, always fit in i32" + )] + fn find_match_length(&self, offset1: usize, offset2: usize, limit: usize) -> i32 { + let buf = &*self.history_buffer; + let start = offset1; + let mut i1 = offset1; + let mut i2 = offset2; + + // Fast path: compare 8 bytes at a time using u64 XOR. + while i1 + 8 <= limit && i2 + 8 < buf.len() { + let a = u64::from_ne_bytes(buf[i1..i1 + 8].try_into().unwrap_or_else(|_| unreachable!())); + let b = u64::from_ne_bytes(buf[i2..i2 + 8].try_into().unwrap_or_else(|_| unreachable!())); + if a != b { + let xor = a ^ b; + let diff_byte = if cfg!(target_endian = "little") { + xor.trailing_zeros() / 8 + } else { + xor.leading_zeros() / 8 + } as usize; + i1 += diff_byte + 1; + return (i1 as i32) - (start as i32) - 1; + } + i1 += 8; + i2 += 8; + } + + // Slow path: byte-by-byte for remaining bytes. + loop { + if i1 > limit { + break; + } + let v1 = buf[i1]; + let v2 = buf[i2]; + i1 += 1; + i2 += 1; + if v1 != v2 { + break; + } + } + + // Equivalent to `Ptr1 - (Ptr + 1)` + (i1 as i32) - (start as i32) - 1 + } + + /// Finds the best LZ77 match for the current position using hash-chain + /// traversal. + /// + /// Searches up to 4 candidates from the hash chain, using a quick filter + /// (checking the byte at the current best match length) before computing + /// full match lengths. Returns `None` if no match is found, or + /// `Some((match_length, match_offset))` for the best match. + /// + /// A match length > 16 is considered "good enough" and terminates the + /// search early. + /// + #[expect( + clippy::as_conversions, + clippy::cast_sign_loss, + reason = "i32→usize: find_match_length returns i32 bounded by 64KB buffer; \ + u16 offsets widen to usize for array indexing" + )] + pub(crate) fn find_best_match(&mut self, history_offset: u16) -> Result, BulkError> { + let ho = usize::from(history_offset); + + if self.match_table[ho] == 0 { + return Ok(None); + } + + let mut match_length: usize = 2; + let mut offset: u16 = history_offset; + let history_ptr = self.history_offset; // end of valid data + + // Sentinel: allows the chain-following logic to work at position 0 + self.match_table[0] = history_offset; + let mut match_offset: u16 = self.match_table[ho]; + let mut next_offset: u16 = self.match_table[usize::from(offset)]; + + for _i in 0..4 { + let mut j: i32 = -1; + + // 6 chain-following steps with quick-filter check. + // Each step follows the chain one link and checks if the + // candidate's byte at position `match_length` matches the + // current position's byte at `history_offset + match_length`. + // Alternates between Offset and NextOffset. + let target_byte = self.history_buffer[ho + match_length]; + + if j < 0 { + offset = self.match_table[usize::from(next_offset)]; + if self.history_buffer[match_length + usize::from(next_offset)] == target_byte { + j = 0; + } + } + if j < 0 { + next_offset = self.match_table[usize::from(offset)]; + if self.history_buffer[match_length + usize::from(offset)] == target_byte { + j = 1; + } + } + if j < 0 { + offset = self.match_table[usize::from(next_offset)]; + if self.history_buffer[match_length + usize::from(next_offset)] == target_byte { + j = 2; + } + } + if j < 0 { + next_offset = self.match_table[usize::from(offset)]; + if self.history_buffer[match_length + usize::from(offset)] == target_byte { + j = 3; + } + } + if j < 0 { + offset = self.match_table[usize::from(next_offset)]; + if self.history_buffer[match_length + usize::from(next_offset)] == target_byte { + j = 4; + } + } + if j < 0 { + next_offset = self.match_table[usize::from(offset)]; + if self.history_buffer[match_length + usize::from(offset)] == target_byte { + j = 5; + } + } + + if j >= 0 { + // Pick the candidate: even j → NextOffset, odd j → Offset + if (j % 2) == 0 { + offset = next_offset; + } + + if (offset != history_offset) && (offset != 0) { + let len = self.find_match_length(ho + 2, usize::from(offset) + 2, history_ptr); + let length = (len + 2) as usize; + + if (len + 2) < 2 { + // Boundary error — clean up and return error + self.match_table[0] = 0; + return Err(BulkError::InvalidCompressedData( + "NCRUSH: match length computation error", + )); + } + + if length > 16 { + // Great match — update and stop + match_length = length; + match_offset = offset; + break; + } + + if length > match_length { + match_length = length; + match_offset = offset; + } + + if (length <= match_length) || (ho + 2 < history_ptr) { + next_offset = self.match_table[usize::from(offset)]; + // match_length may have changed; next iteration + // will recompute target_byte + continue; + } + } + + break; + } + // j < 0: no candidate passed the quick filter in this batch + // of 6 chain steps. Continue to next outer iteration (the + // chain pointers have already advanced). + } + + self.match_table[0] = 0; // Clean up sentinel + Ok(Some((match_length, match_offset))) + } + + /// Slides the encoder window by moving the last 32 KB of history to the + /// front, and adjusting all hash/match table entries accordingly. + /// + /// Called when the history buffer is nearly full to make room for new data + /// while preserving the most recent 32 KB for back-references. + /// + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::cast_sign_loss, + reason = "history_ptr bounded by 65536; i32 arithmetic for offset adjustment; \ + hash/match table entries are u16 (< 65536)" + )] + pub(crate) fn move_encoder_windows(&mut self, history_ptr: usize) -> Result<(), BulkError> { + const HALF: usize = HISTORY_BUFFER_SIZE / 2; // 32768 + + if !(HALF..=HISTORY_BUFFER_SIZE).contains(&history_ptr) { + return Err(BulkError::InvalidCompressedData( + "NCRUSH: invalid history ptr for window move", + )); + } + + // Move last 32 KB to front + self.history_buffer.copy_within((history_ptr - HALF)..history_ptr, 0); + + let history_offset = (history_ptr - HALF) as i32; + + // Adjust hash table entries: subtract the offset shift + for entry in self.hash_table.iter_mut() { + let new_val = i32::from(*entry) - history_offset; + *entry = if new_val <= 0 { 0 } else { new_val as u16 }; + } + + // Adjust match table entries (relocate first half) + const MATCH_HALF: usize = MATCH_TABLE_SIZE / 2; + for j in 0..MATCH_HALF { + let src_idx = (history_offset as usize) + j; + if src_idx >= MATCH_TABLE_SIZE { + continue; + } + let new_val = i32::from(self.match_table[src_idx]) - history_offset; + self.match_table[j] = if new_val <= 0 { 0 } else { new_val as u16 }; + } + + // Zero upper half of match table + self.match_table[MATCH_HALF..MATCH_TABLE_SIZE].fill(0); + + Ok(()) + } + + // --------------------------------------------------------------- + // Huffman encoding helpers for NCRUSH compression + // --------------------------------------------------------------- + + /// Reads a little-endian 16-bit Huffman code from the `HuffCodeLEC` byte + /// array at the given symbol index. + /// + /// `HuffCodeLEC` stores codes as pairs of bytes (LE). For symbol `index`, + /// the two bytes at `[2*index]` and `[2*index + 1]` form the 16-bit code. + fn get_lec_code(index: usize) -> Result { + let byte_index = index * 2; + if byte_index + 1 >= tables::HuffCodeLEC.len() { + return Err(BulkError::InvalidCompressedData("HuffCodeLEC index out of bounds")); + } + let lo = u32::from(tables::HuffCodeLEC[byte_index]); + let hi = u32::from(tables::HuffCodeLEC[byte_index + 1]); + Ok(lo | (hi << 8)) + } + + /// Encodes a literal byte using the LEC Huffman table. + /// + /// Writes `HuffLengthLEC[literal]` bits of `HuffCodeLEC[2*literal]` (LE word). + /// + pub(crate) fn encode_literal(writer: &mut NCrushBitWriter<'_>, literal: u8) -> Result<(), BulkError> { + let index = usize::from(literal); + if index >= tables::HuffLengthLEC.len() { + return Err(BulkError::InvalidCompressedData( + "Literal index out of HuffLengthLEC range", + )); + } + let bit_length = u32::from(tables::HuffLengthLEC[index]); + if bit_length > 15 { + return Err(BulkError::InvalidCompressedData( + "Literal Huffman code length exceeds 15", + )); + } + let code = Self::get_lec_code(index)?; + writer.write_bits(code, bit_length) + } + + /// Encodes a CopyOffset that is **not** in the offset cache. + /// + /// 1. Looks up the copy-offset index via `huff_table_copy_offset`. + /// 2. Writes the Huffman code for `LEC[257 + copy_offset_index]`. + /// 3. Writes the extra low-order bits of the raw copy-offset. + /// + #[expect( + clippy::as_conversions, + reason = "copy_offset >> 7 + 256 bounded by table size; lookup_idx usize for indexing" + )] + pub(crate) fn encode_copy_offset( + &self, + writer: &mut NCrushBitWriter<'_>, + copy_offset: u32, + ) -> Result<(), BulkError> { + // Map raw offset to lookup index + let lookup = if copy_offset >= 256 { + (copy_offset >> 7) + 256 + } else { + copy_offset + }; + + let lookup_idx = (lookup as usize) + 2; // +2 matches generate_tables offset + + if lookup_idx >= HUFF_TABLE_COPY_OFFSET_SIZE { + return Err(BulkError::InvalidCompressedData("CopyOffset lookup index out of range")); + } + + let copy_offset_index = usize::from(self.huff_table_copy_offset[lookup_idx]); + + if copy_offset_index >= tables::CopyOffsetBitsLUT.len() { + return Err(BulkError::InvalidCompressedData( + "CopyOffsetIndex out of CopyOffsetBitsLUT range", + )); + } + let copy_offset_bits = tables::CopyOffsetBitsLUT[copy_offset_index]; + + let index_lec = 257 + copy_offset_index; + if index_lec >= tables::HuffLengthLEC.len() { + return Err(BulkError::InvalidCompressedData( + "CopyOffset LEC index out of HuffLengthLEC range", + )); + } + let bit_length = u32::from(tables::HuffLengthLEC[index_lec]); + if bit_length > 15 { + return Err(BulkError::InvalidCompressedData( + "CopyOffset Huffman code length exceeds 15", + )); + } + if copy_offset_bits > 18 { + return Err(BulkError::InvalidCompressedData("CopyOffset extra bits exceed 18")); + } + + let code = Self::get_lec_code(index_lec)?; + writer.write_bits(code, bit_length)?; + + // Write extra bits (the low-order bits of the raw offset) + if copy_offset_bits > 0 { + let mask = (1u32 << copy_offset_bits) - 1; + let masked_bits = copy_offset & mask; + writer.write_bits(masked_bits, copy_offset_bits)?; + } + + Ok(()) + } + + /// Encodes an offset-cache hit (CopyOffset found in the LRU cache). + /// + /// Writes the Huffman code for `LEC[289 + cache_index]`. + /// + pub(crate) fn encode_offset_cache_hit( + writer: &mut NCrushBitWriter<'_>, + cache_index: usize, + ) -> Result<(), BulkError> { + let index_lec = 289 + cache_index; + if index_lec >= tables::HuffLengthLEC.len() { + return Err(BulkError::InvalidCompressedData( + "OffsetCache LEC index out of HuffLengthLEC range", + )); + } + let bit_length = u32::from(tables::HuffLengthLEC[index_lec]); + if bit_length >= 15 { + return Err(BulkError::InvalidCompressedData( + "OffsetCache Huffman code length >= 15", + )); + } + let code = Self::get_lec_code(index_lec)?; + writer.write_bits(code, bit_length) + } + + /// Encodes a match length using the LOM Huffman table. + /// + /// 1. Looks up `IndexCO` via `huff_table_lom` (or uses 28 for large lengths). + /// 2. Writes `HuffCodeLOM[IndexCO]` with `HuffLengthLOM[IndexCO]` bits. + /// 3. Writes extra bits for the difference from `LOMBaseLUT[IndexCO]`. + /// + /// The `match_length` parameter is the **raw** match length (not minus 2). + /// Uses `(MatchLength - 2)` for the LOM table lookup but keeps + /// `MatchLength` for the extra-bits calculation. + /// + #[expect( + clippy::as_conversions, + reason = "match_length bounded by 4096 (fits usize); huff_table_lom entries are u8→usize" + )] + pub(crate) fn encode_length_of_match( + &self, + writer: &mut NCrushBitWriter<'_>, + match_length: u32, + ) -> Result<(), BulkError> { + let index_co = if (match_length.wrapping_sub(2)) >= 768 { + 28usize + } else { + if (match_length as usize) >= HUFF_TABLE_LOM_SIZE { + return Err(BulkError::InvalidCompressedData( + "MatchLength out of HuffTableLOM range", + )); + } + usize::from(self.huff_table_lom[match_length as usize]) + }; + + if index_co >= tables::HuffLengthLOM.len() { + return Err(BulkError::InvalidCompressedData( + "LOM IndexCO out of HuffLengthLOM range", + )); + } + let bit_length = u32::from(tables::HuffLengthLOM[index_co]); + + if index_co >= tables::LOMBitsLUT.len() { + return Err(BulkError::InvalidCompressedData("LOM IndexCO out of LOMBitsLUT range")); + } + let lom_bits = tables::LOMBitsLUT[index_co]; + + if index_co >= tables::HuffCodeLOM.len() { + return Err(BulkError::InvalidCompressedData("LOM IndexCO out of HuffCodeLOM range")); + } + writer.write_bits(u32::from(tables::HuffCodeLOM[index_co]), bit_length)?; + + // Write extra bits: (MatchLength - 2) & mask + if lom_bits > 0 { + let mask = (1u32 << lom_bits) - 1; + let masked_bits = match_length.wrapping_sub(2) & mask; + + // Verify the encoding is consistent + if index_co >= tables::LOMBaseLUT.len() { + return Err(BulkError::InvalidCompressedData("LOM IndexCO out of LOMBaseLUT range")); + } + if masked_bits + tables::LOMBaseLUT[index_co] != match_length { + return Err(BulkError::InvalidCompressedData( + "LOM encoding inconsistency: MaskedBits + LOMBase != MatchLength", + )); + } + + writer.write_bits(masked_bits, lom_bits)?; + } + + Ok(()) + } + + /// Encodes the end-of-stream marker (symbol 256 in the LEC table). + /// + pub(crate) fn encode_eos(writer: &mut NCrushBitWriter<'_>) -> Result<(), BulkError> { + let index = 256; + if index >= tables::HuffLengthLEC.len() { + return Err(BulkError::InvalidCompressedData("EOS index out of HuffLengthLEC range")); + } + let bit_length = u32::from(tables::HuffLengthLEC[index]); + if bit_length > 15 { + return Err(BulkError::InvalidCompressedData("EOS Huffman code length exceeds 15")); + } + let code = Self::get_lec_code(index)?; + writer.write_bits(code, bit_length) + } + + // --------------------------------------------------------------- + // NCRUSH compress + // --------------------------------------------------------------- + + /// Compresses `src_data` using the NCRUSH algorithm. + /// + /// `dst_buffer` must be at least `src_data.len()` bytes. + /// + /// On success returns `(compressed_size, flags)`. + /// - If `flags & PACKET_COMPRESSED != 0`: the compressed data is in + /// `dst_buffer[..compressed_size]`. + /// - If `flags & PACKET_FLUSHED != 0` **and** `flags & PACKET_COMPRESSED == 0`: + /// compression was abandoned (output would exceed input); the caller + /// should transmit the original `src_data` uncompressed. The context + /// has been reset. + /// + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "history offsets bounded by 65536 (fit u16/u32); \ + copy_offset bounded by history_buffer_size-1 (fits u32); \ + match_length bounded by history buffer (fits u32)" + )] + pub(crate) fn compress(&mut self, src_data: &[u8], dst_buffer: &mut [u8]) -> Result<(usize, u32), BulkError> { + use crate::flags; + + const COMPRESSION_LEVEL: u32 = 2; // NCRUSH compression type + + let src_size = src_data.len(); + if src_size == 0 { + return Ok((0, COMPRESSION_LEVEL)); + } + + let mut out_flags: u32 = 0; + let mut packet_at_front = false; + let mut packet_flushed = false; + + // --- Window management: check if we need to slide or flush --- + if src_size + self.history_offset >= 65529 { + if self.history_offset == self.history_buffer_size + 1 { + // Previously flushed — reset offset + self.history_offset = 0; + packet_flushed = true; + } else { + // Slide the encoder window + self.move_encoder_windows(self.history_offset)?; + self.history_offset = 32768; + packet_at_front = true; + } + } + + if dst_buffer.len() < src_size { + return Err(BulkError::OutputBufferTooSmall { + required: src_size, + available: dst_buffer.len(), + }); + } + + let _dst_size = src_size; // Compressed output must not exceed source size + + // --- Populate hash chains and copy source into history buffer --- + let history_offset = self.history_offset; + self.hash_table_add(src_data, src_size, history_offset); + + // Copy source data into the history buffer at the current offset + let hist_end = history_offset + src_size; + if hist_end > HISTORY_BUFFER_SIZE { + return Err(BulkError::HistoryBufferOverflow); + } + self.history_buffer[history_offset..hist_end].copy_from_slice(src_data); + let history_ptr_limit = hist_end; // End of valid data (for bounds check) + + // Set history_offset to end of valid data — find_best_match reads + // self.history_offset as the limit for find_match_length. + self.history_offset = hist_end; + + // --- Main compression loop --- + let mut writer = NCrushBitWriter::new(dst_buffer); + let mut src_pos: usize = 0; + let mut history_ptr: usize = history_offset; // Current position in history buffer + + // Process all bytes except the last 2 (match needs at least 2 bytes ahead) + while src_pos < src_size.saturating_sub(2) { + let mut match_length: usize = 0; + let ho = history_ptr; + + if ho > history_ptr_limit { + return Err(BulkError::InvalidCompressedData( + "NCRUSH compress: history pointer past limit", + )); + } + if ho >= HISTORY_BUFFER_SIZE { + return Err(BulkError::InvalidCompressedData( + "NCRUSH compress: history offset >= 65536", + )); + } + + // Try to find a match via the hash chain + let mut match_offset: u16 = 0; + if self.match_table[ho] != 0 { + if let Some((mlen, moff)) = self.find_best_match(ho as u16)? { + match_length = mlen; + match_offset = moff; + } + } + + // Compute CopyOffset if we found a match + let copy_offset = if match_length > 0 { + let match_offset_usize = usize::from(match_offset); + let dist = if history_ptr >= match_offset_usize { + history_ptr - match_offset_usize + } else { + // Wrap around + history_ptr + HISTORY_BUFFER_SIZE - match_offset_usize + }; + (self.history_buffer_size - 1) & dist + } else { + 0 + }; + + if match_length == 2 && copy_offset >= 64 { + match_length = 0; + } + + if match_length == 0 { + // --- Encode literal --- + let literal = src_data[src_pos]; + src_pos += 1; + history_ptr += 1; + + // Check output space (PACKET_FLUSH #1) + if writer.would_overflow(2) { + self.reset(true); + return Ok((src_size, flags::PACKET_FLUSHED | COMPRESSION_LEVEL)); + } + + Self::encode_literal(&mut writer, literal)?; + } else { + // --- Encode match --- + history_ptr += match_length; + src_pos += match_length; + + // Check output space (PACKET_FLUSH #2) + if writer.would_overflow(8) { + self.reset(true); + return Ok((src_size, flags::PACKET_FLUSHED | COMPRESSION_LEVEL)); + } + + // --- Offset cache management (LRU) --- + let mut offset_cache_index: usize = 5; // sentinel: not in cache + + // copy_offset is bounded by (history_buffer_size - 1) = 65535, fits in u32 + let copy_offset_u32 = copy_offset as u32; + + if copy_offset_u32 == self.offset_cache[0] + || copy_offset_u32 == self.offset_cache[1] + || copy_offset_u32 == self.offset_cache[2] + || copy_offset_u32 == self.offset_cache[3] + { + if copy_offset_u32 == self.offset_cache[3] { + self.offset_cache.swap(3, 0); + offset_cache_index = 3; + } else if copy_offset_u32 == self.offset_cache[2] { + self.offset_cache.swap(2, 0); + offset_cache_index = 2; + } else if copy_offset_u32 == self.offset_cache[1] { + self.offset_cache.swap(1, 0); + offset_cache_index = 1; + } else { + // copy_offset_u32 == self.offset_cache[0] + offset_cache_index = 0; + } + } else { + // Not in cache — push new offset, shift others down + self.offset_cache[3] = self.offset_cache[2]; + self.offset_cache[2] = self.offset_cache[1]; + self.offset_cache[1] = self.offset_cache[0]; + self.offset_cache[0] = copy_offset_u32; + } + + let match_length_u32 = match_length as u32; + + if offset_cache_index >= 4 { + // CopyOffset NOT in cache + self.encode_copy_offset(&mut writer, copy_offset_u32)?; + self.encode_length_of_match(&mut writer, match_length_u32)?; + } else { + // CopyOffset IS in cache + Self::encode_offset_cache_hit(&mut writer, offset_cache_index)?; + self.encode_length_of_match(&mut writer, match_length_u32)?; + } + } + + if history_ptr >= HISTORY_BUFFER_SIZE { + return Err(BulkError::InvalidCompressedData( + "NCRUSH compress: history pointer reached buffer end", + )); + } + } + + // --- Encode remaining trailing literals (last 0-2 bytes) --- + while src_pos < src_size { + // Check output space (PACKET_FLUSH #3) + if writer.would_overflow(2) { + self.reset(true); + return Ok((src_size, flags::PACKET_FLUSHED | COMPRESSION_LEVEL)); + } + + let literal = src_data[src_pos]; + src_pos += 1; + history_ptr += 1; + + Self::encode_literal(&mut writer, literal)?; + } + + // --- Check output space for EOS + finish (PACKET_FLUSH #4) --- + if writer.would_overflow(4) { + self.reset(true); + return Ok((src_size, flags::PACKET_FLUSHED | COMPRESSION_LEVEL)); + } + + // --- Encode end-of-stream marker --- + Self::encode_eos(&mut writer)?; + writer.finish()?; + + let compressed_size = writer.bytes_written(); + + // If compressed output is larger than source, flush + if compressed_size > src_size { + self.reset(true); + return Ok((src_size, flags::PACKET_FLUSHED | COMPRESSION_LEVEL)); + } + + // --- Build flags --- + out_flags |= flags::PACKET_COMPRESSED; + out_flags |= COMPRESSION_LEVEL; + + if packet_at_front { + out_flags |= flags::PACKET_AT_FRONT; + } + + if packet_flushed { + out_flags |= flags::PACKET_FLUSHED; + } + + // Update history offset for next call + self.history_offset = history_ptr; + + if self.history_offset >= self.history_buffer_size { + return Err(BulkError::InvalidCompressedData( + "NCRUSH compress: final history offset out of range", + )); + } + + Ok((compressed_size, out_flags)) + } + + /// Resets the NCRUSH context. + /// + /// Zeros the history buffer, offset cache, match table, and hash table. + /// If `flush` is `true`, sets `history_offset` to `history_buffer_size + 1` + /// (sentinel value indicating a flush). Otherwise sets `history_offset` to 0. + /// + pub(crate) fn reset(&mut self, flush: bool) { + self.history_buffer.fill(0); + self.offset_cache.fill(0); + self.match_table.fill(0); + self.hash_table.fill(0); + + if flush { + self.history_offset = self.history_buffer_size + 1; + } else { + self.history_offset = 0; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ncrush_context_new() { + let ctx = NCrushContext::new().unwrap(); + assert_eq!(ctx.history_buffer_size, HISTORY_BUFFER_SIZE); + assert_eq!(ctx.history_end_offset, HISTORY_BUFFER_SIZE - 1); + assert_eq!(ctx.history_offset, 0); + assert_eq!(ctx.history_buffer_fence, HISTORY_BUFFER_FENCE); + assert_eq!(ctx.offset_cache, [0u32; 4]); + } + + #[test] + fn test_ncrush_context_reset_no_flush() { + let mut ctx = NCrushContext::new().unwrap(); + ctx.history_offset = 12345; + ctx.offset_cache[0] = 42; + ctx.offset_cache[1] = 99; + ctx.history_buffer[100] = 0xFF; + + ctx.reset(false); + + assert_eq!(ctx.history_offset, 0); + assert_eq!(ctx.offset_cache, [0u32; 4]); + assert_eq!(ctx.history_buffer[100], 0); + } + + #[test] + fn test_ncrush_context_reset_flush() { + let mut ctx = NCrushContext::new().unwrap(); + ctx.reset(true); + + assert_eq!(ctx.history_offset, HISTORY_BUFFER_SIZE + 1); + } + + #[test] + fn test_ncrush_generate_tables_lom() { + let ctx = NCrushContext::new().unwrap(); + + // First entry at index 2 should be 0 (LOM index 0) + assert_eq!(ctx.huff_table_lom[2], 0); + + // Spot-check: LOMBitsLUT[0..8] are all 0, meaning each index maps to + // exactly 1 entry. So indices 2..10 should be 0,1,2,...,7. + for i in 0..8 { + assert_eq!(ctx.huff_table_lom[2 + i], u8::try_from(i).unwrap_or(0)); + } + } + + #[test] + fn test_ncrush_generate_tables_copy_offset() { + let ctx = NCrushContext::new().unwrap(); + + // First entry at index 2 should be 0 + assert_eq!(ctx.huff_table_copy_offset[2], 0); + + // CopyOffsetBitsLUT[0..4] are all 0, so 1 entry each. + // Indices 2..6 should be 0,1,2,3. + for i in 0..4 { + assert_eq!(ctx.huff_table_copy_offset[2 + i], u8::try_from(i).unwrap_or(0)); + } + } + + // --- decompress tests --- + + #[test] + fn test_ncrush_decompress_uncompressed_passthrough() { + use crate::flags; + + let mut ctx = NCrushContext::new().unwrap(); + let data = b"hello world"; + + // No PACKET_COMPRESSED flag → should return source data directly + let result = ctx.decompress(data, flags::PACKET_FLUSHED).unwrap(); + assert_eq!(result, b"hello world"); + // History offset should remain 0 (no decompression occurred) + assert_eq!(ctx.history_offset, 0); + } + + #[test] + fn test_ncrush_decompress_flushed_clears_state() { + use crate::flags; + + let mut ctx = NCrushContext::new().unwrap(); + ctx.history_offset = 1000; + ctx.offset_cache[0] = 42; + ctx.history_buffer[500] = 0xFF; + + let data = b"test"; + let _result = ctx.decompress(data, flags::PACKET_FLUSHED).unwrap(); + + // PACKET_FLUSHED should clear history and offset cache + assert_eq!(ctx.history_offset, 0); + assert_eq!(ctx.offset_cache, [0u32; 4]); + assert_eq!(ctx.history_buffer[500], 0); + } + + #[test] + fn test_ncrush_decompress_compressed_too_short() { + use crate::flags; + + let mut ctx = NCrushContext::new().unwrap(); + let data = [0u8; 3]; // less than 4 bytes + + let result = ctx.decompress(&data, flags::PACKET_FLUSHED | flags::PACKET_COMPRESSED); + assert!(result.is_err()); + } + + #[test] + fn test_ncrush_decompress_fetch_bits_basic() { + // Test the fetch_bits helper directly + let src = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; + let mut src_pos = 0usize; + let mut nbits: i32 = 8; + let mut bits: u32 = 0x12; + + // nbits >= 16, no fetch needed + let mut nbits2: i32 = 20; + let mut bits2: u32 = 0x12345; + let mut src_pos2 = 0usize; + assert!(NCrushContext::fetch_bits(&src, &mut src_pos2, &mut nbits2, &mut bits2)); + assert_eq!(nbits2, 20); // unchanged + assert_eq!(bits2, 0x12345); // unchanged + assert_eq!(src_pos2, 0); // no bytes consumed + + // nbits < 16, fetch 2 bytes + assert!(NCrushContext::fetch_bits(&src, &mut src_pos, &mut nbits, &mut bits)); + assert_eq!(nbits, 24); // 8 + 16 + assert_eq!(src_pos, 2); + // bits = 0x12 + (0xAA | (0xBB << 8)) << 8 + // = 0x12 + 0xBBAA << 8 + // = 0x12 + 0xBBAA00 + // = 0xBBAA12 + assert_eq!(bits, 0x00BBAA12); + } + + #[test] + fn test_ncrush_decompress_fetch_bits_single_byte() { + let src = [0x42]; + let mut src_pos = 0usize; + let mut nbits: i32 = 5; + let mut bits: u32 = 0x1F; + + assert!(NCrushContext::fetch_bits(&src, &mut src_pos, &mut nbits, &mut bits)); + assert_eq!(nbits, 13); // 5 + 8 + assert_eq!(src_pos, 1); + // bits = 0x1F + (0x42 << 5) + // = 0x1F + 0x840 + // = 0x85F + assert_eq!(bits, 0x85F); + } + + #[test] + fn test_ncrush_decompress_fetch_bits_exhausted_ok() { + let src: [u8; 0] = []; + let mut src_pos = 0usize; + let mut nbits: i32 = 5; + let mut bits: u32 = 0x1F; + + // No more data but nbits >= 0 → ok + assert!(NCrushContext::fetch_bits(&src, &mut src_pos, &mut nbits, &mut bits)); + assert_eq!(nbits, 5); // unchanged + } + + #[test] + fn test_ncrush_decompress_fetch_bits_exhausted_fail() { + let src: [u8; 0] = []; + let mut src_pos = 0usize; + let mut nbits: i32 = -1; + let mut bits: u32 = 0; + + // No more data and nbits < 0 → fail + assert!(!NCrushContext::fetch_bits(&src, &mut src_pos, &mut nbits, &mut bits)); + } + + /// Byte-exact decompression test. + /// + /// Verifies that NCRUSH decompression of the compressed "bells" data + /// produces the original plaintext byte-for-byte. + #[test] + fn test_ncrush_decompress_bells() { + use crate::flags; + + let mut ctx = NCrushContext::new().unwrap(); + + // flags: PACKET_COMPRESSED | 2 (compression type NCRUSH) + let flags_value = flags::PACKET_COMPRESSED | 0x02; + + let result = ctx.decompress(test_data::TEST_BELLS_NCRUSH, flags_value).unwrap(); + + assert_eq!( + result.len(), + test_data::TEST_BELLS_DATA.len(), + "output size mismatch: got {}, expected {}", + result.len(), + test_data::TEST_BELLS_DATA.len() + ); + + assert_eq!( + result, + test_data::TEST_BELLS_DATA, + "NCrushDecompressBells: output mismatch" + ); + } + + // --- Match-finding tests --- + + #[test] + fn test_ncrush_hash_table_add_basic() { + let mut ctx = NCrushContext::new().unwrap(); + + // Write "ABABAB..." into history at offset 100 + let data = b"ABABABABAB"; // 10 bytes + ctx.hash_table_add(data, data.len(), 100); + + // The 2-byte hash for "AB" is u16::from_le_bytes([0x41, 0x42]) = 0x4241 + let hash_ab = usize::from(u16::from_le_bytes([b'A', b'B'])); + + // The last occurrence of "AB" should be at the highest offset + // that was inserted. With src_size=10, end_offset = 100+10-8 = 102. + // So we insert at offsets 100, 101. + // "AB" appears at offset 100 (data[0..2]) only; offset 101 would + // hash "BA" which is different. + let hash_ba = usize::from(u16::from_le_bytes([b'B', b'A'])); + + // hash_table[hash_ab] should point to offset 100 + // (only "AB" at position 100 — the later "AB" at 102 is not inserted + // because end_offset = 102 and the while condition is offset < end_offset) + // Actually let's trace: offset starts at 100, end = 102 + // offset=100: hash("AB")=0x4241, insert 100 + // offset=101: hash("BA")=0x4142, insert 101 + // offset=102: 102 >= 102, stop + // Wait, the condition is offset < end_offset, so: + // 100 < 102 → yes, process + // 101 < 102 → yes, process + // 102 < 102 → no, stop + // So only 2 positions are inserted. + + // For hash "AB" (0x4241): hash_table[0x4241] = 100 + assert_eq!(ctx.hash_table[hash_ab], 100); + // For hash "BA" (0x4142): hash_table[0x4142] = 101 + assert_eq!(ctx.hash_table[hash_ba], 101); + } + + #[test] + fn test_ncrush_hash_table_add_chain() { + let mut ctx = NCrushContext::new().unwrap(); + + // Insert two blocks with the same starting bytes to create a chain + let data1 = b"XYXYXYXYXY"; // 10 bytes at offset 50 + ctx.hash_table_add(data1, data1.len(), 50); + + let data2 = b"XYXYXYXYXY"; // 10 bytes at offset 200 + ctx.hash_table_add(data2, data2.len(), 200); + + let hash_xy = usize::from(u16::from_le_bytes([b'X', b'Y'])); + + // hash_table[hash_xy] should point to most recent (200) + assert_eq!(ctx.hash_table[hash_xy], 200); + + // match_table[200] should chain back to 50 + assert_eq!(ctx.match_table[200], 50); + } + + #[test] + fn test_ncrush_find_match_length_basic() { + let mut ctx = NCrushContext::new().unwrap(); + + // Write identical data at two positions + ctx.history_buffer[10] = b'A'; + ctx.history_buffer[11] = b'B'; + ctx.history_buffer[12] = b'C'; + ctx.history_buffer[13] = b'D'; + ctx.history_buffer[14] = b'X'; // mismatch + + ctx.history_buffer[20] = b'A'; + ctx.history_buffer[21] = b'B'; + ctx.history_buffer[22] = b'C'; + ctx.history_buffer[23] = b'D'; + ctx.history_buffer[24] = b'Y'; // mismatch + + ctx.history_offset = 30; // limit + + // Match from offset 10 and 20: 4 bytes match (A, B, C, D), then mismatch + let len = ctx.find_match_length(10, 20, 30); + assert_eq!(len, 4); + } + + #[test] + fn test_ncrush_find_match_length_limit() { + let mut ctx = NCrushContext::new().unwrap(); + + // Write identical data at two positions + for i in 0..10 { + ctx.history_buffer[100 + i] = u8::try_from(i).unwrap_or(0).saturating_add(1); + ctx.history_buffer[200 + i] = u8::try_from(i).unwrap_or(0).saturating_add(1); + } + + // With limit = 104, we can compare indices 100..104 (5 checks). + // All 5 bytes match, but then 105 > 104, so we break. + // Return: (105 - 100) - 1 = 4 + let len = ctx.find_match_length(100, 200, 104); + assert_eq!(len, 4); + } + + #[test] + fn test_ncrush_find_match_length_immediate_limit() { + let ctx = NCrushContext::new().unwrap(); + + // offset1 > limit immediately → returns -1 + let len = ctx.find_match_length(10, 20, 5); + assert_eq!(len, -1); + } + + #[test] + fn test_ncrush_find_best_match_no_chain() { + let mut ctx = NCrushContext::new().unwrap(); + + // match_table[100] = 0 → no chain + let result = ctx.find_best_match(100).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_ncrush_find_best_match_simple() { + let mut ctx = NCrushContext::new().unwrap(); + + // Set up: write "ABCDEF" at position 50 and "ABCDXY" at position 100 + let pattern1 = b"ABCDEF"; + let pattern2 = b"ABCDXY"; + for (i, &b) in pattern1.iter().enumerate() { + ctx.history_buffer[50 + i] = b; + } + for (i, &b) in pattern2.iter().enumerate() { + ctx.history_buffer[100 + i] = b; + } + + // Set history_offset (write cursor) past the data + ctx.history_offset = 110; + + // Create a hash chain: match_table[100] = 50 (position 100 chains to 50) + ctx.match_table[100] = 50; + + // The first 2 bytes match the hash; find_best_match starts comparing + // from offset+2. Bytes at 52,53 match 102,103 (C,D), then mismatch (E vs X). + // So match length = 4 (A,B,C,D). + let result = ctx.find_best_match(100).unwrap(); + assert!(result.is_some()); + let (length, offset) = result.unwrap(); + assert_eq!(length, 4); + assert_eq!(offset, 50); + } + + #[test] + fn test_ncrush_move_encoder_windows_basic() { + let mut ctx = NCrushContext::new().unwrap(); + + // Write some data in the second half of the buffer + for i in 0..100 { + ctx.history_buffer[32768 + i] = u8::try_from(i).unwrap_or(0).saturating_add(1); + } + + // Set up hash and match table entries pointing into second half + ctx.hash_table[0x1234] = 32800; // points to position 32800 + ctx.match_table[32800] = 32790; // chains to position 32790 + + // Slide window: history_ptr = 32868 (100 bytes past the half point) + ctx.move_encoder_windows(32868).unwrap(); + + // Data should now be at the front: positions 32768..32868 → 0..100 + // But actually, copy_within copies (32868 - 32768)..32868 = 100..32868 + // Wait, let me recalculate. + // HALF = 32768, history_ptr = 32868 + // Source: (32868 - 32768)..32868 = 100..32868 + // Dest: 0.. + + // Actually, the function copies history_buffer[(history_ptr - HALF)..history_ptr] + // = history_buffer[100..32868] to position 0. + // history_offset = history_ptr - HALF = 100 + // Hash table entries are adjusted: 32800 - 100 = 32700 + assert_eq!(ctx.hash_table[0x1234], 32700); + } + + #[test] + fn test_ncrush_move_encoder_windows_clamps_negative() { + let mut ctx = NCrushContext::new().unwrap(); + + // Entry pointing before the offset should be clamped to 0 + ctx.hash_table[42] = 50; // 50 < offset (say, 100) + + ctx.move_encoder_windows(32868).unwrap(); + // history_offset = 32868 - 32768 = 100 + // 50 - 100 = -50 → clamped to 0 + assert_eq!(ctx.hash_table[42], 0); + } + + // --------------------------------------------------------------- + // NCrushBitWriter tests + // --------------------------------------------------------------- + + #[test] + fn test_ncrush_bit_writer_basic() { + let mut buf = [0u8; 16]; + let mut writer = NCrushBitWriter::new(&mut buf); + + // Write 8 bits: 0xAB + writer.write_bits(0xAB, 8).unwrap(); + assert_eq!(writer.bytes_written(), 0); // not flushed yet (< 16 bits) + + // Write 8 more bits: 0xCD → accumulator has 16 bits, should flush + writer.write_bits(0xCD, 8).unwrap(); + assert_eq!(writer.bytes_written(), 2); + // Flushed bytes should be LE: low byte first + assert_eq!(buf[0], 0xAB); + assert_eq!(buf[1], 0xCD); + } + + #[test] + fn test_ncrush_bit_writer_finish() { + let mut buf = [0u8; 16]; + let mut writer = NCrushBitWriter::new(&mut buf); + + // Write 5 bits + writer.write_bits(0x15, 5).unwrap(); + assert_eq!(writer.bytes_written(), 0); + + writer.finish().unwrap(); + assert_eq!(writer.bytes_written(), 2); + assert_eq!(buf[0], 0x15); + assert_eq!(buf[1], 0x00); + } + + #[test] + fn test_ncrush_bit_writer_overflow() { + let mut buf = [0u8; 2]; // Only room for one flush + let mut writer = NCrushBitWriter::new(&mut buf); + + // Fill 16 bits → flush (2 bytes) + writer.write_bits(0xFFFF, 16).unwrap(); + assert_eq!(writer.bytes_written(), 2); + + // Another 16 bits → should fail + let result = writer.write_bits(0x0001, 16); + assert!(result.is_err()); + } + + #[test] + fn test_ncrush_bit_writer_accumulation() { + // Verify bits are accumulated LSB-first + let mut buf = [0u8; 4]; + let mut writer = NCrushBitWriter::new(&mut buf); + + // Write 4 bits: 0b1010 + writer.write_bits(0b1010, 4).unwrap(); + // Write 4 bits: 0b0101 → accumulator = 0b0101_1010 + writer.write_bits(0b0101, 4).unwrap(); + // Write 8 bits: 0xFF → accumulator has 16 bits, flush + writer.write_bits(0xFF, 8).unwrap(); + assert_eq!(writer.bytes_written(), 2); + // Low byte: 0b0101_1010 = 0x5A, High byte: 0xFF + assert_eq!(buf[0], 0x5A); + assert_eq!(buf[1], 0xFF); + } + + // --------------------------------------------------------------- + // Huffman encoding helper tests + // --------------------------------------------------------------- + + #[test] + fn test_ncrush_encode_literal() { + let mut buf = [0u8; 16]; + let mut writer = NCrushBitWriter::new(&mut buf); + + // Encode literal 0 (space character equivalent in many codings) + NCrushContext::encode_literal(&mut writer, 0).unwrap(); + + // HuffLengthLEC[0] = 6, HuffCodeLEC[0..2] = [0x04, 0x00] → code = 0x0004 + // After write_bits(0x0004, 6): accumulator = 0x04, offset = 6 + // Not flushed yet — finish to see the output + writer.finish().unwrap(); + assert_eq!(buf[0], 0x04); + assert_eq!(buf[1], 0x00); + } + + #[test] + fn test_ncrush_encode_two_literals() { + let mut buf = [0u8; 16]; + let mut writer = NCrushBitWriter::new(&mut buf); + + // Encode literal 0: code=0x04, len=6 + NCrushContext::encode_literal(&mut writer, 0).unwrap(); + // Encode literal 1: code=0x24, len=6 + NCrushContext::encode_literal(&mut writer, 1).unwrap(); + // Total: 12 bits — not flushed yet + + // Encode literal 2: code=0x14, len=6 + NCrushContext::encode_literal(&mut writer, 2).unwrap(); + // Total: 18 bits → should have flushed 16 bits + + assert_eq!(writer.bytes_written(), 2); + + // accumulator after 3 writes: + // bits 0-5: 0x04 = 0b000100 + // bits 6-11: 0x24 = 0b100100 + // bits 12-17: 0x14 = 0b010100 + // Combined: 0b010100_100100_000100 + // Lower 16 bits: 0b0100_100100_000100 = 0x4904 + assert_eq!(buf[0], 0x04); + assert_eq!(buf[1], 0x49); + } + + #[test] + fn test_ncrush_encode_eos() { + let mut buf = [0u8; 16]; + let mut writer = NCrushBitWriter::new(&mut buf); + + // EOS is symbol 256 in LEC table + NCrushContext::encode_eos(&mut writer).unwrap(); + + // HuffLengthLEC[256] = 13, HuffCodeLEC[512..514] = [0xFF, 0x17] → code = 0x17FF + writer.finish().unwrap(); + // 13 bits of 0x17FF = lower 13 bits = 0x17FF & 0x1FFF = 0x17FF + assert_eq!(buf[0], 0xFF); + assert_eq!(buf[1], 0x17); + } + + #[test] + fn test_ncrush_encode_offset_cache_hit() { + let mut buf = [0u8; 16]; + let mut writer = NCrushBitWriter::new(&mut buf); + + // Cache index 0 → LEC index 289 + // HuffLengthLEC[289] = 5, HuffCodeLEC[578..580] = [0x18, 0x00] → code = 0x0018 + NCrushContext::encode_offset_cache_hit(&mut writer, 0).unwrap(); + writer.finish().unwrap(); + assert_eq!(buf[0], 0x18); + assert_eq!(buf[1], 0x00); + } + + #[test] + fn test_ncrush_encode_length_of_match_simple() { + let ctx = NCrushContext::new().unwrap(); + let mut buf = [0u8; 16]; + let mut writer = NCrushBitWriter::new(&mut buf); + + // match_length = 2 (minimum match) + // huff_table_lom[2] = 0 → IndexCO = 0 + // HuffLengthLOM[0] = 4, HuffCodeLOM[0] = 0x0001 + // LOMBitsLUT[0] = 0 → no extra bits + ctx.encode_length_of_match(&mut writer, 2).unwrap(); + writer.finish().unwrap(); + assert_eq!(buf[0], 0x01); + assert_eq!(buf[1], 0x00); + } + + #[test] + fn test_ncrush_encode_copy_offset_small() { + let ctx = NCrushContext::new().unwrap(); + let mut buf = [0u8; 16]; + let mut writer = NCrushBitWriter::new(&mut buf); + + // CopyOffset = 1 (small offset) + // lookup = 1, lookup_idx = 3 + // huff_table_copy_offset[3] should be 1 (from generate_tables) + // CopyOffsetBitsLUT[1] = 0 → no extra bits + // IndexLEC = 257 + 1 = 258 + // The encoding should succeed without error + ctx.encode_copy_offset(&mut writer, 1).unwrap(); + writer.finish().unwrap(); + + // Just verify it wrote something without error + assert!(writer.bytes_written() > 0); + } + + #[test] + fn test_ncrush_encode_would_overflow() { + let writer = NCrushBitWriter::new(&mut []); + assert!(writer.would_overflow(1)); + } + + // --------------------------------------------------------------- + // ncrush_compress tests + // --------------------------------------------------------------- + + #[test] + fn test_ncrush_compress_basic() { + let mut ctx = NCrushContext::new().unwrap(); + let data = b"hello world"; + let mut dst = vec![0u8; 256]; + + let (size, flags_out) = ctx.compress(data, &mut dst).unwrap(); + + // Should produce compressed output (or flush if output > src) + // Either way, it should not error + assert!(size > 0); + // flags should include COMPRESSION_LEVEL (2) + assert_ne!(flags_out & 0x0F, 0); // compression type != 0 + } + + #[test] + fn test_ncrush_compress_with_repeats() { + let mut ctx = NCrushContext::new().unwrap(); + // Repetitive data should compress well + let data = b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + let mut dst = vec![0u8; 256]; + + let (size, flags_out) = ctx.compress(data, &mut dst).unwrap(); + assert!(size > 0); + + // With enough repetition, compression should succeed + if flags_out & crate::flags::PACKET_COMPRESSED != 0 { + assert!(size < data.len()); + } + } + + #[test] + fn test_ncrush_compress_empty() { + let mut ctx = NCrushContext::new().unwrap(); + let data = b""; + let mut dst = vec![0u8; 256]; + + let (size, flags_out) = ctx.compress(data, &mut dst).unwrap(); + assert_eq!(size, 0); + assert_eq!(flags_out, 2); // Just compression level + } + + #[test] + fn test_ncrush_compress_updates_history_offset() { + let mut ctx = NCrushContext::new().unwrap(); + let data = b"some test data for ncrush compression"; + let mut dst = vec![0u8; 256]; + + let initial_offset = ctx.history_offset; + let (_size, flags_out) = ctx.compress(data, &mut dst).unwrap(); + + if flags_out & crate::flags::PACKET_COMPRESSED != 0 { + // History offset should have advanced by the source data length + assert_eq!(ctx.history_offset, initial_offset + data.len()); + } + } + + #[test] + fn test_ncrush_compress_offset_cache_updated() { + let mut ctx = NCrushContext::new().unwrap(); + // Use data with a repeated pattern to trigger back-references + let data = b"for.whom.the.bell.tolls,.the.bell.tolls.for.thee!"; + let mut dst = vec![0u8; 256]; + + let (_size, flags_out) = ctx.compress(data, &mut dst).unwrap(); + + if flags_out & crate::flags::PACKET_COMPRESSED != 0 { + // If compression succeeded, at least one offset cache entry + // should be non-zero (from back-references) + let any_cached = ctx.offset_cache.iter().any(|&x| x != 0); + assert!(any_cached, "Offset cache should have been updated"); + } + } + + /// Byte-exact compression test. + /// + /// Compresses the "bells" test string with a fresh compressor context + /// and verifies the output matches the expected compressed bytes exactly. + #[test] + fn test_ncrush_compress_bells() { + let mut ctx = NCrushContext::new().unwrap(); + let mut dst = vec![0u8; 65536]; + + let (size, flags_out) = ctx.compress(test_data::TEST_BELLS_DATA, &mut dst).unwrap(); + + // Must be compressed + assert_ne!( + flags_out & crate::flags::PACKET_COMPRESSED, + 0, + "Expected PACKET_COMPRESSED flag, got flags: {flags_out:#010x}" + ); + + // Size must match expected + assert_eq!( + size, + test_data::TEST_BELLS_NCRUSH.len(), + "Compressed size mismatch: got {size}, expected {}", + test_data::TEST_BELLS_NCRUSH.len() + ); + + // Content must match byte-for-byte + assert_eq!( + &dst[..size], + test_data::TEST_BELLS_NCRUSH, + "Compressed output does not match expected bytes" + ); + } + + // --------------------------------------------------------------- + // NCRUSH round-trip tests + // --------------------------------------------------------------- + + /// Round-trip test with the "bells" test data. + /// Compress → decompress → verify output matches original input. + #[test] + fn test_ncrush_roundtrip_bells() { + let mut compressor = NCrushContext::new().unwrap(); + let mut decompressor = NCrushContext::new().unwrap(); + + let input = test_data::TEST_BELLS_DATA; + let mut compressed = vec![0u8; 65536]; + + // Compress + let (comp_size, flags_out) = compressor.compress(input, &mut compressed).unwrap(); + assert_ne!( + flags_out & crate::flags::PACKET_COMPRESSED, + 0, + "Expected compression to succeed" + ); + + // Decompress + let decompressed = decompressor.decompress(&compressed[..comp_size], flags_out).unwrap(); + + // Verify byte-for-byte match + assert_eq!( + decompressed, input, + "Round-trip failed: decompressed output does not match original input" + ); + } + + /// Round-trip test with a short repetitive pattern. + #[test] + fn test_ncrush_roundtrip_repetitive() { + let mut compressor = NCrushContext::new().unwrap(); + let mut decompressor = NCrushContext::new().unwrap(); + + let input = b"ABCABCABCABCABCABCABCABCABCABCABCABC"; + let mut compressed = vec![0u8; 65536]; + + let (comp_size, flags_out) = compressor.compress(input, &mut compressed).unwrap(); + + if flags_out & crate::flags::PACKET_COMPRESSED != 0 { + let decompressed = decompressor.decompress(&compressed[..comp_size], flags_out).unwrap(); + assert_eq!(decompressed, input); + } + } + + /// Round-trip test with a longer text block containing varied content. + #[test] + fn test_ncrush_roundtrip_prose() { + let mut compressor = NCrushContext::new().unwrap(); + let mut decompressor = NCrushContext::new().unwrap(); + + let input = b"The quick brown fox jumps over the lazy dog. \ + The quick brown fox jumps over the lazy dog again. \ + And once more, the quick brown fox jumps."; + let mut compressed = vec![0u8; 65536]; + + let (comp_size, flags_out) = compressor.compress(input, &mut compressed).unwrap(); + + if flags_out & crate::flags::PACKET_COMPRESSED != 0 { + let decompressed = decompressor.decompress(&compressed[..comp_size], flags_out).unwrap(); + assert_eq!(decompressed, input.as_slice()); + } + } + + /// Round-trip test with binary-like data (all byte values 0-255). + #[test] + fn test_ncrush_roundtrip_binary() { + let mut compressor = NCrushContext::new().unwrap(); + let mut decompressor = NCrushContext::new().unwrap(); + + // Create a pattern with all 256 byte values repeated + let mut input = Vec::new(); + for _ in 0..2 { + for b in 0u8..=255 { + input.push(b); + } + } + let mut compressed = vec![0u8; 65536]; + + let (comp_size, flags_out) = compressor.compress(&input, &mut compressed).unwrap(); + + if flags_out & crate::flags::PACKET_COMPRESSED != 0 { + let decompressed = decompressor.decompress(&compressed[..comp_size], flags_out).unwrap(); + assert_eq!(decompressed, input.as_slice()); + } + } + + /// Round-trip test with multiple sequential compressions on the same context + /// (tests that history buffer state carries across calls). + #[test] + fn test_ncrush_roundtrip_sequential() { + let mut compressor = NCrushContext::new().unwrap(); + let mut decompressor = NCrushContext::new().unwrap(); + + let inputs: &[&[u8]] = &[ + b"first.message.to.compress", + b"second.message.with.some.overlap.to.compress", + b"third.message.compress.compress.compress", + ]; + + for input in inputs { + let mut compressed = vec![0u8; 65536]; + + let (comp_size, flags_out) = compressor.compress(input, &mut compressed).unwrap(); + + if flags_out & crate::flags::PACKET_COMPRESSED != 0 { + let decompressed = decompressor.decompress(&compressed[..comp_size], flags_out).unwrap(); + assert_eq!( + decompressed, + *input, + "Sequential round-trip failed for input: {:?}", + core::str::from_utf8(input) + ); + } + } + } +} diff --git a/crates/ironrdp-bulk/src/ncrush/tables.rs b/crates/ironrdp-bulk/src/ncrush/tables.rs new file mode 100644 index 0000000000..65e89318fe --- /dev/null +++ b/crates/ironrdp-bulk/src/ncrush/tables.rs @@ -0,0 +1,861 @@ +// NCRUSH (RDP 6.0) static lookup tables. + +#![allow(non_upper_case_globals)] + +#[rustfmt::skip] +pub(crate) static HuffTableLEC: [u16; 8192] = [ + 0x510B, 0x611F, 0x610D, 0x9027, 0x6000, 0x7105, 0x6117, 0xA068, 0x5111, 0x7007, 0x6113, 0x90C0, + 0x6108, 0x8018, 0x611B, 0xA0B3, 0x510F, 0x7003, 0x6110, 0x9042, 0x6002, 0x800B, 0x6119, 0xA091, + 0x5121, 0x7080, 0x6115, 0xA03A, 0x610A, 0x9012, 0x611D, 0xA0D7, 0x510B, 0x6122, 0x610E, 0x9035, + 0x6001, 0x7123, 0x6118, 0xA07A, 0x5111, 0x7009, 0x6114, 0x90F4, 0x6109, 0x8060, 0x611C, 0xA0C4, + 0x510F, 0x7005, 0x6112, 0x9070, 0x6107, 0x800F, 0x611A, 0xA0A1, 0x5121, 0x7102, 0x6116, 0xA056, + 0x610C, 0x901D, 0x611E, 0xA0E8, 0x510B, 0x611F, 0x610D, 0x902C, 0x6000, 0x7106, 0x6117, 0xA071, + 0x5111, 0x7008, 0x6113, 0x90E0, 0x6108, 0x8020, 0x611B, 0xA0BB, 0x510F, 0x7004, 0x6110, 0x9049, + 0x6002, 0x800D, 0x6119, 0xA099, 0x5121, 0x70FF, 0x6115, 0xA04C, 0x610A, 0x9017, 0x611D, 0xA0DF, + 0x510B, 0x6122, 0x610E, 0x903C, 0x6001, 0x7124, 0x6118, 0xA087, 0x5111, 0x700A, 0x6114, 0xA023, + 0x6109, 0x80FE, 0x611C, 0xA0CE, 0x510F, 0x7006, 0x6112, 0x9082, 0x6107, 0x8011, 0x611A, 0xA0A9, + 0x5121, 0x7103, 0x6116, 0xA05F, 0x610C, 0x9022, 0x611E, 0xA0F5, 0x510B, 0x611F, 0x610D, 0x9029, + 0x6000, 0x7105, 0x6117, 0xA06C, 0x5111, 0x7007, 0x6113, 0x90CC, 0x6108, 0x8019, 0x611B, 0xA0B7, + 0x510F, 0x7003, 0x6110, 0x9044, 0x6002, 0x800C, 0x6119, 0xA095, 0x5121, 0x7080, 0x6115, 0xA046, + 0x610A, 0x9015, 0x611D, 0xA0DB, 0x510B, 0x6122, 0x610E, 0x9038, 0x6001, 0x7123, 0x6118, 0xA07E, + 0x5111, 0x7009, 0x6114, 0x90FC, 0x6109, 0x80F0, 0x611C, 0xA0C9, 0x510F, 0x7005, 0x6112, 0x907F, + 0x6107, 0x8010, 0x611A, 0xA0A5, 0x5121, 0x7102, 0x6116, 0xA05B, 0x610C, 0x901F, 0x611E, 0xA0EC, + 0x510B, 0x611F, 0x610D, 0x9031, 0x6000, 0x7106, 0x6117, 0xA075, 0x5111, 0x7008, 0x6113, 0x90F1, + 0x6108, 0x8040, 0x611B, 0xA0BF, 0x510F, 0x7004, 0x6110, 0x9051, 0x6002, 0x800E, 0x6119, 0xA09D, + 0x5121, 0x70FF, 0x6115, 0xA052, 0x610A, 0x901B, 0x611D, 0xA0E4, 0x510B, 0x6122, 0x610E, 0x903F, + 0x6001, 0x7124, 0x6118, 0xA08C, 0x5111, 0x700A, 0x6114, 0xA02F, 0x6109, 0x8120, 0x611C, 0xA0D3, + 0x510F, 0x7006, 0x6112, 0x9088, 0x6107, 0x8013, 0x611A, 0xA0AE, 0x5121, 0x7103, 0x6116, 0xA064, + 0x610C, 0x9025, 0x611E, 0xA0FA, 0x510B, 0x611F, 0x610D, 0x9028, 0x6000, 0x7105, 0x6117, 0xA06A, + 0x5111, 0x7007, 0x6113, 0x90C6, 0x6108, 0x8018, 0x611B, 0xA0B5, 0x510F, 0x7003, 0x6110, 0x9043, + 0x6002, 0x800B, 0x6119, 0xA093, 0x5121, 0x7080, 0x6115, 0xA03D, 0x610A, 0x9014, 0x611D, 0xA0D9, + 0x510B, 0x6122, 0x610E, 0x9037, 0x6001, 0x7123, 0x6118, 0xA07C, 0x5111, 0x7009, 0x6114, 0x90F8, + 0x6109, 0x8060, 0x611C, 0xA0C7, 0x510F, 0x7005, 0x6112, 0x9078, 0x6107, 0x800F, 0x611A, 0xA0A3, + 0x5121, 0x7102, 0x6116, 0xA058, 0x610C, 0x901E, 0x611E, 0xA0EA, 0x510B, 0x611F, 0x610D, 0x9030, + 0x6000, 0x7106, 0x6117, 0xA073, 0x5111, 0x7008, 0x6113, 0x90EE, 0x6108, 0x8020, 0x611B, 0xA0BD, + 0x510F, 0x7004, 0x6110, 0x9050, 0x6002, 0x800D, 0x6119, 0xA09B, 0x5121, 0x70FF, 0x6115, 0xA04E, + 0x610A, 0x901A, 0x611D, 0xA0E2, 0x510B, 0x6122, 0x610E, 0x903E, 0x6001, 0x7124, 0x6118, 0xA08A, + 0x5111, 0x700A, 0x6114, 0xA02D, 0x6109, 0x80FE, 0x611C, 0xA0D1, 0x510F, 0x7006, 0x6112, 0x9084, + 0x6107, 0x8011, 0x611A, 0xA0AC, 0x5121, 0x7103, 0x6116, 0xA062, 0x610C, 0x9024, 0x611E, 0xA0F7, + 0x510B, 0x611F, 0x610D, 0x902A, 0x6000, 0x7105, 0x6117, 0xA06E, 0x5111, 0x7007, 0x6113, 0x90D0, + 0x6108, 0x8019, 0x611B, 0xA0B9, 0x510F, 0x7003, 0x6110, 0x9048, 0x6002, 0x800C, 0x6119, 0xA097, + 0x5121, 0x7080, 0x6115, 0xA04A, 0x610A, 0x9016, 0x611D, 0xA0DD, 0x510B, 0x6122, 0x610E, 0x9039, + 0x6001, 0x7123, 0x6118, 0xA085, 0x5111, 0x7009, 0x6114, 0x90FD, 0x6109, 0x80F0, 0x611C, 0xA0CB, + 0x510F, 0x7005, 0x6112, 0x9081, 0x6107, 0x8010, 0x611A, 0xA0A7, 0x5121, 0x7102, 0x6116, 0xA05D, + 0x610C, 0x9021, 0x611E, 0xA0EF, 0x510B, 0x611F, 0x610D, 0x9033, 0x6000, 0x7106, 0x6117, 0xA077, + 0x5111, 0x7008, 0x6113, 0x90F2, 0x6108, 0x8040, 0x611B, 0xA0C2, 0x510F, 0x7004, 0x6110, 0x9059, + 0x6002, 0x800E, 0x6119, 0xA09F, 0x5121, 0x70FF, 0x6115, 0xA054, 0x610A, 0x901C, 0x611D, 0xA0E6, + 0x510B, 0x6122, 0x610E, 0x9041, 0x6001, 0x7124, 0x6118, 0xA08E, 0x5111, 0x700A, 0x6114, 0xA034, + 0x6109, 0x8120, 0x611C, 0xA0D5, 0x510F, 0x7006, 0x6112, 0x9090, 0x6107, 0x8013, 0x611A, 0xA0B0, + 0x5121, 0x7103, 0x6116, 0xA066, 0x610C, 0x9026, 0x611E, 0xA104, 0x510B, 0x611F, 0x610D, 0x9027, + 0x6000, 0x7105, 0x6117, 0xA069, 0x5111, 0x7007, 0x6113, 0x90C0, 0x6108, 0x8018, 0x611B, 0xA0B4, + 0x510F, 0x7003, 0x6110, 0x9042, 0x6002, 0x800B, 0x6119, 0xA092, 0x5121, 0x7080, 0x6115, 0xA03B, + 0x610A, 0x9012, 0x611D, 0xA0D8, 0x510B, 0x6122, 0x610E, 0x9035, 0x6001, 0x7123, 0x6118, 0xA07B, + 0x5111, 0x7009, 0x6114, 0x90F4, 0x6109, 0x8060, 0x611C, 0xA0C5, 0x510F, 0x7005, 0x6112, 0x9070, + 0x6107, 0x800F, 0x611A, 0xA0A2, 0x5121, 0x7102, 0x6116, 0xA057, 0x610C, 0x901D, 0x611E, 0xA0E9, + 0x510B, 0x611F, 0x610D, 0x902C, 0x6000, 0x7106, 0x6117, 0xA072, 0x5111, 0x7008, 0x6113, 0x90E0, + 0x6108, 0x8020, 0x611B, 0xA0BC, 0x510F, 0x7004, 0x6110, 0x9049, 0x6002, 0x800D, 0x6119, 0xA09A, + 0x5121, 0x70FF, 0x6115, 0xA04D, 0x610A, 0x9017, 0x611D, 0xA0E1, 0x510B, 0x6122, 0x610E, 0x903C, + 0x6001, 0x7124, 0x6118, 0xA089, 0x5111, 0x700A, 0x6114, 0xA02B, 0x6109, 0x80FE, 0x611C, 0xA0CF, + 0x510F, 0x7006, 0x6112, 0x9082, 0x6107, 0x8011, 0x611A, 0xA0AA, 0x5121, 0x7103, 0x6116, 0xA061, + 0x610C, 0x9022, 0x611E, 0xA0F6, 0x510B, 0x611F, 0x610D, 0x9029, 0x6000, 0x7105, 0x6117, 0xA06D, + 0x5111, 0x7007, 0x6113, 0x90CC, 0x6108, 0x8019, 0x611B, 0xA0B8, 0x510F, 0x7003, 0x6110, 0x9044, + 0x6002, 0x800C, 0x6119, 0xA096, 0x5121, 0x7080, 0x6115, 0xA047, 0x610A, 0x9015, 0x611D, 0xA0DC, + 0x510B, 0x6122, 0x610E, 0x9038, 0x6001, 0x7123, 0x6118, 0xA083, 0x5111, 0x7009, 0x6114, 0x90FC, + 0x6109, 0x80F0, 0x611C, 0xA0CA, 0x510F, 0x7005, 0x6112, 0x907F, 0x6107, 0x8010, 0x611A, 0xA0A6, + 0x5121, 0x7102, 0x6116, 0xA05C, 0x610C, 0x901F, 0x611E, 0xA0ED, 0x510B, 0x611F, 0x610D, 0x9031, + 0x6000, 0x7106, 0x6117, 0xA076, 0x5111, 0x7008, 0x6113, 0x90F1, 0x6108, 0x8040, 0x611B, 0xA0C1, + 0x510F, 0x7004, 0x6110, 0x9051, 0x6002, 0x800E, 0x6119, 0xA09E, 0x5121, 0x70FF, 0x6115, 0xA053, + 0x610A, 0x901B, 0x611D, 0xA0E5, 0x510B, 0x6122, 0x610E, 0x903F, 0x6001, 0x7124, 0x6118, 0xA08D, + 0x5111, 0x700A, 0x6114, 0xA032, 0x6109, 0x8120, 0x611C, 0xA0D4, 0x510F, 0x7006, 0x6112, 0x9088, + 0x6107, 0x8013, 0x611A, 0xA0AF, 0x5121, 0x7103, 0x6116, 0xA065, 0x610C, 0x9025, 0x611E, 0xA0FB, + 0x510B, 0x611F, 0x610D, 0x9028, 0x6000, 0x7105, 0x6117, 0xA06B, 0x5111, 0x7007, 0x6113, 0x90C6, + 0x6108, 0x8018, 0x611B, 0xA0B6, 0x510F, 0x7003, 0x6110, 0x9043, 0x6002, 0x800B, 0x6119, 0xA094, + 0x5121, 0x7080, 0x6115, 0xA045, 0x610A, 0x9014, 0x611D, 0xA0DA, 0x510B, 0x6122, 0x610E, 0x9037, + 0x6001, 0x7123, 0x6118, 0xA07D, 0x5111, 0x7009, 0x6114, 0x90F8, 0x6109, 0x8060, 0x611C, 0xA0C8, + 0x510F, 0x7005, 0x6112, 0x9078, 0x6107, 0x800F, 0x611A, 0xA0A4, 0x5121, 0x7102, 0x6116, 0xA05A, + 0x610C, 0x901E, 0x611E, 0xA0EB, 0x510B, 0x611F, 0x610D, 0x9030, 0x6000, 0x7106, 0x6117, 0xA074, + 0x5111, 0x7008, 0x6113, 0x90EE, 0x6108, 0x8020, 0x611B, 0xA0BE, 0x510F, 0x7004, 0x6110, 0x9050, + 0x6002, 0x800D, 0x6119, 0xA09C, 0x5121, 0x70FF, 0x6115, 0xA04F, 0x610A, 0x901A, 0x611D, 0xA0E3, + 0x510B, 0x6122, 0x610E, 0x903E, 0x6001, 0x7124, 0x6118, 0xA08B, 0x5111, 0x700A, 0x6114, 0xA02E, + 0x6109, 0x80FE, 0x611C, 0xA0D2, 0x510F, 0x7006, 0x6112, 0x9084, 0x6107, 0x8011, 0x611A, 0xA0AD, + 0x5121, 0x7103, 0x6116, 0xA063, 0x610C, 0x9024, 0x611E, 0xA0F9, 0x510B, 0x611F, 0x610D, 0x902A, + 0x6000, 0x7105, 0x6117, 0xA06F, 0x5111, 0x7007, 0x6113, 0x90D0, 0x6108, 0x8019, 0x611B, 0xA0BA, + 0x510F, 0x7003, 0x6110, 0x9048, 0x6002, 0x800C, 0x6119, 0xA098, 0x5121, 0x7080, 0x6115, 0xA04B, + 0x610A, 0x9016, 0x611D, 0xA0DE, 0x510B, 0x6122, 0x610E, 0x9039, 0x6001, 0x7123, 0x6118, 0xA086, + 0x5111, 0x7009, 0x6114, 0x90FD, 0x6109, 0x80F0, 0x611C, 0xA0CD, 0x510F, 0x7005, 0x6112, 0x9081, + 0x6107, 0x8010, 0x611A, 0xA0A8, 0x5121, 0x7102, 0x6116, 0xA05E, 0x610C, 0x9021, 0x611E, 0xA0F3, + 0x510B, 0x611F, 0x610D, 0x9033, 0x6000, 0x7106, 0x6117, 0xA079, 0x5111, 0x7008, 0x6113, 0x90F2, + 0x6108, 0x8040, 0x611B, 0xA0C3, 0x510F, 0x7004, 0x6110, 0x9059, 0x6002, 0x800E, 0x6119, 0xA0A0, + 0x5121, 0x70FF, 0x6115, 0xA055, 0x610A, 0x901C, 0x611D, 0xA0E7, 0x510B, 0x6122, 0x610E, 0x9041, + 0x6001, 0x7124, 0x6118, 0xA08F, 0x5111, 0x700A, 0x6114, 0xA036, 0x6109, 0x8120, 0x611C, 0xA0D6, + 0x510F, 0x7006, 0x6112, 0x9090, 0x6107, 0x8013, 0x611A, 0xA0B1, 0x5121, 0x7103, 0x6116, 0xA067, + 0x610C, 0x9026, 0x611E, 0xB0B2, 0x510B, 0x611F, 0x610D, 0x9027, 0x6000, 0x7105, 0x6117, 0xA068, + 0x5111, 0x7007, 0x6113, 0x90C0, 0x6108, 0x8018, 0x611B, 0xA0B3, 0x510F, 0x7003, 0x6110, 0x9042, + 0x6002, 0x800B, 0x6119, 0xA091, 0x5121, 0x7080, 0x6115, 0xA03A, 0x610A, 0x9012, 0x611D, 0xA0D7, + 0x510B, 0x6122, 0x610E, 0x9035, 0x6001, 0x7123, 0x6118, 0xA07A, 0x5111, 0x7009, 0x6114, 0x90F4, + 0x6109, 0x8060, 0x611C, 0xA0C4, 0x510F, 0x7005, 0x6112, 0x9070, 0x6107, 0x800F, 0x611A, 0xA0A1, + 0x5121, 0x7102, 0x6116, 0xA056, 0x610C, 0x901D, 0x611E, 0xA0E8, 0x510B, 0x611F, 0x610D, 0x902C, + 0x6000, 0x7106, 0x6117, 0xA071, 0x5111, 0x7008, 0x6113, 0x90E0, 0x6108, 0x8020, 0x611B, 0xA0BB, + 0x510F, 0x7004, 0x6110, 0x9049, 0x6002, 0x800D, 0x6119, 0xA099, 0x5121, 0x70FF, 0x6115, 0xA04C, + 0x610A, 0x9017, 0x611D, 0xA0DF, 0x510B, 0x6122, 0x610E, 0x903C, 0x6001, 0x7124, 0x6118, 0xA087, + 0x5111, 0x700A, 0x6114, 0xA023, 0x6109, 0x80FE, 0x611C, 0xA0CE, 0x510F, 0x7006, 0x6112, 0x9082, + 0x6107, 0x8011, 0x611A, 0xA0A9, 0x5121, 0x7103, 0x6116, 0xA05F, 0x610C, 0x9022, 0x611E, 0xA0F5, + 0x510B, 0x611F, 0x610D, 0x9029, 0x6000, 0x7105, 0x6117, 0xA06C, 0x5111, 0x7007, 0x6113, 0x90CC, + 0x6108, 0x8019, 0x611B, 0xA0B7, 0x510F, 0x7003, 0x6110, 0x9044, 0x6002, 0x800C, 0x6119, 0xA095, + 0x5121, 0x7080, 0x6115, 0xA046, 0x610A, 0x9015, 0x611D, 0xA0DB, 0x510B, 0x6122, 0x610E, 0x9038, + 0x6001, 0x7123, 0x6118, 0xA07E, 0x5111, 0x7009, 0x6114, 0x90FC, 0x6109, 0x80F0, 0x611C, 0xA0C9, + 0x510F, 0x7005, 0x6112, 0x907F, 0x6107, 0x8010, 0x611A, 0xA0A5, 0x5121, 0x7102, 0x6116, 0xA05B, + 0x610C, 0x901F, 0x611E, 0xA0EC, 0x510B, 0x611F, 0x610D, 0x9031, 0x6000, 0x7106, 0x6117, 0xA075, + 0x5111, 0x7008, 0x6113, 0x90F1, 0x6108, 0x8040, 0x611B, 0xA0BF, 0x510F, 0x7004, 0x6110, 0x9051, + 0x6002, 0x800E, 0x6119, 0xA09D, 0x5121, 0x70FF, 0x6115, 0xA052, 0x610A, 0x901B, 0x611D, 0xA0E4, + 0x510B, 0x6122, 0x610E, 0x903F, 0x6001, 0x7124, 0x6118, 0xA08C, 0x5111, 0x700A, 0x6114, 0xA02F, + 0x6109, 0x8120, 0x611C, 0xA0D3, 0x510F, 0x7006, 0x6112, 0x9088, 0x6107, 0x8013, 0x611A, 0xA0AE, + 0x5121, 0x7103, 0x6116, 0xA064, 0x610C, 0x9025, 0x611E, 0xA0FA, 0x510B, 0x611F, 0x610D, 0x9028, + 0x6000, 0x7105, 0x6117, 0xA06A, 0x5111, 0x7007, 0x6113, 0x90C6, 0x6108, 0x8018, 0x611B, 0xA0B5, + 0x510F, 0x7003, 0x6110, 0x9043, 0x6002, 0x800B, 0x6119, 0xA093, 0x5121, 0x7080, 0x6115, 0xA03D, + 0x610A, 0x9014, 0x611D, 0xA0D9, 0x510B, 0x6122, 0x610E, 0x9037, 0x6001, 0x7123, 0x6118, 0xA07C, + 0x5111, 0x7009, 0x6114, 0x90F8, 0x6109, 0x8060, 0x611C, 0xA0C7, 0x510F, 0x7005, 0x6112, 0x9078, + 0x6107, 0x800F, 0x611A, 0xA0A3, 0x5121, 0x7102, 0x6116, 0xA058, 0x610C, 0x901E, 0x611E, 0xA0EA, + 0x510B, 0x611F, 0x610D, 0x9030, 0x6000, 0x7106, 0x6117, 0xA073, 0x5111, 0x7008, 0x6113, 0x90EE, + 0x6108, 0x8020, 0x611B, 0xA0BD, 0x510F, 0x7004, 0x6110, 0x9050, 0x6002, 0x800D, 0x6119, 0xA09B, + 0x5121, 0x70FF, 0x6115, 0xA04E, 0x610A, 0x901A, 0x611D, 0xA0E2, 0x510B, 0x6122, 0x610E, 0x903E, + 0x6001, 0x7124, 0x6118, 0xA08A, 0x5111, 0x700A, 0x6114, 0xA02D, 0x6109, 0x80FE, 0x611C, 0xA0D1, + 0x510F, 0x7006, 0x6112, 0x9084, 0x6107, 0x8011, 0x611A, 0xA0AC, 0x5121, 0x7103, 0x6116, 0xA062, + 0x610C, 0x9024, 0x611E, 0xA0F7, 0x510B, 0x611F, 0x610D, 0x902A, 0x6000, 0x7105, 0x6117, 0xA06E, + 0x5111, 0x7007, 0x6113, 0x90D0, 0x6108, 0x8019, 0x611B, 0xA0B9, 0x510F, 0x7003, 0x6110, 0x9048, + 0x6002, 0x800C, 0x6119, 0xA097, 0x5121, 0x7080, 0x6115, 0xA04A, 0x610A, 0x9016, 0x611D, 0xA0DD, + 0x510B, 0x6122, 0x610E, 0x9039, 0x6001, 0x7123, 0x6118, 0xA085, 0x5111, 0x7009, 0x6114, 0x90FD, + 0x6109, 0x80F0, 0x611C, 0xA0CB, 0x510F, 0x7005, 0x6112, 0x9081, 0x6107, 0x8010, 0x611A, 0xA0A7, + 0x5121, 0x7102, 0x6116, 0xA05D, 0x610C, 0x9021, 0x611E, 0xA0EF, 0x510B, 0x611F, 0x610D, 0x9033, + 0x6000, 0x7106, 0x6117, 0xA077, 0x5111, 0x7008, 0x6113, 0x90F2, 0x6108, 0x8040, 0x611B, 0xA0C2, + 0x510F, 0x7004, 0x6110, 0x9059, 0x6002, 0x800E, 0x6119, 0xA09F, 0x5121, 0x70FF, 0x6115, 0xA054, + 0x610A, 0x901C, 0x611D, 0xA0E6, 0x510B, 0x6122, 0x610E, 0x9041, 0x6001, 0x7124, 0x6118, 0xA08E, + 0x5111, 0x700A, 0x6114, 0xA034, 0x6109, 0x8120, 0x611C, 0xA0D5, 0x510F, 0x7006, 0x6112, 0x9090, + 0x6107, 0x8013, 0x611A, 0xA0B0, 0x5121, 0x7103, 0x6116, 0xA066, 0x610C, 0x9026, 0x611E, 0xA104, + 0x510B, 0x611F, 0x610D, 0x9027, 0x6000, 0x7105, 0x6117, 0xA069, 0x5111, 0x7007, 0x6113, 0x90C0, + 0x6108, 0x8018, 0x611B, 0xA0B4, 0x510F, 0x7003, 0x6110, 0x9042, 0x6002, 0x800B, 0x6119, 0xA092, + 0x5121, 0x7080, 0x6115, 0xA03B, 0x610A, 0x9012, 0x611D, 0xA0D8, 0x510B, 0x6122, 0x610E, 0x9035, + 0x6001, 0x7123, 0x6118, 0xA07B, 0x5111, 0x7009, 0x6114, 0x90F4, 0x6109, 0x8060, 0x611C, 0xA0C5, + 0x510F, 0x7005, 0x6112, 0x9070, 0x6107, 0x800F, 0x611A, 0xA0A2, 0x5121, 0x7102, 0x6116, 0xA057, + 0x610C, 0x901D, 0x611E, 0xA0E9, 0x510B, 0x611F, 0x610D, 0x902C, 0x6000, 0x7106, 0x6117, 0xA072, + 0x5111, 0x7008, 0x6113, 0x90E0, 0x6108, 0x8020, 0x611B, 0xA0BC, 0x510F, 0x7004, 0x6110, 0x9049, + 0x6002, 0x800D, 0x6119, 0xA09A, 0x5121, 0x70FF, 0x6115, 0xA04D, 0x610A, 0x9017, 0x611D, 0xA0E1, + 0x510B, 0x6122, 0x610E, 0x903C, 0x6001, 0x7124, 0x6118, 0xA089, 0x5111, 0x700A, 0x6114, 0xA02B, + 0x6109, 0x80FE, 0x611C, 0xA0CF, 0x510F, 0x7006, 0x6112, 0x9082, 0x6107, 0x8011, 0x611A, 0xA0AA, + 0x5121, 0x7103, 0x6116, 0xA061, 0x610C, 0x9022, 0x611E, 0xA0F6, 0x510B, 0x611F, 0x610D, 0x9029, + 0x6000, 0x7105, 0x6117, 0xA06D, 0x5111, 0x7007, 0x6113, 0x90CC, 0x6108, 0x8019, 0x611B, 0xA0B8, + 0x510F, 0x7003, 0x6110, 0x9044, 0x6002, 0x800C, 0x6119, 0xA096, 0x5121, 0x7080, 0x6115, 0xA047, + 0x610A, 0x9015, 0x611D, 0xA0DC, 0x510B, 0x6122, 0x610E, 0x9038, 0x6001, 0x7123, 0x6118, 0xA083, + 0x5111, 0x7009, 0x6114, 0x90FC, 0x6109, 0x80F0, 0x611C, 0xA0CA, 0x510F, 0x7005, 0x6112, 0x907F, + 0x6107, 0x8010, 0x611A, 0xA0A6, 0x5121, 0x7102, 0x6116, 0xA05C, 0x610C, 0x901F, 0x611E, 0xA0ED, + 0x510B, 0x611F, 0x610D, 0x9031, 0x6000, 0x7106, 0x6117, 0xA076, 0x5111, 0x7008, 0x6113, 0x90F1, + 0x6108, 0x8040, 0x611B, 0xA0C1, 0x510F, 0x7004, 0x6110, 0x9051, 0x6002, 0x800E, 0x6119, 0xA09E, + 0x5121, 0x70FF, 0x6115, 0xA053, 0x610A, 0x901B, 0x611D, 0xA0E5, 0x510B, 0x6122, 0x610E, 0x903F, + 0x6001, 0x7124, 0x6118, 0xA08D, 0x5111, 0x700A, 0x6114, 0xA032, 0x6109, 0x8120, 0x611C, 0xA0D4, + 0x510F, 0x7006, 0x6112, 0x9088, 0x6107, 0x8013, 0x611A, 0xA0AF, 0x5121, 0x7103, 0x6116, 0xA065, + 0x610C, 0x9025, 0x611E, 0xA0FB, 0x510B, 0x611F, 0x610D, 0x9028, 0x6000, 0x7105, 0x6117, 0xA06B, + 0x5111, 0x7007, 0x6113, 0x90C6, 0x6108, 0x8018, 0x611B, 0xA0B6, 0x510F, 0x7003, 0x6110, 0x9043, + 0x6002, 0x800B, 0x6119, 0xA094, 0x5121, 0x7080, 0x6115, 0xA045, 0x610A, 0x9014, 0x611D, 0xA0DA, + 0x510B, 0x6122, 0x610E, 0x9037, 0x6001, 0x7123, 0x6118, 0xA07D, 0x5111, 0x7009, 0x6114, 0x90F8, + 0x6109, 0x8060, 0x611C, 0xA0C8, 0x510F, 0x7005, 0x6112, 0x9078, 0x6107, 0x800F, 0x611A, 0xA0A4, + 0x5121, 0x7102, 0x6116, 0xA05A, 0x610C, 0x901E, 0x611E, 0xA0EB, 0x510B, 0x611F, 0x610D, 0x9030, + 0x6000, 0x7106, 0x6117, 0xA074, 0x5111, 0x7008, 0x6113, 0x90EE, 0x6108, 0x8020, 0x611B, 0xA0BE, + 0x510F, 0x7004, 0x6110, 0x9050, 0x6002, 0x800D, 0x6119, 0xA09C, 0x5121, 0x70FF, 0x6115, 0xA04F, + 0x610A, 0x901A, 0x611D, 0xA0E3, 0x510B, 0x6122, 0x610E, 0x903E, 0x6001, 0x7124, 0x6118, 0xA08B, + 0x5111, 0x700A, 0x6114, 0xA02E, 0x6109, 0x80FE, 0x611C, 0xA0D2, 0x510F, 0x7006, 0x6112, 0x9084, + 0x6107, 0x8011, 0x611A, 0xA0AD, 0x5121, 0x7103, 0x6116, 0xA063, 0x610C, 0x9024, 0x611E, 0xA0F9, + 0x510B, 0x611F, 0x610D, 0x902A, 0x6000, 0x7105, 0x6117, 0xA06F, 0x5111, 0x7007, 0x6113, 0x90D0, + 0x6108, 0x8019, 0x611B, 0xA0BA, 0x510F, 0x7003, 0x6110, 0x9048, 0x6002, 0x800C, 0x6119, 0xA098, + 0x5121, 0x7080, 0x6115, 0xA04B, 0x610A, 0x9016, 0x611D, 0xA0DE, 0x510B, 0x6122, 0x610E, 0x9039, + 0x6001, 0x7123, 0x6118, 0xA086, 0x5111, 0x7009, 0x6114, 0x90FD, 0x6109, 0x80F0, 0x611C, 0xA0CD, + 0x510F, 0x7005, 0x6112, 0x9081, 0x6107, 0x8010, 0x611A, 0xA0A8, 0x5121, 0x7102, 0x6116, 0xA05E, + 0x610C, 0x9021, 0x611E, 0xA0F3, 0x510B, 0x611F, 0x610D, 0x9033, 0x6000, 0x7106, 0x6117, 0xA079, + 0x5111, 0x7008, 0x6113, 0x90F2, 0x6108, 0x8040, 0x611B, 0xA0C3, 0x510F, 0x7004, 0x6110, 0x9059, + 0x6002, 0x800E, 0x6119, 0xA0A0, 0x5121, 0x70FF, 0x6115, 0xA055, 0x610A, 0x901C, 0x611D, 0xA0E7, + 0x510B, 0x6122, 0x610E, 0x9041, 0x6001, 0x7124, 0x6118, 0xA08F, 0x5111, 0x700A, 0x6114, 0xA036, + 0x6109, 0x8120, 0x611C, 0xA0D6, 0x510F, 0x7006, 0x6112, 0x9090, 0x6107, 0x8013, 0x611A, 0xA0B1, + 0x5121, 0x7103, 0x6116, 0xA067, 0x610C, 0x9026, 0x611E, 0xD0AB, 0x510B, 0x611F, 0x610D, 0x9027, + 0x6000, 0x7105, 0x6117, 0xA068, 0x5111, 0x7007, 0x6113, 0x90C0, 0x6108, 0x8018, 0x611B, 0xA0B3, + 0x510F, 0x7003, 0x6110, 0x9042, 0x6002, 0x800B, 0x6119, 0xA091, 0x5121, 0x7080, 0x6115, 0xA03A, + 0x610A, 0x9012, 0x611D, 0xA0D7, 0x510B, 0x6122, 0x610E, 0x9035, 0x6001, 0x7123, 0x6118, 0xA07A, + 0x5111, 0x7009, 0x6114, 0x90F4, 0x6109, 0x8060, 0x611C, 0xA0C4, 0x510F, 0x7005, 0x6112, 0x9070, + 0x6107, 0x800F, 0x611A, 0xA0A1, 0x5121, 0x7102, 0x6116, 0xA056, 0x610C, 0x901D, 0x611E, 0xA0E8, + 0x510B, 0x611F, 0x610D, 0x902C, 0x6000, 0x7106, 0x6117, 0xA071, 0x5111, 0x7008, 0x6113, 0x90E0, + 0x6108, 0x8020, 0x611B, 0xA0BB, 0x510F, 0x7004, 0x6110, 0x9049, 0x6002, 0x800D, 0x6119, 0xA099, + 0x5121, 0x70FF, 0x6115, 0xA04C, 0x610A, 0x9017, 0x611D, 0xA0DF, 0x510B, 0x6122, 0x610E, 0x903C, + 0x6001, 0x7124, 0x6118, 0xA087, 0x5111, 0x700A, 0x6114, 0xA023, 0x6109, 0x80FE, 0x611C, 0xA0CE, + 0x510F, 0x7006, 0x6112, 0x9082, 0x6107, 0x8011, 0x611A, 0xA0A9, 0x5121, 0x7103, 0x6116, 0xA05F, + 0x610C, 0x9022, 0x611E, 0xA0F5, 0x510B, 0x611F, 0x610D, 0x9029, 0x6000, 0x7105, 0x6117, 0xA06C, + 0x5111, 0x7007, 0x6113, 0x90CC, 0x6108, 0x8019, 0x611B, 0xA0B7, 0x510F, 0x7003, 0x6110, 0x9044, + 0x6002, 0x800C, 0x6119, 0xA095, 0x5121, 0x7080, 0x6115, 0xA046, 0x610A, 0x9015, 0x611D, 0xA0DB, + 0x510B, 0x6122, 0x610E, 0x9038, 0x6001, 0x7123, 0x6118, 0xA07E, 0x5111, 0x7009, 0x6114, 0x90FC, + 0x6109, 0x80F0, 0x611C, 0xA0C9, 0x510F, 0x7005, 0x6112, 0x907F, 0x6107, 0x8010, 0x611A, 0xA0A5, + 0x5121, 0x7102, 0x6116, 0xA05B, 0x610C, 0x901F, 0x611E, 0xA0EC, 0x510B, 0x611F, 0x610D, 0x9031, + 0x6000, 0x7106, 0x6117, 0xA075, 0x5111, 0x7008, 0x6113, 0x90F1, 0x6108, 0x8040, 0x611B, 0xA0BF, + 0x510F, 0x7004, 0x6110, 0x9051, 0x6002, 0x800E, 0x6119, 0xA09D, 0x5121, 0x70FF, 0x6115, 0xA052, + 0x610A, 0x901B, 0x611D, 0xA0E4, 0x510B, 0x6122, 0x610E, 0x903F, 0x6001, 0x7124, 0x6118, 0xA08C, + 0x5111, 0x700A, 0x6114, 0xA02F, 0x6109, 0x8120, 0x611C, 0xA0D3, 0x510F, 0x7006, 0x6112, 0x9088, + 0x6107, 0x8013, 0x611A, 0xA0AE, 0x5121, 0x7103, 0x6116, 0xA064, 0x610C, 0x9025, 0x611E, 0xA0FA, + 0x510B, 0x611F, 0x610D, 0x9028, 0x6000, 0x7105, 0x6117, 0xA06A, 0x5111, 0x7007, 0x6113, 0x90C6, + 0x6108, 0x8018, 0x611B, 0xA0B5, 0x510F, 0x7003, 0x6110, 0x9043, 0x6002, 0x800B, 0x6119, 0xA093, + 0x5121, 0x7080, 0x6115, 0xA03D, 0x610A, 0x9014, 0x611D, 0xA0D9, 0x510B, 0x6122, 0x610E, 0x9037, + 0x6001, 0x7123, 0x6118, 0xA07C, 0x5111, 0x7009, 0x6114, 0x90F8, 0x6109, 0x8060, 0x611C, 0xA0C7, + 0x510F, 0x7005, 0x6112, 0x9078, 0x6107, 0x800F, 0x611A, 0xA0A3, 0x5121, 0x7102, 0x6116, 0xA058, + 0x610C, 0x901E, 0x611E, 0xA0EA, 0x510B, 0x611F, 0x610D, 0x9030, 0x6000, 0x7106, 0x6117, 0xA073, + 0x5111, 0x7008, 0x6113, 0x90EE, 0x6108, 0x8020, 0x611B, 0xA0BD, 0x510F, 0x7004, 0x6110, 0x9050, + 0x6002, 0x800D, 0x6119, 0xA09B, 0x5121, 0x70FF, 0x6115, 0xA04E, 0x610A, 0x901A, 0x611D, 0xA0E2, + 0x510B, 0x6122, 0x610E, 0x903E, 0x6001, 0x7124, 0x6118, 0xA08A, 0x5111, 0x700A, 0x6114, 0xA02D, + 0x6109, 0x80FE, 0x611C, 0xA0D1, 0x510F, 0x7006, 0x6112, 0x9084, 0x6107, 0x8011, 0x611A, 0xA0AC, + 0x5121, 0x7103, 0x6116, 0xA062, 0x610C, 0x9024, 0x611E, 0xA0F7, 0x510B, 0x611F, 0x610D, 0x902A, + 0x6000, 0x7105, 0x6117, 0xA06E, 0x5111, 0x7007, 0x6113, 0x90D0, 0x6108, 0x8019, 0x611B, 0xA0B9, + 0x510F, 0x7003, 0x6110, 0x9048, 0x6002, 0x800C, 0x6119, 0xA097, 0x5121, 0x7080, 0x6115, 0xA04A, + 0x610A, 0x9016, 0x611D, 0xA0DD, 0x510B, 0x6122, 0x610E, 0x9039, 0x6001, 0x7123, 0x6118, 0xA085, + 0x5111, 0x7009, 0x6114, 0x90FD, 0x6109, 0x80F0, 0x611C, 0xA0CB, 0x510F, 0x7005, 0x6112, 0x9081, + 0x6107, 0x8010, 0x611A, 0xA0A7, 0x5121, 0x7102, 0x6116, 0xA05D, 0x610C, 0x9021, 0x611E, 0xA0EF, + 0x510B, 0x611F, 0x610D, 0x9033, 0x6000, 0x7106, 0x6117, 0xA077, 0x5111, 0x7008, 0x6113, 0x90F2, + 0x6108, 0x8040, 0x611B, 0xA0C2, 0x510F, 0x7004, 0x6110, 0x9059, 0x6002, 0x800E, 0x6119, 0xA09F, + 0x5121, 0x70FF, 0x6115, 0xA054, 0x610A, 0x901C, 0x611D, 0xA0E6, 0x510B, 0x6122, 0x610E, 0x9041, + 0x6001, 0x7124, 0x6118, 0xA08E, 0x5111, 0x700A, 0x6114, 0xA034, 0x6109, 0x8120, 0x611C, 0xA0D5, + 0x510F, 0x7006, 0x6112, 0x9090, 0x6107, 0x8013, 0x611A, 0xA0B0, 0x5121, 0x7103, 0x6116, 0xA066, + 0x610C, 0x9026, 0x611E, 0xA104, 0x510B, 0x611F, 0x610D, 0x9027, 0x6000, 0x7105, 0x6117, 0xA069, + 0x5111, 0x7007, 0x6113, 0x90C0, 0x6108, 0x8018, 0x611B, 0xA0B4, 0x510F, 0x7003, 0x6110, 0x9042, + 0x6002, 0x800B, 0x6119, 0xA092, 0x5121, 0x7080, 0x6115, 0xA03B, 0x610A, 0x9012, 0x611D, 0xA0D8, + 0x510B, 0x6122, 0x610E, 0x9035, 0x6001, 0x7123, 0x6118, 0xA07B, 0x5111, 0x7009, 0x6114, 0x90F4, + 0x6109, 0x8060, 0x611C, 0xA0C5, 0x510F, 0x7005, 0x6112, 0x9070, 0x6107, 0x800F, 0x611A, 0xA0A2, + 0x5121, 0x7102, 0x6116, 0xA057, 0x610C, 0x901D, 0x611E, 0xA0E9, 0x510B, 0x611F, 0x610D, 0x902C, + 0x6000, 0x7106, 0x6117, 0xA072, 0x5111, 0x7008, 0x6113, 0x90E0, 0x6108, 0x8020, 0x611B, 0xA0BC, + 0x510F, 0x7004, 0x6110, 0x9049, 0x6002, 0x800D, 0x6119, 0xA09A, 0x5121, 0x70FF, 0x6115, 0xA04D, + 0x610A, 0x9017, 0x611D, 0xA0E1, 0x510B, 0x6122, 0x610E, 0x903C, 0x6001, 0x7124, 0x6118, 0xA089, + 0x5111, 0x700A, 0x6114, 0xA02B, 0x6109, 0x80FE, 0x611C, 0xA0CF, 0x510F, 0x7006, 0x6112, 0x9082, + 0x6107, 0x8011, 0x611A, 0xA0AA, 0x5121, 0x7103, 0x6116, 0xA061, 0x610C, 0x9022, 0x611E, 0xA0F6, + 0x510B, 0x611F, 0x610D, 0x9029, 0x6000, 0x7105, 0x6117, 0xA06D, 0x5111, 0x7007, 0x6113, 0x90CC, + 0x6108, 0x8019, 0x611B, 0xA0B8, 0x510F, 0x7003, 0x6110, 0x9044, 0x6002, 0x800C, 0x6119, 0xA096, + 0x5121, 0x7080, 0x6115, 0xA047, 0x610A, 0x9015, 0x611D, 0xA0DC, 0x510B, 0x6122, 0x610E, 0x9038, + 0x6001, 0x7123, 0x6118, 0xA083, 0x5111, 0x7009, 0x6114, 0x90FC, 0x6109, 0x80F0, 0x611C, 0xA0CA, + 0x510F, 0x7005, 0x6112, 0x907F, 0x6107, 0x8010, 0x611A, 0xA0A6, 0x5121, 0x7102, 0x6116, 0xA05C, + 0x610C, 0x901F, 0x611E, 0xA0ED, 0x510B, 0x611F, 0x610D, 0x9031, 0x6000, 0x7106, 0x6117, 0xA076, + 0x5111, 0x7008, 0x6113, 0x90F1, 0x6108, 0x8040, 0x611B, 0xA0C1, 0x510F, 0x7004, 0x6110, 0x9051, + 0x6002, 0x800E, 0x6119, 0xA09E, 0x5121, 0x70FF, 0x6115, 0xA053, 0x610A, 0x901B, 0x611D, 0xA0E5, + 0x510B, 0x6122, 0x610E, 0x903F, 0x6001, 0x7124, 0x6118, 0xA08D, 0x5111, 0x700A, 0x6114, 0xA032, + 0x6109, 0x8120, 0x611C, 0xA0D4, 0x510F, 0x7006, 0x6112, 0x9088, 0x6107, 0x8013, 0x611A, 0xA0AF, + 0x5121, 0x7103, 0x6116, 0xA065, 0x610C, 0x9025, 0x611E, 0xA0FB, 0x510B, 0x611F, 0x610D, 0x9028, + 0x6000, 0x7105, 0x6117, 0xA06B, 0x5111, 0x7007, 0x6113, 0x90C6, 0x6108, 0x8018, 0x611B, 0xA0B6, + 0x510F, 0x7003, 0x6110, 0x9043, 0x6002, 0x800B, 0x6119, 0xA094, 0x5121, 0x7080, 0x6115, 0xA045, + 0x610A, 0x9014, 0x611D, 0xA0DA, 0x510B, 0x6122, 0x610E, 0x9037, 0x6001, 0x7123, 0x6118, 0xA07D, + 0x5111, 0x7009, 0x6114, 0x90F8, 0x6109, 0x8060, 0x611C, 0xA0C8, 0x510F, 0x7005, 0x6112, 0x9078, + 0x6107, 0x800F, 0x611A, 0xA0A4, 0x5121, 0x7102, 0x6116, 0xA05A, 0x610C, 0x901E, 0x611E, 0xA0EB, + 0x510B, 0x611F, 0x610D, 0x9030, 0x6000, 0x7106, 0x6117, 0xA074, 0x5111, 0x7008, 0x6113, 0x90EE, + 0x6108, 0x8020, 0x611B, 0xA0BE, 0x510F, 0x7004, 0x6110, 0x9050, 0x6002, 0x800D, 0x6119, 0xA09C, + 0x5121, 0x70FF, 0x6115, 0xA04F, 0x610A, 0x901A, 0x611D, 0xA0E3, 0x510B, 0x6122, 0x610E, 0x903E, + 0x6001, 0x7124, 0x6118, 0xA08B, 0x5111, 0x700A, 0x6114, 0xA02E, 0x6109, 0x80FE, 0x611C, 0xA0D2, + 0x510F, 0x7006, 0x6112, 0x9084, 0x6107, 0x8011, 0x611A, 0xA0AD, 0x5121, 0x7103, 0x6116, 0xA063, + 0x610C, 0x9024, 0x611E, 0xA0F9, 0x510B, 0x611F, 0x610D, 0x902A, 0x6000, 0x7105, 0x6117, 0xA06F, + 0x5111, 0x7007, 0x6113, 0x90D0, 0x6108, 0x8019, 0x611B, 0xA0BA, 0x510F, 0x7003, 0x6110, 0x9048, + 0x6002, 0x800C, 0x6119, 0xA098, 0x5121, 0x7080, 0x6115, 0xA04B, 0x610A, 0x9016, 0x611D, 0xA0DE, + 0x510B, 0x6122, 0x610E, 0x9039, 0x6001, 0x7123, 0x6118, 0xA086, 0x5111, 0x7009, 0x6114, 0x90FD, + 0x6109, 0x80F0, 0x611C, 0xA0CD, 0x510F, 0x7005, 0x6112, 0x9081, 0x6107, 0x8010, 0x611A, 0xA0A8, + 0x5121, 0x7102, 0x6116, 0xA05E, 0x610C, 0x9021, 0x611E, 0xA0F3, 0x510B, 0x611F, 0x610D, 0x9033, + 0x6000, 0x7106, 0x6117, 0xA079, 0x5111, 0x7008, 0x6113, 0x90F2, 0x6108, 0x8040, 0x611B, 0xA0C3, + 0x510F, 0x7004, 0x6110, 0x9059, 0x6002, 0x800E, 0x6119, 0xA0A0, 0x5121, 0x70FF, 0x6115, 0xA055, + 0x610A, 0x901C, 0x611D, 0xA0E7, 0x510B, 0x6122, 0x610E, 0x9041, 0x6001, 0x7124, 0x6118, 0xA08F, + 0x5111, 0x700A, 0x6114, 0xA036, 0x6109, 0x8120, 0x611C, 0xA0D6, 0x510F, 0x7006, 0x6112, 0x9090, + 0x6107, 0x8013, 0x611A, 0xA0B1, 0x5121, 0x7103, 0x6116, 0xA067, 0x610C, 0x9026, 0x611E, 0xB0B2, + 0x510B, 0x611F, 0x610D, 0x9027, 0x6000, 0x7105, 0x6117, 0xA068, 0x5111, 0x7007, 0x6113, 0x90C0, + 0x6108, 0x8018, 0x611B, 0xA0B3, 0x510F, 0x7003, 0x6110, 0x9042, 0x6002, 0x800B, 0x6119, 0xA091, + 0x5121, 0x7080, 0x6115, 0xA03A, 0x610A, 0x9012, 0x611D, 0xA0D7, 0x510B, 0x6122, 0x610E, 0x9035, + 0x6001, 0x7123, 0x6118, 0xA07A, 0x5111, 0x7009, 0x6114, 0x90F4, 0x6109, 0x8060, 0x611C, 0xA0C4, + 0x510F, 0x7005, 0x6112, 0x9070, 0x6107, 0x800F, 0x611A, 0xA0A1, 0x5121, 0x7102, 0x6116, 0xA056, + 0x610C, 0x901D, 0x611E, 0xA0E8, 0x510B, 0x611F, 0x610D, 0x902C, 0x6000, 0x7106, 0x6117, 0xA071, + 0x5111, 0x7008, 0x6113, 0x90E0, 0x6108, 0x8020, 0x611B, 0xA0BB, 0x510F, 0x7004, 0x6110, 0x9049, + 0x6002, 0x800D, 0x6119, 0xA099, 0x5121, 0x70FF, 0x6115, 0xA04C, 0x610A, 0x9017, 0x611D, 0xA0DF, + 0x510B, 0x6122, 0x610E, 0x903C, 0x6001, 0x7124, 0x6118, 0xA087, 0x5111, 0x700A, 0x6114, 0xA023, + 0x6109, 0x80FE, 0x611C, 0xA0CE, 0x510F, 0x7006, 0x6112, 0x9082, 0x6107, 0x8011, 0x611A, 0xA0A9, + 0x5121, 0x7103, 0x6116, 0xA05F, 0x610C, 0x9022, 0x611E, 0xA0F5, 0x510B, 0x611F, 0x610D, 0x9029, + 0x6000, 0x7105, 0x6117, 0xA06C, 0x5111, 0x7007, 0x6113, 0x90CC, 0x6108, 0x8019, 0x611B, 0xA0B7, + 0x510F, 0x7003, 0x6110, 0x9044, 0x6002, 0x800C, 0x6119, 0xA095, 0x5121, 0x7080, 0x6115, 0xA046, + 0x610A, 0x9015, 0x611D, 0xA0DB, 0x510B, 0x6122, 0x610E, 0x9038, 0x6001, 0x7123, 0x6118, 0xA07E, + 0x5111, 0x7009, 0x6114, 0x90FC, 0x6109, 0x80F0, 0x611C, 0xA0C9, 0x510F, 0x7005, 0x6112, 0x907F, + 0x6107, 0x8010, 0x611A, 0xA0A5, 0x5121, 0x7102, 0x6116, 0xA05B, 0x610C, 0x901F, 0x611E, 0xA0EC, + 0x510B, 0x611F, 0x610D, 0x9031, 0x6000, 0x7106, 0x6117, 0xA075, 0x5111, 0x7008, 0x6113, 0x90F1, + 0x6108, 0x8040, 0x611B, 0xA0BF, 0x510F, 0x7004, 0x6110, 0x9051, 0x6002, 0x800E, 0x6119, 0xA09D, + 0x5121, 0x70FF, 0x6115, 0xA052, 0x610A, 0x901B, 0x611D, 0xA0E4, 0x510B, 0x6122, 0x610E, 0x903F, + 0x6001, 0x7124, 0x6118, 0xA08C, 0x5111, 0x700A, 0x6114, 0xA02F, 0x6109, 0x8120, 0x611C, 0xA0D3, + 0x510F, 0x7006, 0x6112, 0x9088, 0x6107, 0x8013, 0x611A, 0xA0AE, 0x5121, 0x7103, 0x6116, 0xA064, + 0x610C, 0x9025, 0x611E, 0xA0FA, 0x510B, 0x611F, 0x610D, 0x9028, 0x6000, 0x7105, 0x6117, 0xA06A, + 0x5111, 0x7007, 0x6113, 0x90C6, 0x6108, 0x8018, 0x611B, 0xA0B5, 0x510F, 0x7003, 0x6110, 0x9043, + 0x6002, 0x800B, 0x6119, 0xA093, 0x5121, 0x7080, 0x6115, 0xA03D, 0x610A, 0x9014, 0x611D, 0xA0D9, + 0x510B, 0x6122, 0x610E, 0x9037, 0x6001, 0x7123, 0x6118, 0xA07C, 0x5111, 0x7009, 0x6114, 0x90F8, + 0x6109, 0x8060, 0x611C, 0xA0C7, 0x510F, 0x7005, 0x6112, 0x9078, 0x6107, 0x800F, 0x611A, 0xA0A3, + 0x5121, 0x7102, 0x6116, 0xA058, 0x610C, 0x901E, 0x611E, 0xA0EA, 0x510B, 0x611F, 0x610D, 0x9030, + 0x6000, 0x7106, 0x6117, 0xA073, 0x5111, 0x7008, 0x6113, 0x90EE, 0x6108, 0x8020, 0x611B, 0xA0BD, + 0x510F, 0x7004, 0x6110, 0x9050, 0x6002, 0x800D, 0x6119, 0xA09B, 0x5121, 0x70FF, 0x6115, 0xA04E, + 0x610A, 0x901A, 0x611D, 0xA0E2, 0x510B, 0x6122, 0x610E, 0x903E, 0x6001, 0x7124, 0x6118, 0xA08A, + 0x5111, 0x700A, 0x6114, 0xA02D, 0x6109, 0x80FE, 0x611C, 0xA0D1, 0x510F, 0x7006, 0x6112, 0x9084, + 0x6107, 0x8011, 0x611A, 0xA0AC, 0x5121, 0x7103, 0x6116, 0xA062, 0x610C, 0x9024, 0x611E, 0xA0F7, + 0x510B, 0x611F, 0x610D, 0x902A, 0x6000, 0x7105, 0x6117, 0xA06E, 0x5111, 0x7007, 0x6113, 0x90D0, + 0x6108, 0x8019, 0x611B, 0xA0B9, 0x510F, 0x7003, 0x6110, 0x9048, 0x6002, 0x800C, 0x6119, 0xA097, + 0x5121, 0x7080, 0x6115, 0xA04A, 0x610A, 0x9016, 0x611D, 0xA0DD, 0x510B, 0x6122, 0x610E, 0x9039, + 0x6001, 0x7123, 0x6118, 0xA085, 0x5111, 0x7009, 0x6114, 0x90FD, 0x6109, 0x80F0, 0x611C, 0xA0CB, + 0x510F, 0x7005, 0x6112, 0x9081, 0x6107, 0x8010, 0x611A, 0xA0A7, 0x5121, 0x7102, 0x6116, 0xA05D, + 0x610C, 0x9021, 0x611E, 0xA0EF, 0x510B, 0x611F, 0x610D, 0x9033, 0x6000, 0x7106, 0x6117, 0xA077, + 0x5111, 0x7008, 0x6113, 0x90F2, 0x6108, 0x8040, 0x611B, 0xA0C2, 0x510F, 0x7004, 0x6110, 0x9059, + 0x6002, 0x800E, 0x6119, 0xA09F, 0x5121, 0x70FF, 0x6115, 0xA054, 0x610A, 0x901C, 0x611D, 0xA0E6, + 0x510B, 0x6122, 0x610E, 0x9041, 0x6001, 0x7124, 0x6118, 0xA08E, 0x5111, 0x700A, 0x6114, 0xA034, + 0x6109, 0x8120, 0x611C, 0xA0D5, 0x510F, 0x7006, 0x6112, 0x9090, 0x6107, 0x8013, 0x611A, 0xA0B0, + 0x5121, 0x7103, 0x6116, 0xA066, 0x610C, 0x9026, 0x611E, 0xA104, 0x510B, 0x611F, 0x610D, 0x9027, + 0x6000, 0x7105, 0x6117, 0xA069, 0x5111, 0x7007, 0x6113, 0x90C0, 0x6108, 0x8018, 0x611B, 0xA0B4, + 0x510F, 0x7003, 0x6110, 0x9042, 0x6002, 0x800B, 0x6119, 0xA092, 0x5121, 0x7080, 0x6115, 0xA03B, + 0x610A, 0x9012, 0x611D, 0xA0D8, 0x510B, 0x6122, 0x610E, 0x9035, 0x6001, 0x7123, 0x6118, 0xA07B, + 0x5111, 0x7009, 0x6114, 0x90F4, 0x6109, 0x8060, 0x611C, 0xA0C5, 0x510F, 0x7005, 0x6112, 0x9070, + 0x6107, 0x800F, 0x611A, 0xA0A2, 0x5121, 0x7102, 0x6116, 0xA057, 0x610C, 0x901D, 0x611E, 0xA0E9, + 0x510B, 0x611F, 0x610D, 0x902C, 0x6000, 0x7106, 0x6117, 0xA072, 0x5111, 0x7008, 0x6113, 0x90E0, + 0x6108, 0x8020, 0x611B, 0xA0BC, 0x510F, 0x7004, 0x6110, 0x9049, 0x6002, 0x800D, 0x6119, 0xA09A, + 0x5121, 0x70FF, 0x6115, 0xA04D, 0x610A, 0x9017, 0x611D, 0xA0E1, 0x510B, 0x6122, 0x610E, 0x903C, + 0x6001, 0x7124, 0x6118, 0xA089, 0x5111, 0x700A, 0x6114, 0xA02B, 0x6109, 0x80FE, 0x611C, 0xA0CF, + 0x510F, 0x7006, 0x6112, 0x9082, 0x6107, 0x8011, 0x611A, 0xA0AA, 0x5121, 0x7103, 0x6116, 0xA061, + 0x610C, 0x9022, 0x611E, 0xA0F6, 0x510B, 0x611F, 0x610D, 0x9029, 0x6000, 0x7105, 0x6117, 0xA06D, + 0x5111, 0x7007, 0x6113, 0x90CC, 0x6108, 0x8019, 0x611B, 0xA0B8, 0x510F, 0x7003, 0x6110, 0x9044, + 0x6002, 0x800C, 0x6119, 0xA096, 0x5121, 0x7080, 0x6115, 0xA047, 0x610A, 0x9015, 0x611D, 0xA0DC, + 0x510B, 0x6122, 0x610E, 0x9038, 0x6001, 0x7123, 0x6118, 0xA083, 0x5111, 0x7009, 0x6114, 0x90FC, + 0x6109, 0x80F0, 0x611C, 0xA0CA, 0x510F, 0x7005, 0x6112, 0x907F, 0x6107, 0x8010, 0x611A, 0xA0A6, + 0x5121, 0x7102, 0x6116, 0xA05C, 0x610C, 0x901F, 0x611E, 0xA0ED, 0x510B, 0x611F, 0x610D, 0x9031, + 0x6000, 0x7106, 0x6117, 0xA076, 0x5111, 0x7008, 0x6113, 0x90F1, 0x6108, 0x8040, 0x611B, 0xA0C1, + 0x510F, 0x7004, 0x6110, 0x9051, 0x6002, 0x800E, 0x6119, 0xA09E, 0x5121, 0x70FF, 0x6115, 0xA053, + 0x610A, 0x901B, 0x611D, 0xA0E5, 0x510B, 0x6122, 0x610E, 0x903F, 0x6001, 0x7124, 0x6118, 0xA08D, + 0x5111, 0x700A, 0x6114, 0xA032, 0x6109, 0x8120, 0x611C, 0xA0D4, 0x510F, 0x7006, 0x6112, 0x9088, + 0x6107, 0x8013, 0x611A, 0xA0AF, 0x5121, 0x7103, 0x6116, 0xA065, 0x610C, 0x9025, 0x611E, 0xA0FB, + 0x510B, 0x611F, 0x610D, 0x9028, 0x6000, 0x7105, 0x6117, 0xA06B, 0x5111, 0x7007, 0x6113, 0x90C6, + 0x6108, 0x8018, 0x611B, 0xA0B6, 0x510F, 0x7003, 0x6110, 0x9043, 0x6002, 0x800B, 0x6119, 0xA094, + 0x5121, 0x7080, 0x6115, 0xA045, 0x610A, 0x9014, 0x611D, 0xA0DA, 0x510B, 0x6122, 0x610E, 0x9037, + 0x6001, 0x7123, 0x6118, 0xA07D, 0x5111, 0x7009, 0x6114, 0x90F8, 0x6109, 0x8060, 0x611C, 0xA0C8, + 0x510F, 0x7005, 0x6112, 0x9078, 0x6107, 0x800F, 0x611A, 0xA0A4, 0x5121, 0x7102, 0x6116, 0xA05A, + 0x610C, 0x901E, 0x611E, 0xA0EB, 0x510B, 0x611F, 0x610D, 0x9030, 0x6000, 0x7106, 0x6117, 0xA074, + 0x5111, 0x7008, 0x6113, 0x90EE, 0x6108, 0x8020, 0x611B, 0xA0BE, 0x510F, 0x7004, 0x6110, 0x9050, + 0x6002, 0x800D, 0x6119, 0xA09C, 0x5121, 0x70FF, 0x6115, 0xA04F, 0x610A, 0x901A, 0x611D, 0xA0E3, + 0x510B, 0x6122, 0x610E, 0x903E, 0x6001, 0x7124, 0x6118, 0xA08B, 0x5111, 0x700A, 0x6114, 0xA02E, + 0x6109, 0x80FE, 0x611C, 0xA0D2, 0x510F, 0x7006, 0x6112, 0x9084, 0x6107, 0x8011, 0x611A, 0xA0AD, + 0x5121, 0x7103, 0x6116, 0xA063, 0x610C, 0x9024, 0x611E, 0xA0F9, 0x510B, 0x611F, 0x610D, 0x902A, + 0x6000, 0x7105, 0x6117, 0xA06F, 0x5111, 0x7007, 0x6113, 0x90D0, 0x6108, 0x8019, 0x611B, 0xA0BA, + 0x510F, 0x7003, 0x6110, 0x9048, 0x6002, 0x800C, 0x6119, 0xA098, 0x5121, 0x7080, 0x6115, 0xA04B, + 0x610A, 0x9016, 0x611D, 0xA0DE, 0x510B, 0x6122, 0x610E, 0x9039, 0x6001, 0x7123, 0x6118, 0xA086, + 0x5111, 0x7009, 0x6114, 0x90FD, 0x6109, 0x80F0, 0x611C, 0xA0CD, 0x510F, 0x7005, 0x6112, 0x9081, + 0x6107, 0x8010, 0x611A, 0xA0A8, 0x5121, 0x7102, 0x6116, 0xA05E, 0x610C, 0x9021, 0x611E, 0xA0F3, + 0x510B, 0x611F, 0x610D, 0x9033, 0x6000, 0x7106, 0x6117, 0xA079, 0x5111, 0x7008, 0x6113, 0x90F2, + 0x6108, 0x8040, 0x611B, 0xA0C3, 0x510F, 0x7004, 0x6110, 0x9059, 0x6002, 0x800E, 0x6119, 0xA0A0, + 0x5121, 0x70FF, 0x6115, 0xA055, 0x610A, 0x901C, 0x611D, 0xA0E7, 0x510B, 0x6122, 0x610E, 0x9041, + 0x6001, 0x7124, 0x6118, 0xA08F, 0x5111, 0x700A, 0x6114, 0xA036, 0x6109, 0x8120, 0x611C, 0xA0D6, + 0x510F, 0x7006, 0x6112, 0x9090, 0x6107, 0x8013, 0x611A, 0xA0B1, 0x5121, 0x7103, 0x6116, 0xA067, + 0x610C, 0x9026, 0x611E, 0xD101, 0x510B, 0x611F, 0x610D, 0x9027, 0x6000, 0x7105, 0x6117, 0xA068, + 0x5111, 0x7007, 0x6113, 0x90C0, 0x6108, 0x8018, 0x611B, 0xA0B3, 0x510F, 0x7003, 0x6110, 0x9042, + 0x6002, 0x800B, 0x6119, 0xA091, 0x5121, 0x7080, 0x6115, 0xA03A, 0x610A, 0x9012, 0x611D, 0xA0D7, + 0x510B, 0x6122, 0x610E, 0x9035, 0x6001, 0x7123, 0x6118, 0xA07A, 0x5111, 0x7009, 0x6114, 0x90F4, + 0x6109, 0x8060, 0x611C, 0xA0C4, 0x510F, 0x7005, 0x6112, 0x9070, 0x6107, 0x800F, 0x611A, 0xA0A1, + 0x5121, 0x7102, 0x6116, 0xA056, 0x610C, 0x901D, 0x611E, 0xA0E8, 0x510B, 0x611F, 0x610D, 0x902C, + 0x6000, 0x7106, 0x6117, 0xA071, 0x5111, 0x7008, 0x6113, 0x90E0, 0x6108, 0x8020, 0x611B, 0xA0BB, + 0x510F, 0x7004, 0x6110, 0x9049, 0x6002, 0x800D, 0x6119, 0xA099, 0x5121, 0x70FF, 0x6115, 0xA04C, + 0x610A, 0x9017, 0x611D, 0xA0DF, 0x510B, 0x6122, 0x610E, 0x903C, 0x6001, 0x7124, 0x6118, 0xA087, + 0x5111, 0x700A, 0x6114, 0xA023, 0x6109, 0x80FE, 0x611C, 0xA0CE, 0x510F, 0x7006, 0x6112, 0x9082, + 0x6107, 0x8011, 0x611A, 0xA0A9, 0x5121, 0x7103, 0x6116, 0xA05F, 0x610C, 0x9022, 0x611E, 0xA0F5, + 0x510B, 0x611F, 0x610D, 0x9029, 0x6000, 0x7105, 0x6117, 0xA06C, 0x5111, 0x7007, 0x6113, 0x90CC, + 0x6108, 0x8019, 0x611B, 0xA0B7, 0x510F, 0x7003, 0x6110, 0x9044, 0x6002, 0x800C, 0x6119, 0xA095, + 0x5121, 0x7080, 0x6115, 0xA046, 0x610A, 0x9015, 0x611D, 0xA0DB, 0x510B, 0x6122, 0x610E, 0x9038, + 0x6001, 0x7123, 0x6118, 0xA07E, 0x5111, 0x7009, 0x6114, 0x90FC, 0x6109, 0x80F0, 0x611C, 0xA0C9, + 0x510F, 0x7005, 0x6112, 0x907F, 0x6107, 0x8010, 0x611A, 0xA0A5, 0x5121, 0x7102, 0x6116, 0xA05B, + 0x610C, 0x901F, 0x611E, 0xA0EC, 0x510B, 0x611F, 0x610D, 0x9031, 0x6000, 0x7106, 0x6117, 0xA075, + 0x5111, 0x7008, 0x6113, 0x90F1, 0x6108, 0x8040, 0x611B, 0xA0BF, 0x510F, 0x7004, 0x6110, 0x9051, + 0x6002, 0x800E, 0x6119, 0xA09D, 0x5121, 0x70FF, 0x6115, 0xA052, 0x610A, 0x901B, 0x611D, 0xA0E4, + 0x510B, 0x6122, 0x610E, 0x903F, 0x6001, 0x7124, 0x6118, 0xA08C, 0x5111, 0x700A, 0x6114, 0xA02F, + 0x6109, 0x8120, 0x611C, 0xA0D3, 0x510F, 0x7006, 0x6112, 0x9088, 0x6107, 0x8013, 0x611A, 0xA0AE, + 0x5121, 0x7103, 0x6116, 0xA064, 0x610C, 0x9025, 0x611E, 0xA0FA, 0x510B, 0x611F, 0x610D, 0x9028, + 0x6000, 0x7105, 0x6117, 0xA06A, 0x5111, 0x7007, 0x6113, 0x90C6, 0x6108, 0x8018, 0x611B, 0xA0B5, + 0x510F, 0x7003, 0x6110, 0x9043, 0x6002, 0x800B, 0x6119, 0xA093, 0x5121, 0x7080, 0x6115, 0xA03D, + 0x610A, 0x9014, 0x611D, 0xA0D9, 0x510B, 0x6122, 0x610E, 0x9037, 0x6001, 0x7123, 0x6118, 0xA07C, + 0x5111, 0x7009, 0x6114, 0x90F8, 0x6109, 0x8060, 0x611C, 0xA0C7, 0x510F, 0x7005, 0x6112, 0x9078, + 0x6107, 0x800F, 0x611A, 0xA0A3, 0x5121, 0x7102, 0x6116, 0xA058, 0x610C, 0x901E, 0x611E, 0xA0EA, + 0x510B, 0x611F, 0x610D, 0x9030, 0x6000, 0x7106, 0x6117, 0xA073, 0x5111, 0x7008, 0x6113, 0x90EE, + 0x6108, 0x8020, 0x611B, 0xA0BD, 0x510F, 0x7004, 0x6110, 0x9050, 0x6002, 0x800D, 0x6119, 0xA09B, + 0x5121, 0x70FF, 0x6115, 0xA04E, 0x610A, 0x901A, 0x611D, 0xA0E2, 0x510B, 0x6122, 0x610E, 0x903E, + 0x6001, 0x7124, 0x6118, 0xA08A, 0x5111, 0x700A, 0x6114, 0xA02D, 0x6109, 0x80FE, 0x611C, 0xA0D1, + 0x510F, 0x7006, 0x6112, 0x9084, 0x6107, 0x8011, 0x611A, 0xA0AC, 0x5121, 0x7103, 0x6116, 0xA062, + 0x610C, 0x9024, 0x611E, 0xA0F7, 0x510B, 0x611F, 0x610D, 0x902A, 0x6000, 0x7105, 0x6117, 0xA06E, + 0x5111, 0x7007, 0x6113, 0x90D0, 0x6108, 0x8019, 0x611B, 0xA0B9, 0x510F, 0x7003, 0x6110, 0x9048, + 0x6002, 0x800C, 0x6119, 0xA097, 0x5121, 0x7080, 0x6115, 0xA04A, 0x610A, 0x9016, 0x611D, 0xA0DD, + 0x510B, 0x6122, 0x610E, 0x9039, 0x6001, 0x7123, 0x6118, 0xA085, 0x5111, 0x7009, 0x6114, 0x90FD, + 0x6109, 0x80F0, 0x611C, 0xA0CB, 0x510F, 0x7005, 0x6112, 0x9081, 0x6107, 0x8010, 0x611A, 0xA0A7, + 0x5121, 0x7102, 0x6116, 0xA05D, 0x610C, 0x9021, 0x611E, 0xA0EF, 0x510B, 0x611F, 0x610D, 0x9033, + 0x6000, 0x7106, 0x6117, 0xA077, 0x5111, 0x7008, 0x6113, 0x90F2, 0x6108, 0x8040, 0x611B, 0xA0C2, + 0x510F, 0x7004, 0x6110, 0x9059, 0x6002, 0x800E, 0x6119, 0xA09F, 0x5121, 0x70FF, 0x6115, 0xA054, + 0x610A, 0x901C, 0x611D, 0xA0E6, 0x510B, 0x6122, 0x610E, 0x9041, 0x6001, 0x7124, 0x6118, 0xA08E, + 0x5111, 0x700A, 0x6114, 0xA034, 0x6109, 0x8120, 0x611C, 0xA0D5, 0x510F, 0x7006, 0x6112, 0x9090, + 0x6107, 0x8013, 0x611A, 0xA0B0, 0x5121, 0x7103, 0x6116, 0xA066, 0x610C, 0x9026, 0x611E, 0xA104, + 0x510B, 0x611F, 0x610D, 0x9027, 0x6000, 0x7105, 0x6117, 0xA069, 0x5111, 0x7007, 0x6113, 0x90C0, + 0x6108, 0x8018, 0x611B, 0xA0B4, 0x510F, 0x7003, 0x6110, 0x9042, 0x6002, 0x800B, 0x6119, 0xA092, + 0x5121, 0x7080, 0x6115, 0xA03B, 0x610A, 0x9012, 0x611D, 0xA0D8, 0x510B, 0x6122, 0x610E, 0x9035, + 0x6001, 0x7123, 0x6118, 0xA07B, 0x5111, 0x7009, 0x6114, 0x90F4, 0x6109, 0x8060, 0x611C, 0xA0C5, + 0x510F, 0x7005, 0x6112, 0x9070, 0x6107, 0x800F, 0x611A, 0xA0A2, 0x5121, 0x7102, 0x6116, 0xA057, + 0x610C, 0x901D, 0x611E, 0xA0E9, 0x510B, 0x611F, 0x610D, 0x902C, 0x6000, 0x7106, 0x6117, 0xA072, + 0x5111, 0x7008, 0x6113, 0x90E0, 0x6108, 0x8020, 0x611B, 0xA0BC, 0x510F, 0x7004, 0x6110, 0x9049, + 0x6002, 0x800D, 0x6119, 0xA09A, 0x5121, 0x70FF, 0x6115, 0xA04D, 0x610A, 0x9017, 0x611D, 0xA0E1, + 0x510B, 0x6122, 0x610E, 0x903C, 0x6001, 0x7124, 0x6118, 0xA089, 0x5111, 0x700A, 0x6114, 0xA02B, + 0x6109, 0x80FE, 0x611C, 0xA0CF, 0x510F, 0x7006, 0x6112, 0x9082, 0x6107, 0x8011, 0x611A, 0xA0AA, + 0x5121, 0x7103, 0x6116, 0xA061, 0x610C, 0x9022, 0x611E, 0xA0F6, 0x510B, 0x611F, 0x610D, 0x9029, + 0x6000, 0x7105, 0x6117, 0xA06D, 0x5111, 0x7007, 0x6113, 0x90CC, 0x6108, 0x8019, 0x611B, 0xA0B8, + 0x510F, 0x7003, 0x6110, 0x9044, 0x6002, 0x800C, 0x6119, 0xA096, 0x5121, 0x7080, 0x6115, 0xA047, + 0x610A, 0x9015, 0x611D, 0xA0DC, 0x510B, 0x6122, 0x610E, 0x9038, 0x6001, 0x7123, 0x6118, 0xA083, + 0x5111, 0x7009, 0x6114, 0x90FC, 0x6109, 0x80F0, 0x611C, 0xA0CA, 0x510F, 0x7005, 0x6112, 0x907F, + 0x6107, 0x8010, 0x611A, 0xA0A6, 0x5121, 0x7102, 0x6116, 0xA05C, 0x610C, 0x901F, 0x611E, 0xA0ED, + 0x510B, 0x611F, 0x610D, 0x9031, 0x6000, 0x7106, 0x6117, 0xA076, 0x5111, 0x7008, 0x6113, 0x90F1, + 0x6108, 0x8040, 0x611B, 0xA0C1, 0x510F, 0x7004, 0x6110, 0x9051, 0x6002, 0x800E, 0x6119, 0xA09E, + 0x5121, 0x70FF, 0x6115, 0xA053, 0x610A, 0x901B, 0x611D, 0xA0E5, 0x510B, 0x6122, 0x610E, 0x903F, + 0x6001, 0x7124, 0x6118, 0xA08D, 0x5111, 0x700A, 0x6114, 0xA032, 0x6109, 0x8120, 0x611C, 0xA0D4, + 0x510F, 0x7006, 0x6112, 0x9088, 0x6107, 0x8013, 0x611A, 0xA0AF, 0x5121, 0x7103, 0x6116, 0xA065, + 0x610C, 0x9025, 0x611E, 0xA0FB, 0x510B, 0x611F, 0x610D, 0x9028, 0x6000, 0x7105, 0x6117, 0xA06B, + 0x5111, 0x7007, 0x6113, 0x90C6, 0x6108, 0x8018, 0x611B, 0xA0B6, 0x510F, 0x7003, 0x6110, 0x9043, + 0x6002, 0x800B, 0x6119, 0xA094, 0x5121, 0x7080, 0x6115, 0xA045, 0x610A, 0x9014, 0x611D, 0xA0DA, + 0x510B, 0x6122, 0x610E, 0x9037, 0x6001, 0x7123, 0x6118, 0xA07D, 0x5111, 0x7009, 0x6114, 0x90F8, + 0x6109, 0x8060, 0x611C, 0xA0C8, 0x510F, 0x7005, 0x6112, 0x9078, 0x6107, 0x800F, 0x611A, 0xA0A4, + 0x5121, 0x7102, 0x6116, 0xA05A, 0x610C, 0x901E, 0x611E, 0xA0EB, 0x510B, 0x611F, 0x610D, 0x9030, + 0x6000, 0x7106, 0x6117, 0xA074, 0x5111, 0x7008, 0x6113, 0x90EE, 0x6108, 0x8020, 0x611B, 0xA0BE, + 0x510F, 0x7004, 0x6110, 0x9050, 0x6002, 0x800D, 0x6119, 0xA09C, 0x5121, 0x70FF, 0x6115, 0xA04F, + 0x610A, 0x901A, 0x611D, 0xA0E3, 0x510B, 0x6122, 0x610E, 0x903E, 0x6001, 0x7124, 0x6118, 0xA08B, + 0x5111, 0x700A, 0x6114, 0xA02E, 0x6109, 0x80FE, 0x611C, 0xA0D2, 0x510F, 0x7006, 0x6112, 0x9084, + 0x6107, 0x8011, 0x611A, 0xA0AD, 0x5121, 0x7103, 0x6116, 0xA063, 0x610C, 0x9024, 0x611E, 0xA0F9, + 0x510B, 0x611F, 0x610D, 0x902A, 0x6000, 0x7105, 0x6117, 0xA06F, 0x5111, 0x7007, 0x6113, 0x90D0, + 0x6108, 0x8019, 0x611B, 0xA0BA, 0x510F, 0x7003, 0x6110, 0x9048, 0x6002, 0x800C, 0x6119, 0xA098, + 0x5121, 0x7080, 0x6115, 0xA04B, 0x610A, 0x9016, 0x611D, 0xA0DE, 0x510B, 0x6122, 0x610E, 0x9039, + 0x6001, 0x7123, 0x6118, 0xA086, 0x5111, 0x7009, 0x6114, 0x90FD, 0x6109, 0x80F0, 0x611C, 0xA0CD, + 0x510F, 0x7005, 0x6112, 0x9081, 0x6107, 0x8010, 0x611A, 0xA0A8, 0x5121, 0x7102, 0x6116, 0xA05E, + 0x610C, 0x9021, 0x611E, 0xA0F3, 0x510B, 0x611F, 0x610D, 0x9033, 0x6000, 0x7106, 0x6117, 0xA079, + 0x5111, 0x7008, 0x6113, 0x90F2, 0x6108, 0x8040, 0x611B, 0xA0C3, 0x510F, 0x7004, 0x6110, 0x9059, + 0x6002, 0x800E, 0x6119, 0xA0A0, 0x5121, 0x70FF, 0x6115, 0xA055, 0x610A, 0x901C, 0x611D, 0xA0E7, + 0x510B, 0x6122, 0x610E, 0x9041, 0x6001, 0x7124, 0x6118, 0xA08F, 0x5111, 0x700A, 0x6114, 0xA036, + 0x6109, 0x8120, 0x611C, 0xA0D6, 0x510F, 0x7006, 0x6112, 0x9090, 0x6107, 0x8013, 0x611A, 0xA0B1, + 0x5121, 0x7103, 0x6116, 0xA067, 0x610C, 0x9026, 0x611E, 0xB0B2, 0x510B, 0x611F, 0x610D, 0x9027, + 0x6000, 0x7105, 0x6117, 0xA068, 0x5111, 0x7007, 0x6113, 0x90C0, 0x6108, 0x8018, 0x611B, 0xA0B3, + 0x510F, 0x7003, 0x6110, 0x9042, 0x6002, 0x800B, 0x6119, 0xA091, 0x5121, 0x7080, 0x6115, 0xA03A, + 0x610A, 0x9012, 0x611D, 0xA0D7, 0x510B, 0x6122, 0x610E, 0x9035, 0x6001, 0x7123, 0x6118, 0xA07A, + 0x5111, 0x7009, 0x6114, 0x90F4, 0x6109, 0x8060, 0x611C, 0xA0C4, 0x510F, 0x7005, 0x6112, 0x9070, + 0x6107, 0x800F, 0x611A, 0xA0A1, 0x5121, 0x7102, 0x6116, 0xA056, 0x610C, 0x901D, 0x611E, 0xA0E8, + 0x510B, 0x611F, 0x610D, 0x902C, 0x6000, 0x7106, 0x6117, 0xA071, 0x5111, 0x7008, 0x6113, 0x90E0, + 0x6108, 0x8020, 0x611B, 0xA0BB, 0x510F, 0x7004, 0x6110, 0x9049, 0x6002, 0x800D, 0x6119, 0xA099, + 0x5121, 0x70FF, 0x6115, 0xA04C, 0x610A, 0x9017, 0x611D, 0xA0DF, 0x510B, 0x6122, 0x610E, 0x903C, + 0x6001, 0x7124, 0x6118, 0xA087, 0x5111, 0x700A, 0x6114, 0xA023, 0x6109, 0x80FE, 0x611C, 0xA0CE, + 0x510F, 0x7006, 0x6112, 0x9082, 0x6107, 0x8011, 0x611A, 0xA0A9, 0x5121, 0x7103, 0x6116, 0xA05F, + 0x610C, 0x9022, 0x611E, 0xA0F5, 0x510B, 0x611F, 0x610D, 0x9029, 0x6000, 0x7105, 0x6117, 0xA06C, + 0x5111, 0x7007, 0x6113, 0x90CC, 0x6108, 0x8019, 0x611B, 0xA0B7, 0x510F, 0x7003, 0x6110, 0x9044, + 0x6002, 0x800C, 0x6119, 0xA095, 0x5121, 0x7080, 0x6115, 0xA046, 0x610A, 0x9015, 0x611D, 0xA0DB, + 0x510B, 0x6122, 0x610E, 0x9038, 0x6001, 0x7123, 0x6118, 0xA07E, 0x5111, 0x7009, 0x6114, 0x90FC, + 0x6109, 0x80F0, 0x611C, 0xA0C9, 0x510F, 0x7005, 0x6112, 0x907F, 0x6107, 0x8010, 0x611A, 0xA0A5, + 0x5121, 0x7102, 0x6116, 0xA05B, 0x610C, 0x901F, 0x611E, 0xA0EC, 0x510B, 0x611F, 0x610D, 0x9031, + 0x6000, 0x7106, 0x6117, 0xA075, 0x5111, 0x7008, 0x6113, 0x90F1, 0x6108, 0x8040, 0x611B, 0xA0BF, + 0x510F, 0x7004, 0x6110, 0x9051, 0x6002, 0x800E, 0x6119, 0xA09D, 0x5121, 0x70FF, 0x6115, 0xA052, + 0x610A, 0x901B, 0x611D, 0xA0E4, 0x510B, 0x6122, 0x610E, 0x903F, 0x6001, 0x7124, 0x6118, 0xA08C, + 0x5111, 0x700A, 0x6114, 0xA02F, 0x6109, 0x8120, 0x611C, 0xA0D3, 0x510F, 0x7006, 0x6112, 0x9088, + 0x6107, 0x8013, 0x611A, 0xA0AE, 0x5121, 0x7103, 0x6116, 0xA064, 0x610C, 0x9025, 0x611E, 0xA0FA, + 0x510B, 0x611F, 0x610D, 0x9028, 0x6000, 0x7105, 0x6117, 0xA06A, 0x5111, 0x7007, 0x6113, 0x90C6, + 0x6108, 0x8018, 0x611B, 0xA0B5, 0x510F, 0x7003, 0x6110, 0x9043, 0x6002, 0x800B, 0x6119, 0xA093, + 0x5121, 0x7080, 0x6115, 0xA03D, 0x610A, 0x9014, 0x611D, 0xA0D9, 0x510B, 0x6122, 0x610E, 0x9037, + 0x6001, 0x7123, 0x6118, 0xA07C, 0x5111, 0x7009, 0x6114, 0x90F8, 0x6109, 0x8060, 0x611C, 0xA0C7, + 0x510F, 0x7005, 0x6112, 0x9078, 0x6107, 0x800F, 0x611A, 0xA0A3, 0x5121, 0x7102, 0x6116, 0xA058, + 0x610C, 0x901E, 0x611E, 0xA0EA, 0x510B, 0x611F, 0x610D, 0x9030, 0x6000, 0x7106, 0x6117, 0xA073, + 0x5111, 0x7008, 0x6113, 0x90EE, 0x6108, 0x8020, 0x611B, 0xA0BD, 0x510F, 0x7004, 0x6110, 0x9050, + 0x6002, 0x800D, 0x6119, 0xA09B, 0x5121, 0x70FF, 0x6115, 0xA04E, 0x610A, 0x901A, 0x611D, 0xA0E2, + 0x510B, 0x6122, 0x610E, 0x903E, 0x6001, 0x7124, 0x6118, 0xA08A, 0x5111, 0x700A, 0x6114, 0xA02D, + 0x6109, 0x80FE, 0x611C, 0xA0D1, 0x510F, 0x7006, 0x6112, 0x9084, 0x6107, 0x8011, 0x611A, 0xA0AC, + 0x5121, 0x7103, 0x6116, 0xA062, 0x610C, 0x9024, 0x611E, 0xA0F7, 0x510B, 0x611F, 0x610D, 0x902A, + 0x6000, 0x7105, 0x6117, 0xA06E, 0x5111, 0x7007, 0x6113, 0x90D0, 0x6108, 0x8019, 0x611B, 0xA0B9, + 0x510F, 0x7003, 0x6110, 0x9048, 0x6002, 0x800C, 0x6119, 0xA097, 0x5121, 0x7080, 0x6115, 0xA04A, + 0x610A, 0x9016, 0x611D, 0xA0DD, 0x510B, 0x6122, 0x610E, 0x9039, 0x6001, 0x7123, 0x6118, 0xA085, + 0x5111, 0x7009, 0x6114, 0x90FD, 0x6109, 0x80F0, 0x611C, 0xA0CB, 0x510F, 0x7005, 0x6112, 0x9081, + 0x6107, 0x8010, 0x611A, 0xA0A7, 0x5121, 0x7102, 0x6116, 0xA05D, 0x610C, 0x9021, 0x611E, 0xA0EF, + 0x510B, 0x611F, 0x610D, 0x9033, 0x6000, 0x7106, 0x6117, 0xA077, 0x5111, 0x7008, 0x6113, 0x90F2, + 0x6108, 0x8040, 0x611B, 0xA0C2, 0x510F, 0x7004, 0x6110, 0x9059, 0x6002, 0x800E, 0x6119, 0xA09F, + 0x5121, 0x70FF, 0x6115, 0xA054, 0x610A, 0x901C, 0x611D, 0xA0E6, 0x510B, 0x6122, 0x610E, 0x9041, + 0x6001, 0x7124, 0x6118, 0xA08E, 0x5111, 0x700A, 0x6114, 0xA034, 0x6109, 0x8120, 0x611C, 0xA0D5, + 0x510F, 0x7006, 0x6112, 0x9090, 0x6107, 0x8013, 0x611A, 0xA0B0, 0x5121, 0x7103, 0x6116, 0xA066, + 0x610C, 0x9026, 0x611E, 0xA104, 0x510B, 0x611F, 0x610D, 0x9027, 0x6000, 0x7105, 0x6117, 0xA069, + 0x5111, 0x7007, 0x6113, 0x90C0, 0x6108, 0x8018, 0x611B, 0xA0B4, 0x510F, 0x7003, 0x6110, 0x9042, + 0x6002, 0x800B, 0x6119, 0xA092, 0x5121, 0x7080, 0x6115, 0xA03B, 0x610A, 0x9012, 0x611D, 0xA0D8, + 0x510B, 0x6122, 0x610E, 0x9035, 0x6001, 0x7123, 0x6118, 0xA07B, 0x5111, 0x7009, 0x6114, 0x90F4, + 0x6109, 0x8060, 0x611C, 0xA0C5, 0x510F, 0x7005, 0x6112, 0x9070, 0x6107, 0x800F, 0x611A, 0xA0A2, + 0x5121, 0x7102, 0x6116, 0xA057, 0x610C, 0x901D, 0x611E, 0xA0E9, 0x510B, 0x611F, 0x610D, 0x902C, + 0x6000, 0x7106, 0x6117, 0xA072, 0x5111, 0x7008, 0x6113, 0x90E0, 0x6108, 0x8020, 0x611B, 0xA0BC, + 0x510F, 0x7004, 0x6110, 0x9049, 0x6002, 0x800D, 0x6119, 0xA09A, 0x5121, 0x70FF, 0x6115, 0xA04D, + 0x610A, 0x9017, 0x611D, 0xA0E1, 0x510B, 0x6122, 0x610E, 0x903C, 0x6001, 0x7124, 0x6118, 0xA089, + 0x5111, 0x700A, 0x6114, 0xA02B, 0x6109, 0x80FE, 0x611C, 0xA0CF, 0x510F, 0x7006, 0x6112, 0x9082, + 0x6107, 0x8011, 0x611A, 0xA0AA, 0x5121, 0x7103, 0x6116, 0xA061, 0x610C, 0x9022, 0x611E, 0xA0F6, + 0x510B, 0x611F, 0x610D, 0x9029, 0x6000, 0x7105, 0x6117, 0xA06D, 0x5111, 0x7007, 0x6113, 0x90CC, + 0x6108, 0x8019, 0x611B, 0xA0B8, 0x510F, 0x7003, 0x6110, 0x9044, 0x6002, 0x800C, 0x6119, 0xA096, + 0x5121, 0x7080, 0x6115, 0xA047, 0x610A, 0x9015, 0x611D, 0xA0DC, 0x510B, 0x6122, 0x610E, 0x9038, + 0x6001, 0x7123, 0x6118, 0xA083, 0x5111, 0x7009, 0x6114, 0x90FC, 0x6109, 0x80F0, 0x611C, 0xA0CA, + 0x510F, 0x7005, 0x6112, 0x907F, 0x6107, 0x8010, 0x611A, 0xA0A6, 0x5121, 0x7102, 0x6116, 0xA05C, + 0x610C, 0x901F, 0x611E, 0xA0ED, 0x510B, 0x611F, 0x610D, 0x9031, 0x6000, 0x7106, 0x6117, 0xA076, + 0x5111, 0x7008, 0x6113, 0x90F1, 0x6108, 0x8040, 0x611B, 0xA0C1, 0x510F, 0x7004, 0x6110, 0x9051, + 0x6002, 0x800E, 0x6119, 0xA09E, 0x5121, 0x70FF, 0x6115, 0xA053, 0x610A, 0x901B, 0x611D, 0xA0E5, + 0x510B, 0x6122, 0x610E, 0x903F, 0x6001, 0x7124, 0x6118, 0xA08D, 0x5111, 0x700A, 0x6114, 0xA032, + 0x6109, 0x8120, 0x611C, 0xA0D4, 0x510F, 0x7006, 0x6112, 0x9088, 0x6107, 0x8013, 0x611A, 0xA0AF, + 0x5121, 0x7103, 0x6116, 0xA065, 0x610C, 0x9025, 0x611E, 0xA0FB, 0x510B, 0x611F, 0x610D, 0x9028, + 0x6000, 0x7105, 0x6117, 0xA06B, 0x5111, 0x7007, 0x6113, 0x90C6, 0x6108, 0x8018, 0x611B, 0xA0B6, + 0x510F, 0x7003, 0x6110, 0x9043, 0x6002, 0x800B, 0x6119, 0xA094, 0x5121, 0x7080, 0x6115, 0xA045, + 0x610A, 0x9014, 0x611D, 0xA0DA, 0x510B, 0x6122, 0x610E, 0x9037, 0x6001, 0x7123, 0x6118, 0xA07D, + 0x5111, 0x7009, 0x6114, 0x90F8, 0x6109, 0x8060, 0x611C, 0xA0C8, 0x510F, 0x7005, 0x6112, 0x9078, + 0x6107, 0x800F, 0x611A, 0xA0A4, 0x5121, 0x7102, 0x6116, 0xA05A, 0x610C, 0x901E, 0x611E, 0xA0EB, + 0x510B, 0x611F, 0x610D, 0x9030, 0x6000, 0x7106, 0x6117, 0xA074, 0x5111, 0x7008, 0x6113, 0x90EE, + 0x6108, 0x8020, 0x611B, 0xA0BE, 0x510F, 0x7004, 0x6110, 0x9050, 0x6002, 0x800D, 0x6119, 0xA09C, + 0x5121, 0x70FF, 0x6115, 0xA04F, 0x610A, 0x901A, 0x611D, 0xA0E3, 0x510B, 0x6122, 0x610E, 0x903E, + 0x6001, 0x7124, 0x6118, 0xA08B, 0x5111, 0x700A, 0x6114, 0xA02E, 0x6109, 0x80FE, 0x611C, 0xA0D2, + 0x510F, 0x7006, 0x6112, 0x9084, 0x6107, 0x8011, 0x611A, 0xA0AD, 0x5121, 0x7103, 0x6116, 0xA063, + 0x610C, 0x9024, 0x611E, 0xA0F9, 0x510B, 0x611F, 0x610D, 0x902A, 0x6000, 0x7105, 0x6117, 0xA06F, + 0x5111, 0x7007, 0x6113, 0x90D0, 0x6108, 0x8019, 0x611B, 0xA0BA, 0x510F, 0x7003, 0x6110, 0x9048, + 0x6002, 0x800C, 0x6119, 0xA098, 0x5121, 0x7080, 0x6115, 0xA04B, 0x610A, 0x9016, 0x611D, 0xA0DE, + 0x510B, 0x6122, 0x610E, 0x9039, 0x6001, 0x7123, 0x6118, 0xA086, 0x5111, 0x7009, 0x6114, 0x90FD, + 0x6109, 0x80F0, 0x611C, 0xA0CD, 0x510F, 0x7005, 0x6112, 0x9081, 0x6107, 0x8010, 0x611A, 0xA0A8, + 0x5121, 0x7102, 0x6116, 0xA05E, 0x610C, 0x9021, 0x611E, 0xA0F3, 0x510B, 0x611F, 0x610D, 0x9033, + 0x6000, 0x7106, 0x6117, 0xA079, 0x5111, 0x7008, 0x6113, 0x90F2, 0x6108, 0x8040, 0x611B, 0xA0C3, + 0x510F, 0x7004, 0x6110, 0x9059, 0x6002, 0x800E, 0x6119, 0xA0A0, 0x5121, 0x70FF, 0x6115, 0xA055, + 0x610A, 0x901C, 0x611D, 0xA0E7, 0x510B, 0x6122, 0x610E, 0x9041, 0x6001, 0x7124, 0x6118, 0xA08F, + 0x5111, 0x700A, 0x6114, 0xA036, 0x6109, 0x8120, 0x611C, 0xA0D6, 0x510F, 0x7006, 0x6112, 0x9090, + 0x6107, 0x8013, 0x611A, 0xA0B1, 0x5121, 0x7103, 0x6116, 0xA067, 0x610C, 0x9026, 0x611E, 0xD100, + 0x510B, 0x611F, 0x610D, 0x9027, 0x6000, 0x7105, 0x6117, 0xA068, 0x5111, 0x7007, 0x6113, 0x90C0, + 0x6108, 0x8018, 0x611B, 0xA0B3, 0x510F, 0x7003, 0x6110, 0x9042, 0x6002, 0x800B, 0x6119, 0xA091, + 0x5121, 0x7080, 0x6115, 0xA03A, 0x610A, 0x9012, 0x611D, 0xA0D7, 0x510B, 0x6122, 0x610E, 0x9035, + 0x6001, 0x7123, 0x6118, 0xA07A, 0x5111, 0x7009, 0x6114, 0x90F4, 0x6109, 0x8060, 0x611C, 0xA0C4, + 0x510F, 0x7005, 0x6112, 0x9070, 0x6107, 0x800F, 0x611A, 0xA0A1, 0x5121, 0x7102, 0x6116, 0xA056, + 0x610C, 0x901D, 0x611E, 0xA0E8, 0x510B, 0x611F, 0x610D, 0x902C, 0x6000, 0x7106, 0x6117, 0xA071, + 0x5111, 0x7008, 0x6113, 0x90E0, 0x6108, 0x8020, 0x611B, 0xA0BB, 0x510F, 0x7004, 0x6110, 0x9049, + 0x6002, 0x800D, 0x6119, 0xA099, 0x5121, 0x70FF, 0x6115, 0xA04C, 0x610A, 0x9017, 0x611D, 0xA0DF, + 0x510B, 0x6122, 0x610E, 0x903C, 0x6001, 0x7124, 0x6118, 0xA087, 0x5111, 0x700A, 0x6114, 0xA023, + 0x6109, 0x80FE, 0x611C, 0xA0CE, 0x510F, 0x7006, 0x6112, 0x9082, 0x6107, 0x8011, 0x611A, 0xA0A9, + 0x5121, 0x7103, 0x6116, 0xA05F, 0x610C, 0x9022, 0x611E, 0xA0F5, 0x510B, 0x611F, 0x610D, 0x9029, + 0x6000, 0x7105, 0x6117, 0xA06C, 0x5111, 0x7007, 0x6113, 0x90CC, 0x6108, 0x8019, 0x611B, 0xA0B7, + 0x510F, 0x7003, 0x6110, 0x9044, 0x6002, 0x800C, 0x6119, 0xA095, 0x5121, 0x7080, 0x6115, 0xA046, + 0x610A, 0x9015, 0x611D, 0xA0DB, 0x510B, 0x6122, 0x610E, 0x9038, 0x6001, 0x7123, 0x6118, 0xA07E, + 0x5111, 0x7009, 0x6114, 0x90FC, 0x6109, 0x80F0, 0x611C, 0xA0C9, 0x510F, 0x7005, 0x6112, 0x907F, + 0x6107, 0x8010, 0x611A, 0xA0A5, 0x5121, 0x7102, 0x6116, 0xA05B, 0x610C, 0x901F, 0x611E, 0xA0EC, + 0x510B, 0x611F, 0x610D, 0x9031, 0x6000, 0x7106, 0x6117, 0xA075, 0x5111, 0x7008, 0x6113, 0x90F1, + 0x6108, 0x8040, 0x611B, 0xA0BF, 0x510F, 0x7004, 0x6110, 0x9051, 0x6002, 0x800E, 0x6119, 0xA09D, + 0x5121, 0x70FF, 0x6115, 0xA052, 0x610A, 0x901B, 0x611D, 0xA0E4, 0x510B, 0x6122, 0x610E, 0x903F, + 0x6001, 0x7124, 0x6118, 0xA08C, 0x5111, 0x700A, 0x6114, 0xA02F, 0x6109, 0x8120, 0x611C, 0xA0D3, + 0x510F, 0x7006, 0x6112, 0x9088, 0x6107, 0x8013, 0x611A, 0xA0AE, 0x5121, 0x7103, 0x6116, 0xA064, + 0x610C, 0x9025, 0x611E, 0xA0FA, 0x510B, 0x611F, 0x610D, 0x9028, 0x6000, 0x7105, 0x6117, 0xA06A, + 0x5111, 0x7007, 0x6113, 0x90C6, 0x6108, 0x8018, 0x611B, 0xA0B5, 0x510F, 0x7003, 0x6110, 0x9043, + 0x6002, 0x800B, 0x6119, 0xA093, 0x5121, 0x7080, 0x6115, 0xA03D, 0x610A, 0x9014, 0x611D, 0xA0D9, + 0x510B, 0x6122, 0x610E, 0x9037, 0x6001, 0x7123, 0x6118, 0xA07C, 0x5111, 0x7009, 0x6114, 0x90F8, + 0x6109, 0x8060, 0x611C, 0xA0C7, 0x510F, 0x7005, 0x6112, 0x9078, 0x6107, 0x800F, 0x611A, 0xA0A3, + 0x5121, 0x7102, 0x6116, 0xA058, 0x610C, 0x901E, 0x611E, 0xA0EA, 0x510B, 0x611F, 0x610D, 0x9030, + 0x6000, 0x7106, 0x6117, 0xA073, 0x5111, 0x7008, 0x6113, 0x90EE, 0x6108, 0x8020, 0x611B, 0xA0BD, + 0x510F, 0x7004, 0x6110, 0x9050, 0x6002, 0x800D, 0x6119, 0xA09B, 0x5121, 0x70FF, 0x6115, 0xA04E, + 0x610A, 0x901A, 0x611D, 0xA0E2, 0x510B, 0x6122, 0x610E, 0x903E, 0x6001, 0x7124, 0x6118, 0xA08A, + 0x5111, 0x700A, 0x6114, 0xA02D, 0x6109, 0x80FE, 0x611C, 0xA0D1, 0x510F, 0x7006, 0x6112, 0x9084, + 0x6107, 0x8011, 0x611A, 0xA0AC, 0x5121, 0x7103, 0x6116, 0xA062, 0x610C, 0x9024, 0x611E, 0xA0F7, + 0x510B, 0x611F, 0x610D, 0x902A, 0x6000, 0x7105, 0x6117, 0xA06E, 0x5111, 0x7007, 0x6113, 0x90D0, + 0x6108, 0x8019, 0x611B, 0xA0B9, 0x510F, 0x7003, 0x6110, 0x9048, 0x6002, 0x800C, 0x6119, 0xA097, + 0x5121, 0x7080, 0x6115, 0xA04A, 0x610A, 0x9016, 0x611D, 0xA0DD, 0x510B, 0x6122, 0x610E, 0x9039, + 0x6001, 0x7123, 0x6118, 0xA085, 0x5111, 0x7009, 0x6114, 0x90FD, 0x6109, 0x80F0, 0x611C, 0xA0CB, + 0x510F, 0x7005, 0x6112, 0x9081, 0x6107, 0x8010, 0x611A, 0xA0A7, 0x5121, 0x7102, 0x6116, 0xA05D, + 0x610C, 0x9021, 0x611E, 0xA0EF, 0x510B, 0x611F, 0x610D, 0x9033, 0x6000, 0x7106, 0x6117, 0xA077, + 0x5111, 0x7008, 0x6113, 0x90F2, 0x6108, 0x8040, 0x611B, 0xA0C2, 0x510F, 0x7004, 0x6110, 0x9059, + 0x6002, 0x800E, 0x6119, 0xA09F, 0x5121, 0x70FF, 0x6115, 0xA054, 0x610A, 0x901C, 0x611D, 0xA0E6, + 0x510B, 0x6122, 0x610E, 0x9041, 0x6001, 0x7124, 0x6118, 0xA08E, 0x5111, 0x700A, 0x6114, 0xA034, + 0x6109, 0x8120, 0x611C, 0xA0D5, 0x510F, 0x7006, 0x6112, 0x9090, 0x6107, 0x8013, 0x611A, 0xA0B0, + 0x5121, 0x7103, 0x6116, 0xA066, 0x610C, 0x9026, 0x611E, 0xA104, 0x510B, 0x611F, 0x610D, 0x9027, + 0x6000, 0x7105, 0x6117, 0xA069, 0x5111, 0x7007, 0x6113, 0x90C0, 0x6108, 0x8018, 0x611B, 0xA0B4, + 0x510F, 0x7003, 0x6110, 0x9042, 0x6002, 0x800B, 0x6119, 0xA092, 0x5121, 0x7080, 0x6115, 0xA03B, + 0x610A, 0x9012, 0x611D, 0xA0D8, 0x510B, 0x6122, 0x610E, 0x9035, 0x6001, 0x7123, 0x6118, 0xA07B, + 0x5111, 0x7009, 0x6114, 0x90F4, 0x6109, 0x8060, 0x611C, 0xA0C5, 0x510F, 0x7005, 0x6112, 0x9070, + 0x6107, 0x800F, 0x611A, 0xA0A2, 0x5121, 0x7102, 0x6116, 0xA057, 0x610C, 0x901D, 0x611E, 0xA0E9, + 0x510B, 0x611F, 0x610D, 0x902C, 0x6000, 0x7106, 0x6117, 0xA072, 0x5111, 0x7008, 0x6113, 0x90E0, + 0x6108, 0x8020, 0x611B, 0xA0BC, 0x510F, 0x7004, 0x6110, 0x9049, 0x6002, 0x800D, 0x6119, 0xA09A, + 0x5121, 0x70FF, 0x6115, 0xA04D, 0x610A, 0x9017, 0x611D, 0xA0E1, 0x510B, 0x6122, 0x610E, 0x903C, + 0x6001, 0x7124, 0x6118, 0xA089, 0x5111, 0x700A, 0x6114, 0xA02B, 0x6109, 0x80FE, 0x611C, 0xA0CF, + 0x510F, 0x7006, 0x6112, 0x9082, 0x6107, 0x8011, 0x611A, 0xA0AA, 0x5121, 0x7103, 0x6116, 0xA061, + 0x610C, 0x9022, 0x611E, 0xA0F6, 0x510B, 0x611F, 0x610D, 0x9029, 0x6000, 0x7105, 0x6117, 0xA06D, + 0x5111, 0x7007, 0x6113, 0x90CC, 0x6108, 0x8019, 0x611B, 0xA0B8, 0x510F, 0x7003, 0x6110, 0x9044, + 0x6002, 0x800C, 0x6119, 0xA096, 0x5121, 0x7080, 0x6115, 0xA047, 0x610A, 0x9015, 0x611D, 0xA0DC, + 0x510B, 0x6122, 0x610E, 0x9038, 0x6001, 0x7123, 0x6118, 0xA083, 0x5111, 0x7009, 0x6114, 0x90FC, + 0x6109, 0x80F0, 0x611C, 0xA0CA, 0x510F, 0x7005, 0x6112, 0x907F, 0x6107, 0x8010, 0x611A, 0xA0A6, + 0x5121, 0x7102, 0x6116, 0xA05C, 0x610C, 0x901F, 0x611E, 0xA0ED, 0x510B, 0x611F, 0x610D, 0x9031, + 0x6000, 0x7106, 0x6117, 0xA076, 0x5111, 0x7008, 0x6113, 0x90F1, 0x6108, 0x8040, 0x611B, 0xA0C1, + 0x510F, 0x7004, 0x6110, 0x9051, 0x6002, 0x800E, 0x6119, 0xA09E, 0x5121, 0x70FF, 0x6115, 0xA053, + 0x610A, 0x901B, 0x611D, 0xA0E5, 0x510B, 0x6122, 0x610E, 0x903F, 0x6001, 0x7124, 0x6118, 0xA08D, + 0x5111, 0x700A, 0x6114, 0xA032, 0x6109, 0x8120, 0x611C, 0xA0D4, 0x510F, 0x7006, 0x6112, 0x9088, + 0x6107, 0x8013, 0x611A, 0xA0AF, 0x5121, 0x7103, 0x6116, 0xA065, 0x610C, 0x9025, 0x611E, 0xA0FB, + 0x510B, 0x611F, 0x610D, 0x9028, 0x6000, 0x7105, 0x6117, 0xA06B, 0x5111, 0x7007, 0x6113, 0x90C6, + 0x6108, 0x8018, 0x611B, 0xA0B6, 0x510F, 0x7003, 0x6110, 0x9043, 0x6002, 0x800B, 0x6119, 0xA094, + 0x5121, 0x7080, 0x6115, 0xA045, 0x610A, 0x9014, 0x611D, 0xA0DA, 0x510B, 0x6122, 0x610E, 0x9037, + 0x6001, 0x7123, 0x6118, 0xA07D, 0x5111, 0x7009, 0x6114, 0x90F8, 0x6109, 0x8060, 0x611C, 0xA0C8, + 0x510F, 0x7005, 0x6112, 0x9078, 0x6107, 0x800F, 0x611A, 0xA0A4, 0x5121, 0x7102, 0x6116, 0xA05A, + 0x610C, 0x901E, 0x611E, 0xA0EB, 0x510B, 0x611F, 0x610D, 0x9030, 0x6000, 0x7106, 0x6117, 0xA074, + 0x5111, 0x7008, 0x6113, 0x90EE, 0x6108, 0x8020, 0x611B, 0xA0BE, 0x510F, 0x7004, 0x6110, 0x9050, + 0x6002, 0x800D, 0x6119, 0xA09C, 0x5121, 0x70FF, 0x6115, 0xA04F, 0x610A, 0x901A, 0x611D, 0xA0E3, + 0x510B, 0x6122, 0x610E, 0x903E, 0x6001, 0x7124, 0x6118, 0xA08B, 0x5111, 0x700A, 0x6114, 0xA02E, + 0x6109, 0x80FE, 0x611C, 0xA0D2, 0x510F, 0x7006, 0x6112, 0x9084, 0x6107, 0x8011, 0x611A, 0xA0AD, + 0x5121, 0x7103, 0x6116, 0xA063, 0x610C, 0x9024, 0x611E, 0xA0F9, 0x510B, 0x611F, 0x610D, 0x902A, + 0x6000, 0x7105, 0x6117, 0xA06F, 0x5111, 0x7007, 0x6113, 0x90D0, 0x6108, 0x8019, 0x611B, 0xA0BA, + 0x510F, 0x7003, 0x6110, 0x9048, 0x6002, 0x800C, 0x6119, 0xA098, 0x5121, 0x7080, 0x6115, 0xA04B, + 0x610A, 0x9016, 0x611D, 0xA0DE, 0x510B, 0x6122, 0x610E, 0x9039, 0x6001, 0x7123, 0x6118, 0xA086, + 0x5111, 0x7009, 0x6114, 0x90FD, 0x6109, 0x80F0, 0x611C, 0xA0CD, 0x510F, 0x7005, 0x6112, 0x9081, + 0x6107, 0x8010, 0x611A, 0xA0A8, 0x5121, 0x7102, 0x6116, 0xA05E, 0x610C, 0x9021, 0x611E, 0xA0F3, + 0x510B, 0x611F, 0x610D, 0x9033, 0x6000, 0x7106, 0x6117, 0xA079, 0x5111, 0x7008, 0x6113, 0x90F2, + 0x6108, 0x8040, 0x611B, 0xA0C3, 0x510F, 0x7004, 0x6110, 0x9059, 0x6002, 0x800E, 0x6119, 0xA0A0, + 0x5121, 0x70FF, 0x6115, 0xA055, 0x610A, 0x901C, 0x611D, 0xA0E7, 0x510B, 0x6122, 0x610E, 0x9041, + 0x6001, 0x7124, 0x6118, 0xA08F, 0x5111, 0x700A, 0x6114, 0xA036, 0x6109, 0x8120, 0x611C, 0xA0D6, + 0x510F, 0x7006, 0x6112, 0x9090, 0x6107, 0x8013, 0x611A, 0xA0B1, 0x5121, 0x7103, 0x6116, 0xA067, + 0x610C, 0x9026, 0x611E, 0xB0B2, 0x510B, 0x611F, 0x610D, 0x9027, 0x6000, 0x7105, 0x6117, 0xA068, + 0x5111, 0x7007, 0x6113, 0x90C0, 0x6108, 0x8018, 0x611B, 0xA0B3, 0x510F, 0x7003, 0x6110, 0x9042, + 0x6002, 0x800B, 0x6119, 0xA091, 0x5121, 0x7080, 0x6115, 0xA03A, 0x610A, 0x9012, 0x611D, 0xA0D7, + 0x510B, 0x6122, 0x610E, 0x9035, 0x6001, 0x7123, 0x6118, 0xA07A, 0x5111, 0x7009, 0x6114, 0x90F4, + 0x6109, 0x8060, 0x611C, 0xA0C4, 0x510F, 0x7005, 0x6112, 0x9070, 0x6107, 0x800F, 0x611A, 0xA0A1, + 0x5121, 0x7102, 0x6116, 0xA056, 0x610C, 0x901D, 0x611E, 0xA0E8, 0x510B, 0x611F, 0x610D, 0x902C, + 0x6000, 0x7106, 0x6117, 0xA071, 0x5111, 0x7008, 0x6113, 0x90E0, 0x6108, 0x8020, 0x611B, 0xA0BB, + 0x510F, 0x7004, 0x6110, 0x9049, 0x6002, 0x800D, 0x6119, 0xA099, 0x5121, 0x70FF, 0x6115, 0xA04C, + 0x610A, 0x9017, 0x611D, 0xA0DF, 0x510B, 0x6122, 0x610E, 0x903C, 0x6001, 0x7124, 0x6118, 0xA087, + 0x5111, 0x700A, 0x6114, 0xA023, 0x6109, 0x80FE, 0x611C, 0xA0CE, 0x510F, 0x7006, 0x6112, 0x9082, + 0x6107, 0x8011, 0x611A, 0xA0A9, 0x5121, 0x7103, 0x6116, 0xA05F, 0x610C, 0x9022, 0x611E, 0xA0F5, + 0x510B, 0x611F, 0x610D, 0x9029, 0x6000, 0x7105, 0x6117, 0xA06C, 0x5111, 0x7007, 0x6113, 0x90CC, + 0x6108, 0x8019, 0x611B, 0xA0B7, 0x510F, 0x7003, 0x6110, 0x9044, 0x6002, 0x800C, 0x6119, 0xA095, + 0x5121, 0x7080, 0x6115, 0xA046, 0x610A, 0x9015, 0x611D, 0xA0DB, 0x510B, 0x6122, 0x610E, 0x9038, + 0x6001, 0x7123, 0x6118, 0xA07E, 0x5111, 0x7009, 0x6114, 0x90FC, 0x6109, 0x80F0, 0x611C, 0xA0C9, + 0x510F, 0x7005, 0x6112, 0x907F, 0x6107, 0x8010, 0x611A, 0xA0A5, 0x5121, 0x7102, 0x6116, 0xA05B, + 0x610C, 0x901F, 0x611E, 0xA0EC, 0x510B, 0x611F, 0x610D, 0x9031, 0x6000, 0x7106, 0x6117, 0xA075, + 0x5111, 0x7008, 0x6113, 0x90F1, 0x6108, 0x8040, 0x611B, 0xA0BF, 0x510F, 0x7004, 0x6110, 0x9051, + 0x6002, 0x800E, 0x6119, 0xA09D, 0x5121, 0x70FF, 0x6115, 0xA052, 0x610A, 0x901B, 0x611D, 0xA0E4, + 0x510B, 0x6122, 0x610E, 0x903F, 0x6001, 0x7124, 0x6118, 0xA08C, 0x5111, 0x700A, 0x6114, 0xA02F, + 0x6109, 0x8120, 0x611C, 0xA0D3, 0x510F, 0x7006, 0x6112, 0x9088, 0x6107, 0x8013, 0x611A, 0xA0AE, + 0x5121, 0x7103, 0x6116, 0xA064, 0x610C, 0x9025, 0x611E, 0xA0FA, 0x510B, 0x611F, 0x610D, 0x9028, + 0x6000, 0x7105, 0x6117, 0xA06A, 0x5111, 0x7007, 0x6113, 0x90C6, 0x6108, 0x8018, 0x611B, 0xA0B5, + 0x510F, 0x7003, 0x6110, 0x9043, 0x6002, 0x800B, 0x6119, 0xA093, 0x5121, 0x7080, 0x6115, 0xA03D, + 0x610A, 0x9014, 0x611D, 0xA0D9, 0x510B, 0x6122, 0x610E, 0x9037, 0x6001, 0x7123, 0x6118, 0xA07C, + 0x5111, 0x7009, 0x6114, 0x90F8, 0x6109, 0x8060, 0x611C, 0xA0C7, 0x510F, 0x7005, 0x6112, 0x9078, + 0x6107, 0x800F, 0x611A, 0xA0A3, 0x5121, 0x7102, 0x6116, 0xA058, 0x610C, 0x901E, 0x611E, 0xA0EA, + 0x510B, 0x611F, 0x610D, 0x9030, 0x6000, 0x7106, 0x6117, 0xA073, 0x5111, 0x7008, 0x6113, 0x90EE, + 0x6108, 0x8020, 0x611B, 0xA0BD, 0x510F, 0x7004, 0x6110, 0x9050, 0x6002, 0x800D, 0x6119, 0xA09B, + 0x5121, 0x70FF, 0x6115, 0xA04E, 0x610A, 0x901A, 0x611D, 0xA0E2, 0x510B, 0x6122, 0x610E, 0x903E, + 0x6001, 0x7124, 0x6118, 0xA08A, 0x5111, 0x700A, 0x6114, 0xA02D, 0x6109, 0x80FE, 0x611C, 0xA0D1, + 0x510F, 0x7006, 0x6112, 0x9084, 0x6107, 0x8011, 0x611A, 0xA0AC, 0x5121, 0x7103, 0x6116, 0xA062, + 0x610C, 0x9024, 0x611E, 0xA0F7, 0x510B, 0x611F, 0x610D, 0x902A, 0x6000, 0x7105, 0x6117, 0xA06E, + 0x5111, 0x7007, 0x6113, 0x90D0, 0x6108, 0x8019, 0x611B, 0xA0B9, 0x510F, 0x7003, 0x6110, 0x9048, + 0x6002, 0x800C, 0x6119, 0xA097, 0x5121, 0x7080, 0x6115, 0xA04A, 0x610A, 0x9016, 0x611D, 0xA0DD, + 0x510B, 0x6122, 0x610E, 0x9039, 0x6001, 0x7123, 0x6118, 0xA085, 0x5111, 0x7009, 0x6114, 0x90FD, + 0x6109, 0x80F0, 0x611C, 0xA0CB, 0x510F, 0x7005, 0x6112, 0x9081, 0x6107, 0x8010, 0x611A, 0xA0A7, + 0x5121, 0x7102, 0x6116, 0xA05D, 0x610C, 0x9021, 0x611E, 0xA0EF, 0x510B, 0x611F, 0x610D, 0x9033, + 0x6000, 0x7106, 0x6117, 0xA077, 0x5111, 0x7008, 0x6113, 0x90F2, 0x6108, 0x8040, 0x611B, 0xA0C2, + 0x510F, 0x7004, 0x6110, 0x9059, 0x6002, 0x800E, 0x6119, 0xA09F, 0x5121, 0x70FF, 0x6115, 0xA054, + 0x610A, 0x901C, 0x611D, 0xA0E6, 0x510B, 0x6122, 0x610E, 0x9041, 0x6001, 0x7124, 0x6118, 0xA08E, + 0x5111, 0x700A, 0x6114, 0xA034, 0x6109, 0x8120, 0x611C, 0xA0D5, 0x510F, 0x7006, 0x6112, 0x9090, + 0x6107, 0x8013, 0x611A, 0xA0B0, 0x5121, 0x7103, 0x6116, 0xA066, 0x610C, 0x9026, 0x611E, 0xA104, + 0x510B, 0x611F, 0x610D, 0x9027, 0x6000, 0x7105, 0x6117, 0xA069, 0x5111, 0x7007, 0x6113, 0x90C0, + 0x6108, 0x8018, 0x611B, 0xA0B4, 0x510F, 0x7003, 0x6110, 0x9042, 0x6002, 0x800B, 0x6119, 0xA092, + 0x5121, 0x7080, 0x6115, 0xA03B, 0x610A, 0x9012, 0x611D, 0xA0D8, 0x510B, 0x6122, 0x610E, 0x9035, + 0x6001, 0x7123, 0x6118, 0xA07B, 0x5111, 0x7009, 0x6114, 0x90F4, 0x6109, 0x8060, 0x611C, 0xA0C5, + 0x510F, 0x7005, 0x6112, 0x9070, 0x6107, 0x800F, 0x611A, 0xA0A2, 0x5121, 0x7102, 0x6116, 0xA057, + 0x610C, 0x901D, 0x611E, 0xA0E9, 0x510B, 0x611F, 0x610D, 0x902C, 0x6000, 0x7106, 0x6117, 0xA072, + 0x5111, 0x7008, 0x6113, 0x90E0, 0x6108, 0x8020, 0x611B, 0xA0BC, 0x510F, 0x7004, 0x6110, 0x9049, + 0x6002, 0x800D, 0x6119, 0xA09A, 0x5121, 0x70FF, 0x6115, 0xA04D, 0x610A, 0x9017, 0x611D, 0xA0E1, + 0x510B, 0x6122, 0x610E, 0x903C, 0x6001, 0x7124, 0x6118, 0xA089, 0x5111, 0x700A, 0x6114, 0xA02B, + 0x6109, 0x80FE, 0x611C, 0xA0CF, 0x510F, 0x7006, 0x6112, 0x9082, 0x6107, 0x8011, 0x611A, 0xA0AA, + 0x5121, 0x7103, 0x6116, 0xA061, 0x610C, 0x9022, 0x611E, 0xA0F6, 0x510B, 0x611F, 0x610D, 0x9029, + 0x6000, 0x7105, 0x6117, 0xA06D, 0x5111, 0x7007, 0x6113, 0x90CC, 0x6108, 0x8019, 0x611B, 0xA0B8, + 0x510F, 0x7003, 0x6110, 0x9044, 0x6002, 0x800C, 0x6119, 0xA096, 0x5121, 0x7080, 0x6115, 0xA047, + 0x610A, 0x9015, 0x611D, 0xA0DC, 0x510B, 0x6122, 0x610E, 0x9038, 0x6001, 0x7123, 0x6118, 0xA083, + 0x5111, 0x7009, 0x6114, 0x90FC, 0x6109, 0x80F0, 0x611C, 0xA0CA, 0x510F, 0x7005, 0x6112, 0x907F, + 0x6107, 0x8010, 0x611A, 0xA0A6, 0x5121, 0x7102, 0x6116, 0xA05C, 0x610C, 0x901F, 0x611E, 0xA0ED, + 0x510B, 0x611F, 0x610D, 0x9031, 0x6000, 0x7106, 0x6117, 0xA076, 0x5111, 0x7008, 0x6113, 0x90F1, + 0x6108, 0x8040, 0x611B, 0xA0C1, 0x510F, 0x7004, 0x6110, 0x9051, 0x6002, 0x800E, 0x6119, 0xA09E, + 0x5121, 0x70FF, 0x6115, 0xA053, 0x610A, 0x901B, 0x611D, 0xA0E5, 0x510B, 0x6122, 0x610E, 0x903F, + 0x6001, 0x7124, 0x6118, 0xA08D, 0x5111, 0x700A, 0x6114, 0xA032, 0x6109, 0x8120, 0x611C, 0xA0D4, + 0x510F, 0x7006, 0x6112, 0x9088, 0x6107, 0x8013, 0x611A, 0xA0AF, 0x5121, 0x7103, 0x6116, 0xA065, + 0x610C, 0x9025, 0x611E, 0xA0FB, 0x510B, 0x611F, 0x610D, 0x9028, 0x6000, 0x7105, 0x6117, 0xA06B, + 0x5111, 0x7007, 0x6113, 0x90C6, 0x6108, 0x8018, 0x611B, 0xA0B6, 0x510F, 0x7003, 0x6110, 0x9043, + 0x6002, 0x800B, 0x6119, 0xA094, 0x5121, 0x7080, 0x6115, 0xA045, 0x610A, 0x9014, 0x611D, 0xA0DA, + 0x510B, 0x6122, 0x610E, 0x9037, 0x6001, 0x7123, 0x6118, 0xA07D, 0x5111, 0x7009, 0x6114, 0x90F8, + 0x6109, 0x8060, 0x611C, 0xA0C8, 0x510F, 0x7005, 0x6112, 0x9078, 0x6107, 0x800F, 0x611A, 0xA0A4, + 0x5121, 0x7102, 0x6116, 0xA05A, 0x610C, 0x901E, 0x611E, 0xA0EB, 0x510B, 0x611F, 0x610D, 0x9030, + 0x6000, 0x7106, 0x6117, 0xA074, 0x5111, 0x7008, 0x6113, 0x90EE, 0x6108, 0x8020, 0x611B, 0xA0BE, + 0x510F, 0x7004, 0x6110, 0x9050, 0x6002, 0x800D, 0x6119, 0xA09C, 0x5121, 0x70FF, 0x6115, 0xA04F, + 0x610A, 0x901A, 0x611D, 0xA0E3, 0x510B, 0x6122, 0x610E, 0x903E, 0x6001, 0x7124, 0x6118, 0xA08B, + 0x5111, 0x700A, 0x6114, 0xA02E, 0x6109, 0x80FE, 0x611C, 0xA0D2, 0x510F, 0x7006, 0x6112, 0x9084, + 0x6107, 0x8011, 0x611A, 0xA0AD, 0x5121, 0x7103, 0x6116, 0xA063, 0x610C, 0x9024, 0x611E, 0xA0F9, + 0x510B, 0x611F, 0x610D, 0x902A, 0x6000, 0x7105, 0x6117, 0xA06F, 0x5111, 0x7007, 0x6113, 0x90D0, + 0x6108, 0x8019, 0x611B, 0xA0BA, 0x510F, 0x7003, 0x6110, 0x9048, 0x6002, 0x800C, 0x6119, 0xA098, + 0x5121, 0x7080, 0x6115, 0xA04B, 0x610A, 0x9016, 0x611D, 0xA0DE, 0x510B, 0x6122, 0x610E, 0x9039, + 0x6001, 0x7123, 0x6118, 0xA086, 0x5111, 0x7009, 0x6114, 0x90FD, 0x6109, 0x80F0, 0x611C, 0xA0CD, + 0x510F, 0x7005, 0x6112, 0x9081, 0x6107, 0x8010, 0x611A, 0xA0A8, 0x5121, 0x7102, 0x6116, 0xA05E, + 0x610C, 0x9021, 0x611E, 0xA0F3, 0x510B, 0x611F, 0x610D, 0x9033, 0x6000, 0x7106, 0x6117, 0xA079, + 0x5111, 0x7008, 0x6113, 0x90F2, 0x6108, 0x8040, 0x611B, 0xA0C3, 0x510F, 0x7004, 0x6110, 0x9059, + 0x6002, 0x800E, 0x6119, 0xA0A0, 0x5121, 0x70FF, 0x6115, 0xA055, 0x610A, 0x901C, 0x611D, 0xA0E7, + 0x510B, 0x6122, 0x610E, 0x9041, 0x6001, 0x7124, 0x6118, 0xA08F, 0x5111, 0x700A, 0x6114, 0xA036, + 0x6109, 0x8120, 0x611C, 0xA0D6, 0x510F, 0x7006, 0x6112, 0x9090, 0x6107, 0x8013, 0x611A, 0xA0B1, + 0x5121, 0x7103, 0x6116, 0xA067, 0x610C, 0x9026, 0x611E, 0xD125, +]; + +#[rustfmt::skip] +pub(crate) static HuffTableLOM: [u16; 512] = [ + 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x500A, 0x2001, 0x4003, 0x3002, 0x5007, + 0x2001, 0x4006, 0x3004, 0x700D, 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x600B, + 0x2001, 0x4003, 0x3002, 0x5009, 0x2001, 0x4006, 0x3004, 0x8012, 0x2001, 0x4000, 0x3002, 0x4008, + 0x2001, 0x4005, 0x3004, 0x500A, 0x2001, 0x4003, 0x3002, 0x5007, 0x2001, 0x4006, 0x3004, 0x7010, + 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x600C, 0x2001, 0x4003, 0x3002, 0x5009, + 0x2001, 0x4006, 0x3004, 0x9018, 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x500A, + 0x2001, 0x4003, 0x3002, 0x5007, 0x2001, 0x4006, 0x3004, 0x700E, 0x2001, 0x4000, 0x3002, 0x4008, + 0x2001, 0x4005, 0x3004, 0x600B, 0x2001, 0x4003, 0x3002, 0x5009, 0x2001, 0x4006, 0x3004, 0x9013, + 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x500A, 0x2001, 0x4003, 0x3002, 0x5007, + 0x2001, 0x4006, 0x3004, 0x800F, 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x600C, + 0x2001, 0x4003, 0x3002, 0x5009, 0x2001, 0x4006, 0x3004, 0x901C, 0x2001, 0x4000, 0x3002, 0x4008, + 0x2001, 0x4005, 0x3004, 0x500A, 0x2001, 0x4003, 0x3002, 0x5007, 0x2001, 0x4006, 0x3004, 0x700D, + 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x600B, 0x2001, 0x4003, 0x3002, 0x5009, + 0x2001, 0x4006, 0x3004, 0x8015, 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x500A, + 0x2001, 0x4003, 0x3002, 0x5007, 0x2001, 0x4006, 0x3004, 0x7010, 0x2001, 0x4000, 0x3002, 0x4008, + 0x2001, 0x4005, 0x3004, 0x600C, 0x2001, 0x4003, 0x3002, 0x5009, 0x2001, 0x4006, 0x3004, 0x901A, + 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x500A, 0x2001, 0x4003, 0x3002, 0x5007, + 0x2001, 0x4006, 0x3004, 0x700E, 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x600B, + 0x2001, 0x4003, 0x3002, 0x5009, 0x2001, 0x4006, 0x3004, 0x9016, 0x2001, 0x4000, 0x3002, 0x4008, + 0x2001, 0x4005, 0x3004, 0x500A, 0x2001, 0x4003, 0x3002, 0x5007, 0x2001, 0x4006, 0x3004, 0x8011, + 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x600C, 0x2001, 0x4003, 0x3002, 0x5009, + 0x2001, 0x4006, 0x3004, 0x901E, 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x500A, + 0x2001, 0x4003, 0x3002, 0x5007, 0x2001, 0x4006, 0x3004, 0x700D, 0x2001, 0x4000, 0x3002, 0x4008, + 0x2001, 0x4005, 0x3004, 0x600B, 0x2001, 0x4003, 0x3002, 0x5009, 0x2001, 0x4006, 0x3004, 0x8012, + 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x500A, 0x2001, 0x4003, 0x3002, 0x5007, + 0x2001, 0x4006, 0x3004, 0x7010, 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x600C, + 0x2001, 0x4003, 0x3002, 0x5009, 0x2001, 0x4006, 0x3004, 0x9019, 0x2001, 0x4000, 0x3002, 0x4008, + 0x2001, 0x4005, 0x3004, 0x500A, 0x2001, 0x4003, 0x3002, 0x5007, 0x2001, 0x4006, 0x3004, 0x700E, + 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x600B, 0x2001, 0x4003, 0x3002, 0x5009, + 0x2001, 0x4006, 0x3004, 0x9014, 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x500A, + 0x2001, 0x4003, 0x3002, 0x5007, 0x2001, 0x4006, 0x3004, 0x800F, 0x2001, 0x4000, 0x3002, 0x4008, + 0x2001, 0x4005, 0x3004, 0x600C, 0x2001, 0x4003, 0x3002, 0x5009, 0x2001, 0x4006, 0x3004, 0x901D, + 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x500A, 0x2001, 0x4003, 0x3002, 0x5007, + 0x2001, 0x4006, 0x3004, 0x700D, 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x600B, + 0x2001, 0x4003, 0x3002, 0x5009, 0x2001, 0x4006, 0x3004, 0x8015, 0x2001, 0x4000, 0x3002, 0x4008, + 0x2001, 0x4005, 0x3004, 0x500A, 0x2001, 0x4003, 0x3002, 0x5007, 0x2001, 0x4006, 0x3004, 0x7010, + 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x600C, 0x2001, 0x4003, 0x3002, 0x5009, + 0x2001, 0x4006, 0x3004, 0x901B, 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x500A, + 0x2001, 0x4003, 0x3002, 0x5007, 0x2001, 0x4006, 0x3004, 0x700E, 0x2001, 0x4000, 0x3002, 0x4008, + 0x2001, 0x4005, 0x3004, 0x600B, 0x2001, 0x4003, 0x3002, 0x5009, 0x2001, 0x4006, 0x3004, 0x9017, + 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x500A, 0x2001, 0x4003, 0x3002, 0x5007, + 0x2001, 0x4006, 0x3004, 0x8011, 0x2001, 0x4000, 0x3002, 0x4008, 0x2001, 0x4005, 0x3004, 0x600C, + 0x2001, 0x4003, 0x3002, 0x5009, 0x2001, 0x4006, 0x3004, 0x901F, +]; + +#[rustfmt::skip] +pub(crate) static HuffLengthLEC: [u8; 294] = [ + 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 8, + 8, 8, 8, 8, 8, 8, 9, 8, 9, 9, 9, 9, + 8, 8, 9, 9, 9, 9, 9, 9, 8, 9, 9, 10, + 9, 9, 9, 9, 9, 9, 9, 10, 9, 10, 10, 10, + 9, 9, 10, 9, 10, 9, 10, 9, 9, 9, 10, 10, + 9, 10, 9, 9, 8, 9, 9, 9, 9, 10, 10, 10, + 9, 9, 10, 10, 10, 10, 10, 10, 9, 9, 10, 10, + 10, 10, 10, 10, 10, 9, 10, 10, 10, 10, 10, 10, + 8, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 9, 10, 10, 10, 10, 10, 10, 10, + 9, 10, 10, 10, 10, 10, 10, 9, 7, 9, 9, 10, + 9, 10, 10, 10, 9, 10, 10, 10, 10, 10, 10, 10, + 9, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 13, 10, 10, 10, 10, 10, 10, 11, 10, + 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, + 9, 10, 10, 10, 10, 10, 9, 10, 10, 10, 10, 10, + 9, 10, 10, 10, 9, 10, 10, 10, 10, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, 10, 9, 10, 10, 10, + 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 9, 10, + 8, 9, 9, 10, 9, 10, 10, 10, 9, 10, 10, 10, + 9, 9, 8, 7, 13, 13, 7, 7, 10, 7, 7, 6, + 6, 6, 6, 5, 6, 6, 6, 5, 6, 5, 6, 6, + 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, + 8, 5, 6, 7, 7, 13, +]; + +#[rustfmt::skip] +pub(crate) static HuffCodeLEC: [u8; 588] = [ + 0x04, 0x00, 0x24, 0x00, 0x14, 0x00, 0x11, 0x00, 0x51, 0x00, 0x31, 0x00, + 0x71, 0x00, 0x09, 0x00, 0x49, 0x00, 0x29, 0x00, 0x69, 0x00, 0x15, 0x00, + 0x95, 0x00, 0x55, 0x00, 0xD5, 0x00, 0x35, 0x00, 0xB5, 0x00, 0x75, 0x00, + 0x1D, 0x00, 0xF5, 0x00, 0x1D, 0x01, 0x9D, 0x00, 0x9D, 0x01, 0x5D, 0x00, + 0x0D, 0x00, 0x8D, 0x00, 0x5D, 0x01, 0xDD, 0x00, 0xDD, 0x01, 0x3D, 0x00, + 0x3D, 0x01, 0xBD, 0x00, 0x4D, 0x00, 0xBD, 0x01, 0x7D, 0x00, 0x6B, 0x00, + 0x7D, 0x01, 0xFD, 0x00, 0xFD, 0x01, 0x03, 0x00, 0x03, 0x01, 0x83, 0x00, + 0x83, 0x01, 0x6B, 0x02, 0x43, 0x00, 0x6B, 0x01, 0x6B, 0x03, 0xEB, 0x00, + 0x43, 0x01, 0xC3, 0x00, 0xEB, 0x02, 0xC3, 0x01, 0xEB, 0x01, 0x23, 0x00, + 0xEB, 0x03, 0x23, 0x01, 0xA3, 0x00, 0xA3, 0x01, 0x1B, 0x00, 0x1B, 0x02, + 0x63, 0x00, 0x1B, 0x01, 0x63, 0x01, 0xE3, 0x00, 0xCD, 0x00, 0xE3, 0x01, + 0x13, 0x00, 0x13, 0x01, 0x93, 0x00, 0x1B, 0x03, 0x9B, 0x00, 0x9B, 0x02, + 0x93, 0x01, 0x53, 0x00, 0x9B, 0x01, 0x9B, 0x03, 0x5B, 0x00, 0x5B, 0x02, + 0x5B, 0x01, 0x5B, 0x03, 0x53, 0x01, 0xD3, 0x00, 0xDB, 0x00, 0xDB, 0x02, + 0xDB, 0x01, 0xDB, 0x03, 0x3B, 0x00, 0x3B, 0x02, 0x3B, 0x01, 0xD3, 0x01, + 0x3B, 0x03, 0xBB, 0x00, 0xBB, 0x02, 0xBB, 0x01, 0xBB, 0x03, 0x7B, 0x00, + 0x2D, 0x00, 0x7B, 0x02, 0x7B, 0x01, 0x7B, 0x03, 0xFB, 0x00, 0xFB, 0x02, + 0xFB, 0x01, 0xFB, 0x03, 0x07, 0x00, 0x07, 0x02, 0x07, 0x01, 0x07, 0x03, + 0x87, 0x00, 0x87, 0x02, 0x87, 0x01, 0x87, 0x03, 0x33, 0x00, 0x47, 0x00, + 0x47, 0x02, 0x47, 0x01, 0x47, 0x03, 0xC7, 0x00, 0xC7, 0x02, 0xC7, 0x01, + 0x33, 0x01, 0xC7, 0x03, 0x27, 0x00, 0x27, 0x02, 0x27, 0x01, 0x27, 0x03, + 0xA7, 0x00, 0xB3, 0x00, 0x19, 0x00, 0xB3, 0x01, 0x73, 0x00, 0xA7, 0x02, + 0x73, 0x01, 0xA7, 0x01, 0xA7, 0x03, 0x67, 0x00, 0xF3, 0x00, 0x67, 0x02, + 0x67, 0x01, 0x67, 0x03, 0xE7, 0x00, 0xE7, 0x02, 0xE7, 0x01, 0xE7, 0x03, + 0xF3, 0x01, 0x17, 0x00, 0x17, 0x02, 0x17, 0x01, 0x17, 0x03, 0x97, 0x00, + 0x97, 0x02, 0x97, 0x01, 0x97, 0x03, 0x57, 0x00, 0x57, 0x02, 0x57, 0x01, + 0x57, 0x03, 0xD7, 0x00, 0xD7, 0x02, 0xD7, 0x01, 0xD7, 0x03, 0x37, 0x00, + 0x37, 0x02, 0x37, 0x01, 0x37, 0x03, 0xB7, 0x00, 0xB7, 0x02, 0xB7, 0x01, + 0xB7, 0x03, 0x77, 0x00, 0x77, 0x02, 0xFF, 0x07, 0x77, 0x01, 0x77, 0x03, + 0xF7, 0x00, 0xF7, 0x02, 0xF7, 0x01, 0xF7, 0x03, 0xFF, 0x03, 0x0F, 0x00, + 0x0F, 0x02, 0x0F, 0x01, 0x0F, 0x03, 0x8F, 0x00, 0x8F, 0x02, 0x8F, 0x01, + 0x8F, 0x03, 0x4F, 0x00, 0x4F, 0x02, 0x4F, 0x01, 0x4F, 0x03, 0xCF, 0x00, + 0x0B, 0x00, 0xCF, 0x02, 0xCF, 0x01, 0xCF, 0x03, 0x2F, 0x00, 0x2F, 0x02, + 0x0B, 0x01, 0x2F, 0x01, 0x2F, 0x03, 0xAF, 0x00, 0xAF, 0x02, 0xAF, 0x01, + 0x8B, 0x00, 0xAF, 0x03, 0x6F, 0x00, 0x6F, 0x02, 0x8B, 0x01, 0x6F, 0x01, + 0x6F, 0x03, 0xEF, 0x00, 0xEF, 0x02, 0xEF, 0x01, 0xEF, 0x03, 0x1F, 0x00, + 0x1F, 0x02, 0x1F, 0x01, 0x1F, 0x03, 0x9F, 0x00, 0x9F, 0x02, 0x9F, 0x01, + 0x9F, 0x03, 0x5F, 0x00, 0x4B, 0x00, 0x5F, 0x02, 0x5F, 0x01, 0x5F, 0x03, + 0xDF, 0x00, 0xDF, 0x02, 0xDF, 0x01, 0xDF, 0x03, 0x3F, 0x00, 0x3F, 0x02, + 0x3F, 0x01, 0x3F, 0x03, 0xBF, 0x00, 0xBF, 0x02, 0x4B, 0x01, 0xBF, 0x01, + 0xAD, 0x00, 0xCB, 0x00, 0xCB, 0x01, 0xBF, 0x03, 0x2B, 0x00, 0x7F, 0x00, + 0x7F, 0x02, 0x7F, 0x01, 0x2B, 0x01, 0x7F, 0x03, 0xFF, 0x00, 0xFF, 0x02, + 0xAB, 0x00, 0xAB, 0x01, 0x6D, 0x00, 0x59, 0x00, 0xFF, 0x17, 0xFF, 0x0F, + 0x39, 0x00, 0x79, 0x00, 0xFF, 0x01, 0x05, 0x00, 0x45, 0x00, 0x34, 0x00, + 0x0C, 0x00, 0x2C, 0x00, 0x1C, 0x00, 0x00, 0x00, 0x3C, 0x00, 0x02, 0x00, + 0x22, 0x00, 0x10, 0x00, 0x12, 0x00, 0x08, 0x00, 0x32, 0x00, 0x0A, 0x00, + 0x2A, 0x00, 0x1A, 0x00, 0x3A, 0x00, 0x06, 0x00, 0x26, 0x00, 0x16, 0x00, + 0x36, 0x00, 0x0E, 0x00, 0x2E, 0x00, 0x1E, 0x00, 0x3E, 0x00, 0x01, 0x00, + 0xED, 0x00, 0x18, 0x00, 0x21, 0x00, 0x25, 0x00, 0x65, 0x00, 0xFF, 0x1F, +]; + +#[rustfmt::skip] +pub(crate) static HuffLengthLOM: [u8; 32] = [ + 4, 2, 3, 4, 3, 4, 4, 5, 4, 5, 5, 6, + 6, 7, 7, 8, 7, 8, 8, 9, 9, 8, 9, 9, + 9, 9, 9, 9, 9, 9, 9, 9, +]; + +#[rustfmt::skip] +pub(crate) static HuffCodeLOM: [u16; 32] = [ + 0x0001, 0x0000, 0x0002, 0x0009, 0x0006, 0x0005, 0x000D, 0x000B, 0x0003, 0x001B, 0x0007, 0x0017, + 0x0037, 0x000F, 0x004F, 0x006F, 0x002F, 0x00EF, 0x001F, 0x005F, 0x015F, 0x009F, 0x00DF, 0x01DF, + 0x003F, 0x013F, 0x00BF, 0x01BF, 0x007F, 0x017F, 0x00FF, 0x01FF, +]; + +#[rustfmt::skip] +pub(crate) static CopyOffsetBitsLUT: [u32; 32] = [ + 0x0, 0x0, 0x0, 0x0, 0x1, 0x1, 0x2, 0x2, 0x3, 0x3, 0x4, 0x4, + 0x5, 0x5, 0x6, 0x6, 0x7, 0x7, 0x8, 0x8, 0x9, 0x9, 0xA, 0xA, + 0xB, 0xB, 0xC, 0xC, 0xD, 0xD, 0xE, 0xE, +]; + +#[rustfmt::skip] +pub(crate) static CopyOffsetBaseLUT: [u32; 32] = [ + 0x1, 0x2, 0x3, 0x4, 0x5, 0x7, 0x9, 0xD, 0x11, 0x19, 0x21, 0x31, + 0x41, 0x61, 0x81, 0xC1, 0x101, 0x181, 0x201, 0x301, 0x401, 0x601, 0x801, 0xC01, + 0x1001, 0x1801, 0x2001, 0x3001, 0x4001, 0x6001, 0x8001, 0xC001, +]; + +#[rustfmt::skip] +pub(crate) static LOMBitsLUT: [u32; 30] = [ + 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, 0x1, 0x1, 0x1, + 0x2, 0x2, 0x2, 0x2, 0x3, 0x3, 0x3, 0x3, 0x4, 0x4, 0x4, 0x4, + 0x6, 0x6, 0x8, 0x8, 0xE, 0xE, +]; + +#[rustfmt::skip] +pub(crate) static LOMBaseLUT: [u32; 30] = [ + 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xA, 0xC, 0xE, 0x10, + 0x12, 0x16, 0x1A, 0x1E, 0x22, 0x2A, 0x32, 0x3A, 0x42, 0x52, 0x62, 0x72, + 0x82, 0xC2, 0x102, 0x202, 0x2, 0x2, +]; diff --git a/crates/ironrdp-bulk/src/ncrush/test_data.rs b/crates/ironrdp-bulk/src/ncrush/test_data.rs new file mode 100644 index 0000000000..1a1967e30c --- /dev/null +++ b/crates/ironrdp-bulk/src/ncrush/test_data.rs @@ -0,0 +1,15 @@ +// NCRUSH (RDP 6.0) test data. + +/// Plaintext "bells" test string used by both compress and decompress tests. +pub(super) const TEST_BELLS_DATA: &[u8] = b"for.whom.the.bell.tolls,.the.bell.tolls.for.thee!"; + +/// NCRUSH-compressed form of `TEST_BELLS_DATA`. +#[rustfmt::skip] +pub(super) const TEST_BELLS_NCRUSH: &[u8] = &[ + 0xfb, 0x1d, 0x7e, 0xe4, 0xda, 0xc7, 0x1d, 0x70, + 0xf8, 0xa1, 0x6b, 0x1f, 0x7d, 0xc0, 0xbe, 0x6b, + 0xef, 0xb5, 0xef, 0x21, 0x87, 0xd0, 0xc5, 0xe1, + 0x85, 0x71, 0xd4, 0x10, 0x16, 0xe7, 0xda, 0xfb, + 0x1d, 0x7e, 0xe4, 0xda, 0x47, 0x1f, 0xb0, 0xef, + 0xbe, 0xbd, 0xff, 0x2f, +]; diff --git a/crates/ironrdp-bulk/src/xcrush/mod.rs b/crates/ironrdp-bulk/src/xcrush/mod.rs new file mode 100644 index 0000000000..bc3c80df82 --- /dev/null +++ b/crates/ironrdp-bulk/src/xcrush/mod.rs @@ -0,0 +1,1592 @@ +//! XCRUSH (RDP 6.1) two-level compression implementation. +//! +//! Level 1 uses chunk-based matching; Level 2 uses MPPC. + +#[cfg(test)] +mod test_data; + +#[cfg(not(feature = "std"))] +use alloc::{boxed::Box, vec, vec::Vec}; + +use crate::error::BulkError; +use crate::flags; +use crate::mppc::MppcContext; + +/// History buffer size for XCRUSH (2 MB). +/// +/// XCRUSH uses a much larger history buffer than MPPC (8K/64K). +pub(crate) const HISTORY_BUFFER_SIZE: usize = 2_000_000; + +/// Block buffer size for XCRUSH temporary data (16 KB). +pub(crate) const BLOCK_BUFFER_SIZE: usize = 16384; + +/// Maximum number of signatures tracked by the chunk computation. +pub(crate) const MAX_SIGNATURE_COUNT: usize = 1000; + +/// Maximum number of chunks in the chunk table. +pub(crate) const MAX_CHUNKS: usize = 65534; + +/// Size of the next-chunk lookup table (one entry per possible chunk hash). +pub(crate) const NEXT_CHUNKS_SIZE: usize = 65536; + +/// Maximum number of match entries (original or optimized). +pub(crate) const MAX_MATCH_COUNT: usize = 1000; + +// --------------------------------------------------------------------------- +// Helper structures +// --------------------------------------------------------------------------- + +/// Information about a single match found during chunk-based matching. +/// +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct XCrushMatchInfo { + /// Byte offset into the history buffer where the match starts. + pub(crate) match_offset: u32, + /// Byte offset of the chunk that contains the match. + pub(crate) chunk_offset: u32, + /// Length of the matching region in bytes. + pub(crate) match_length: u32, +} + +/// A chunk descriptor in the chunk hash table. +/// +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct XCrushChunk { + /// Starting offset of this chunk in the history buffer. + pub(crate) offset: u32, + /// Index of the next chunk entry in the chain (0 = end of chain). + pub(crate) next: u32, +} + +/// A rolling-hash signature describing one chunk. +/// +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct XCrushSignature { + /// The rolling hash seed value at this chunk boundary. + pub(crate) seed: u16, + /// The size of this chunk in bytes. + pub(crate) size: u16, +} + +// --------------------------------------------------------------------------- +// Main XCRUSH context +// --------------------------------------------------------------------------- + +/// XCRUSH compression/decompression context. +/// +/// Holds a 2 MB history buffer, chunk tables, signature arrays, +/// match arrays, and an inner MPPC context for Level-2 compression. +/// +pub(crate) struct XCrushContext { + /// Inner MPPC context (RDP5 / 64K) for Level-2 compression/decompression. + pub(crate) mppc: MppcContext, + /// Current write position in the history buffer. + pub(crate) history_offset: usize, + /// Total history buffer size (always 2,000,000). + pub(crate) history_buffer_size: usize, + /// 2 MB sliding-window history buffer. + pub(crate) history_buffer: Box<[u8; HISTORY_BUFFER_SIZE]>, + /// Level-2 (MPPC) compression flags carried over between calls. + pub(crate) compression_flags: u32, + /// Current index into the signatures array. + pub(crate) signature_index: usize, + /// Maximum number of signatures available. + pub(crate) signature_count: usize, + /// Rolling-hash signatures for chunk-based matching. + pub(crate) signatures: Box<[XCrushSignature; MAX_SIGNATURE_COUNT]>, + /// Head index of the chunk linked list. + pub(crate) chunk_head: u32, + /// Tail index of the chunk linked list. + pub(crate) chunk_tail: u32, + /// Chunk descriptor table (indexed by chunk ID). + pub(crate) chunks: Box<[XCrushChunk; MAX_CHUNKS]>, + /// Next-chunk lookup table (indexed by rolling hash value). + pub(crate) next_chunks: Box<[u16; NEXT_CHUNKS_SIZE]>, + /// Number of original (unoptimized) match entries found. + pub(crate) original_match_count: usize, + /// Number of optimized match entries after filtering. + pub(crate) optimized_match_count: usize, + /// Original match entries found by chunk comparison. + pub(crate) original_matches: Box<[XCrushMatchInfo; MAX_MATCH_COUNT]>, + /// Optimized match entries after removing overlaps. + pub(crate) optimized_matches: Box<[XCrushMatchInfo; MAX_MATCH_COUNT]>, +} + +/// Allocates a zeroed `Box<[u8; N]>` on the heap without touching the stack. +/// +/// Uses `vec!` to allocate on the heap, avoiding large stack frames for +/// buffers like the 2 MB XCRUSH history. +#[expect( + clippy::unnecessary_box_returns, + reason = "returning Box is intentional — avoids placing large arrays on the stack" +)] +fn heap_zeroed_u8_array() -> Box<[u8; N]> { + // Vec length is exactly N, so the try_into is infallible. + vec![0u8; N] + .into_boxed_slice() + .try_into() + .unwrap_or_else(|_| unreachable!()) +} + +/// Allocates a zeroed `Box<[u16; N]>` on the heap without touching the stack. +#[expect( + clippy::unnecessary_box_returns, + reason = "returning Box is intentional — avoids placing large arrays on the stack" +)] +fn heap_zeroed_u16_array() -> Box<[u16; N]> { + // Vec length is exactly N, so the try_into is infallible. + vec![0u16; N] + .into_boxed_slice() + .try_into() + .unwrap_or_else(|_| unreachable!()) +} + +/// Allocates a `Box<[T; N]>` filled with `T::default()` on the heap. +#[expect( + clippy::unnecessary_box_returns, + reason = "returning Box is intentional — avoids placing large arrays on the stack" +)] +fn heap_default_array() -> Box<[T; N]> { + // Vec length is exactly N, so the try_into is infallible. + vec![T::default(); N] + .into_boxed_slice() + .try_into() + .unwrap_or_else(|_| unreachable!()) +} + +impl XCrushContext { + /// Creates a new XCRUSH context. + /// + /// Large buffers (2 MB history, 512 KB chunks, etc.) are allocated on the + /// heap via `vec!` to avoid stack overflow. + pub(crate) fn new() -> Self { + let mut ctx = Self { + mppc: MppcContext::new(1), // XCRUSH always uses RDP5 MPPC + history_offset: 0, + history_buffer_size: HISTORY_BUFFER_SIZE, + history_buffer: heap_zeroed_u8_array::(), + compression_flags: 0, + signature_index: 0, + signature_count: MAX_SIGNATURE_COUNT, + signatures: heap_default_array::(), + chunk_head: 1, + chunk_tail: 1, + chunks: heap_default_array::(), + next_chunks: heap_zeroed_u16_array::(), + original_match_count: 0, + optimized_match_count: 0, + original_matches: heap_default_array::(), + optimized_matches: heap_default_array::(), + }; + ctx.reset(false); + ctx + } + + /// Decompresses Level-1 (chunk-based matching) XCRUSH data. + /// + /// Parses the RDP 6.1 compressed data format: reads match count, match + /// details array, and literal data. Reconstructs output by interleaving + /// literal copies with history match copies. + /// + /// + /// Returns a reference to the decompressed data in the history buffer. + #[expect( + clippy::as_conversions, + reason = "u32::from_le_bytes for match_history_offset: bounded by history buffer size (2MB)" + )] + pub(crate) fn decompress_l1<'a>(&'a mut self, src_data: &[u8], l1_flags: u32) -> Result<&'a [u8], BulkError> { + if src_data.is_empty() { + return Err(BulkError::InvalidCompressedData("XCRUSH L1: empty input")); + } + + if l1_flags & flags::L1_PACKET_AT_FRONT != 0 { + self.history_offset = 0; + } + + let history_buffer_size = self.history_buffer_size; + let mut history_ptr = self.history_offset; + let output_start = history_ptr; + + // Track current position in the literal data + let mut literals_start: usize; + + if l1_flags & flags::L1_NO_COMPRESSION != 0 { + // No L1 compression — entire input is literal data + literals_start = 0; + } else { + if l1_flags & flags::L1_COMPRESSED == 0 { + return Err(BulkError::InvalidCompressedData( + "XCRUSH L1: neither compressed nor uncompressed", + )); + } + + if src_data.len() < 2 { + return Err(BulkError::InvalidCompressedData("XCRUSH L1: too short for match count")); + } + + let match_count = usize::from(u16::from_le_bytes([src_data[0], src_data[1]])); + + // Each RDP61_MATCH_DETAILS entry is 8 bytes (u16 + u16 + u32) + let match_details_end = 2 + match_count * 8; + + if match_details_end > src_data.len() { + return Err(BulkError::InvalidCompressedData( + "XCRUSH L1: match details exceed input", + )); + } + + literals_start = match_details_end; + let mut output_offset: usize = 0; + + for i in 0..match_count { + let d = 2 + i * 8; + let match_length = usize::from(u16::from_le_bytes([src_data[d], src_data[d + 1]])); + let match_output_offset = usize::from(u16::from_le_bytes([src_data[d + 2], src_data[d + 3]])); + let match_history_offset = + u32::from_le_bytes([src_data[d + 4], src_data[d + 5], src_data[d + 6], src_data[d + 7]]) as usize; + + if match_output_offset < output_offset { + return Err(BulkError::InvalidCompressedData( + "XCRUSH L1: match output offset out of order", + )); + } + if match_length > history_buffer_size { + return Err(BulkError::InvalidCompressedData( + "XCRUSH L1: match length exceeds history buffer", + )); + } + if match_history_offset > history_buffer_size { + return Err(BulkError::InvalidCompressedData( + "XCRUSH L1: match history offset exceeds history buffer", + )); + } + + // Copy literal bytes between the previous output position and this match + let literal_length = match_output_offset - output_offset; + + if literal_length > history_buffer_size { + return Err(BulkError::InvalidCompressedData( + "XCRUSH L1: literal gap exceeds history buffer", + )); + } + + if literal_length > 0 { + let literals_end = literals_start + literal_length; + + if history_ptr + literal_length >= history_buffer_size + || literals_start >= src_data.len() + || literals_end > src_data.len() + { + return Err(BulkError::InvalidCompressedData( + "XCRUSH L1: literal copy out of bounds", + )); + } + + self.history_buffer[history_ptr..history_ptr + literal_length] + .copy_from_slice(&src_data[literals_start..literals_end]); + history_ptr += literal_length; + literals_start = literals_end; + output_offset += literal_length; + + if literals_start > src_data.len() { + return Err(BulkError::InvalidCompressedData( + "XCRUSH L1: literals past end of input", + )); + } + } + + // Copy match data from history buffer + if history_ptr + match_length >= history_buffer_size + || match_history_offset + match_length >= history_buffer_size + { + return Err(BulkError::InvalidCompressedData("XCRUSH L1: match copy out of bounds")); + } + + // Copy match data from history buffer. + let distance = history_ptr.saturating_sub(match_history_offset); + if distance >= match_length { + // Fast path: no overlap — bulk copy. + self.history_buffer + .copy_within(match_history_offset..match_history_offset + match_length, history_ptr); + } else { + // Slow path: overlapping (LZ77-style). Must copy left-to-right + // so earlier output feeds later reads. + for i in 0..match_length { + self.history_buffer[history_ptr + i] = self.history_buffer[match_history_offset + i]; + } + } + output_offset += match_length; + history_ptr += match_length; + } + } + + // Copy any remaining literals after all matches + if literals_start < src_data.len() { + let remaining = src_data.len() - literals_start; + + if history_ptr + remaining >= history_buffer_size || literals_start + remaining > src_data.len() { + return Err(BulkError::InvalidCompressedData( + "XCRUSH L1: trailing literal copy out of bounds", + )); + } + + self.history_buffer[history_ptr..history_ptr + remaining].copy_from_slice(&src_data[literals_start..]); + history_ptr += remaining; + } + + self.history_offset = history_ptr; + let output_end = history_ptr; + + Ok(&self.history_buffer[output_start..output_end]) + } + + /// Decompresses XCRUSH (RDP 6.1) data. + /// + /// Handles all flag combinations: + /// - Level-2 (MPPC) + Level-1 decompression + /// - Level-1 only decompression + /// - No compression passthrough + /// + /// + /// Returns a reference to the decompressed data. + pub(crate) fn decompress<'a>(&'a mut self, src_data: &[u8], outer_flags: u32) -> Result<&'a [u8], BulkError> { + if src_data.len() < 2 { + return Err(BulkError::InvalidCompressedData( + "XCRUSH: input too short for L1/L2 flags", + )); + } + + let level1_compr_flags = u32::from(src_data[0]); + let level2_compr_flags = u32::from(src_data[1]); + let inner_data = &src_data[2..]; + + if outer_flags & flags::PACKET_FLUSHED != 0 { + self.history_buffer[..self.history_buffer_size].fill(0); + self.history_offset = 0; + } + + if level2_compr_flags & flags::PACKET_COMPRESSED == 0 { + // No Level-2 (MPPC) compression — go straight to L1 + return self.decompress_l1(inner_data, level1_compr_flags); + } + + // Level-2 (MPPC) decompression first + let mppc_output = self.mppc.decompress(inner_data, level2_compr_flags)?; + + // We need to copy the MPPC output to a temporary buffer because + // decompress_l1 borrows self mutably and the MPPC output lives + // in self.mppc.history_buffer. + // + // The MPPC output is at most 64K (MPPC history buffer size). + let mppc_output_copy: Vec = mppc_output.to_vec(); + + // Level-1 decompression on the MPPC output + self.decompress_l1(&mppc_output_copy, level1_compr_flags) + } + + // ======================== + // Chunk computation (compression helpers) + // ======================== + + /// Computes a hash over the first `min(32, size)` bytes of `data`. + /// + fn update_hash(data: &[u8], size: usize) -> u16 { + debug_assert!(size >= 4); + + let (mut seed, process_size) = if size > 32 { (5413u16, 32usize) } else { (5381u16, size) }; + + let end = process_size.saturating_sub(4); + let mut i = 0; + while i < end { + let val = u16::from(data[i + 3] ^ data[i]).wrapping_add(u16::from(data[i + 1]) << 8); + seed = seed.wrapping_add(val); + i += 4; + } + + seed + } + + /// Appends a chunk to the signatures array if the chunk is large enough. + /// + /// Returns `true` on success, `false` if the signature table is full + /// or the chunk size exceeds 65535. + /// + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "size is checked to be <= 65535 before truncation to u16" + )] + fn append_chunk(&mut self, data: &[u8], beg: &mut usize, end: usize) -> bool { + if self.signature_index >= self.signature_count { + return false; + } + + let size = end.saturating_sub(*beg); + + if size > 65535 { + return false; + } + + if size >= 15 { + let seed = Self::update_hash(&data[*beg..], size); + self.signatures[self.signature_index].size = size as u16; + self.signatures[self.signature_index].seed = seed; + self.signature_index += 1; + *beg = end; + } + + true + } + + /// Computes chunk boundaries using a 32-byte rolling hash. + /// + /// Splits `data` into variable-sized chunks based on where the rolling + /// hash accumulator satisfies `accumulator & 0x7F == 0`. Populates + /// the `signatures` array with hash seeds and chunk sizes. + /// + /// Returns the number of signatures computed, or 0 if the input is + /// too small (< 128 bytes) or an error occurs. + /// + pub(crate) fn compute_signatures(&mut self, data: &[u8]) -> usize { + self.signature_index = 0; + + let size = data.len(); + if size < 128 { + return 0; + } + + // Initialize the rolling hash with the first 32 bytes + let mut accumulator: u32 = 0; + for byte in &data[..32] { + let rotation = accumulator.rotate_left(1); + accumulator = u32::from(*byte) ^ rotation; + } + + let mut offset: usize = 0; // start of current chunk + let limit = size - 64; + let mut i: usize = 0; + + while i < limit { + for _ in 0..4 { + let rotation = accumulator.rotate_left(1); + accumulator = u32::from(data[i + 32]) ^ u32::from(data[i]) ^ rotation; + + if accumulator & 0x7F == 0 && !self.append_chunk(data, &mut offset, i + 32) { + return 0; + } + + i += 1; + } + } + + // Append final chunk (remaining bytes) + if offset < size && !self.append_chunk(data, &mut offset, size) { + return 0; + } + + self.signature_index + } + + // ======================== + // Compression (L1 + L2/MPPC) + // ======================== + + /// Performs Level-1 (chunk-based) compression. + /// + /// Copies `src_data` into the history buffer, computes chunk signatures, + /// finds and optimizes matches, and generates the L1 compressed output + /// format. If no matches are found or the data is too small (≤ 50 bytes), + /// falls back to no-compression mode. + /// + /// Returns `(compressed_size, l1_flags)`. + /// + pub(crate) fn compress_l1(&mut self, src_data: &[u8], dst_data: &mut [u8]) -> Result<(usize, u32), BulkError> { + let src_size = src_data.len(); + let mut l1_flags: u32 = 0; + + // Check if we need to wrap around the history buffer + if self.history_offset + src_size + 8 > self.history_buffer_size { + self.history_offset = 0; + l1_flags |= flags::L1_PACKET_AT_FRONT; + } + + let history_offset = self.history_offset; + + // Copy source data into the history buffer + self.history_buffer[history_offset..history_offset + src_size].copy_from_slice(src_data); + self.history_offset += src_size; + + if src_size > 50 { + let sig_index = self.compute_signatures(src_data); + + if sig_index > 0 { + let match_count = self.find_all_matches(sig_index, history_offset, src_size)?; + + self.original_match_count = match_count; + self.optimized_match_count = 0; + + if self.original_match_count > 0 { + self.optimize_matches()?; + } + + if self.optimized_match_count > 0 { + let compressed_size = self.generate_output(dst_data, history_offset)?; + + l1_flags |= flags::L1_COMPRESSED; + return Ok((compressed_size, l1_flags)); + } + } + } + + // No compression: output is same as input + l1_flags |= flags::L1_NO_COMPRESSION; + Ok((src_size, l1_flags)) + } + + /// Performs full XCRUSH (RDP 6.1) compression: Level-1 then Level-2 (MPPC). + /// + /// 1. Applies Level-1 chunk-based compression + /// 2. If L1 output > 50 bytes, applies Level-2 MPPC compression + /// 3. Handles fallback to uncompressed if compression doesn't help + /// 4. Writes the 2-byte [L1_flags, L2_flags] header into the output + /// + /// Returns `(output_size, outer_flags)` where `output_size` is the total + /// compressed size including the 2-byte header, and `outer_flags` contains + /// `PACKET_COMPRESSED | CompressionLevel(3)`. + /// + /// If compression fails to reduce size, returns `(src_size, 0)` indicating + /// the caller should use the original data. + /// + pub(crate) fn compress(&mut self, src_data: &[u8], output_buffer: &mut [u8]) -> Result<(usize, u32), BulkError> { + let src_size = src_data.len(); + + if src_size > BLOCK_BUFFER_SIZE { + return Err(BulkError::InvalidCompressedData("XCRUSH: input exceeds 16KB limit")); + } + + if src_size + 2 > output_buffer.len() { + return Err(BulkError::OutputBufferTooSmall { + required: src_size + 2, + available: output_buffer.len(), + }); + } + + // L1 compression into a temporary buffer (cannot borrow block_buffer + // while also mutably borrowing self for compress_l1) + let mut l1_buffer = vec![0u8; BLOCK_BUFFER_SIZE]; + let (compressed_data_size, l1_flags) = self.compress_l1(src_data, &mut l1_buffer)?; + + // Determine the L1-compressed (or original) data for L2 input + let l1_output: Vec = if l1_flags & flags::L1_COMPRESSED != 0 { + if compressed_data_size > src_size { + return Err(BulkError::InvalidCompressedData( + "XCRUSH: L1 compressed larger than input", + )); + } + l1_buffer[..compressed_data_size].to_vec() + } else { + if compressed_data_size != src_size { + return Err(BulkError::InvalidCompressedData( + "XCRUSH: L1 uncompressed size mismatch", + )); + } + src_data.to_vec() + }; + + // Try L2 (MPPC) compression if L1 output is large enough + let mut level2_compr_flags: u32 = 0; + let mut l2_status = false; + // Available space after the 2-byte header + let mut dst_size = src_size.saturating_sub(2); + let l2_data_start = 2; // first 2 bytes are L1/L2 flag header + + if compressed_data_size > 50 { + let mppc_output_buf = &mut output_buffer[l2_data_start..]; + let result = self.mppc.compress(&l1_output, mppc_output_buf); + + match result { + Ok((mppc_size, mppc_flags)) => { + level2_compr_flags = mppc_flags; + dst_size = mppc_size; + l2_status = true; + } + Err(_) => { + // MPPC compression failed + l2_status = false; + } + } + } + + // Handle fallback cases: L2 not applied or flushed + if !l2_status || (level2_compr_flags & flags::PACKET_FLUSHED != 0) { + if compressed_data_size > dst_size { + // Compression didn't help — return uncompressed + self.reset(true); + return Ok((src_size, 0)); + } + + dst_size = compressed_data_size; + output_buffer[l2_data_start..l2_data_start + compressed_data_size] + .copy_from_slice(&l1_output[..compressed_data_size]); + } + + // Handle compression flags carry-over + if level2_compr_flags & flags::PACKET_COMPRESSED != 0 { + level2_compr_flags |= self.compression_flags; + self.compression_flags = 0; + } else if level2_compr_flags & flags::PACKET_FLUSHED != 0 { + self.compression_flags = flags::PACKET_FLUSHED; + } + + // Write L1/L2 flag header + // L1 flags fit in a single byte (max value 0x17 = all flags set) + let final_l1_flags = l1_flags | flags::L1_INNER_COMPRESSION; + // Mask with 0xFF guarantees the value fits in u8, so try_from is infallible. + output_buffer[0] = u8::try_from(final_l1_flags & 0xFF).unwrap_or_else(|_| unreachable!()); + output_buffer[1] = u8::try_from(level2_compr_flags & 0xFF).unwrap_or_else(|_| unreachable!()); + + let total_size = dst_size + 2; + + if total_size > output_buffer.len() { + return Err(BulkError::OutputBufferTooSmall { + required: total_size, + available: output_buffer.len(), + }); + } + + // XCRUSH uses compression level 3 (RDP 6.1) + let outer_flags = flags::PACKET_COMPRESSED | 0x03; + + Ok((total_size, outer_flags)) + } + + // ======================== + // Match finding and optimization (compression helpers) + // ======================== + + /// Clears entries in the chunk hash table that fall within `[beg, end]`. + /// + fn clear_hash_table_range(&mut self, beg: u32, end: u32) { + for entry in self.next_chunks.iter_mut() { + let v = u32::from(*entry); + if v >= beg && v <= end { + *entry = 0; + } + } + for chunk in self.chunks[..MAX_CHUNKS].iter_mut() { + if chunk.next >= beg && chunk.next <= end { + chunk.next = 0; + } + } + } + + /// Finds the next chunk in the chain with a matching signature seed. + /// + /// Returns `Some(index)` of the next matching chunk, or `None` if + /// there is no next chunk or the chain is invalid. + /// + #[expect( + clippy::as_conversions, + reason = "u32 chunk indices widen to usize for array indexing" + )] + fn find_next_matching_chunk(&self, chunk_index: u32) -> Result, BulkError> { + if chunk_index as usize >= MAX_CHUNKS { + return Err(BulkError::InvalidCompressedData("XCRUSH: chunk index out of range")); + } + + let chunk = &self.chunks[chunk_index as usize]; + if chunk.next == 0 { + return Ok(None); + } + + if chunk_index < self.chunk_head || chunk.next >= self.chunk_head { + if chunk.next as usize >= MAX_CHUNKS { + return Err(BulkError::InvalidCompressedData( + "XCRUSH: next chunk index out of range", + )); + } + return Ok(Some(chunk.next)); + } + + Ok(None) + } + + /// Inserts a chunk into the hash table keyed by signature seed. + /// + /// Returns the index of a previously-existing chunk with the same seed + /// (for match finding), or `None` if no previous chunk exists. + /// + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "chunk indices bounded to < 65534, fit in u16; offsets bounded by 2MB history" + )] + fn insert_chunk(&mut self, signature: &XCrushSignature, offset: u32) -> Result, BulkError> { + if self.chunk_head >= 65530 { + self.chunk_head = 1; + self.chunk_tail = 1; + } + + if self.chunk_head >= self.chunk_tail { + self.clear_hash_table_range(self.chunk_tail, self.chunk_tail + 10000); + self.chunk_tail += 10000; + } + + let index = self.chunk_head; + self.chunk_head += 1; + + if self.chunk_head as usize >= MAX_CHUNKS { + return Err(BulkError::InvalidCompressedData("XCRUSH: chunk head overflow")); + } + + self.chunks[index as usize].offset = offset; + let seed = usize::from(signature.seed); + let prev_chunk_index = if self.next_chunks[seed] != 0 { + if usize::from(self.next_chunks[seed]) >= MAX_CHUNKS { + return Err(BulkError::InvalidCompressedData( + "XCRUSH: next_chunks index out of range", + )); + } + Some(u32::from(self.next_chunks[seed])) + } else { + None + }; + + self.chunks[index as usize].next = u32::from(self.next_chunks[seed]); + self.next_chunks[seed] = index as u16; + Ok(prev_chunk_index) + } + + /// Finds the match length between two positions in the history buffer. + /// + /// Searches both forward and backward from the match point to find + /// the longest matching region. + /// + /// Returns the total match length (0 if < 11 bytes or a quick-reject + /// heuristic fails), or an error for invalid offsets. + /// + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "offsets and lengths bounded by history buffer size (2MB), fit in u32" + )] + fn find_match_length( + &self, + match_offset: usize, + chunk_offset: usize, + history_offset: usize, + src_size: usize, + max_match_length: usize, + ) -> Result, BulkError> { + let history_buffer_size = self.history_buffer_size; + let buf_end = history_offset + src_size; + + if match_offset > history_buffer_size { + return Err(BulkError::InvalidCompressedData( + "XCRUSH: match_offset exceeds history buffer", + )); + } + if chunk_offset > history_buffer_size { + return Err(BulkError::InvalidCompressedData( + "XCRUSH: chunk_offset exceeds history buffer", + )); + } + if match_offset == chunk_offset { + return Err(BulkError::InvalidCompressedData("XCRUSH: match_offset == chunk_offset")); + } + + let buf = &*self.history_buffer; + + // Quick-reject heuristic: if byte at max_match_length+1 doesn't match, skip + if match_offset + max_match_length + 1 < buf_end + && buf[match_offset + max_match_length + 1] != buf[chunk_offset + max_match_length + 1] + { + return Ok(None); + } + + // Forward matching — compare in 8-byte chunks when possible, + // then fall back to byte-by-byte for the tail. + let mut forward_len: usize = 0; + let mut fm = match_offset; + let mut fc = chunk_offset; + + // Fast path: compare 8 bytes at a time using u64 XOR + trailing zeros. + while fm + 8 <= buf_end && fc + 8 < buf.len() { + let a = u64::from_ne_bytes(buf[fm..fm + 8].try_into().unwrap_or_else(|_| unreachable!())); + let b = u64::from_ne_bytes(buf[fc..fc + 8].try_into().unwrap_or_else(|_| unreachable!())); + if a != b { + // Find the first differing byte using XOR + leading/trailing zeros. + let xor = a ^ b; + let diff_byte = if cfg!(target_endian = "little") { + xor.trailing_zeros() / 8 + } else { + xor.leading_zeros() / 8 + } as usize; + forward_len += diff_byte; + // Advance fm/fc past the matched portion so the byte-by-byte + // fallback loop doesn't double-count these bytes. + fm += diff_byte; + fc += diff_byte; + break; + } + fm += 8; + fc += 8; + forward_len += 8; + } + + // Slow path: byte-by-byte for remaining bytes. + // Use `>= buf_end` (not `> buf_end`) so we never read the byte + // just past the current block boundary. + loop { + if fm >= buf_end { + break; + } + if buf[fm] != buf[fc] { + break; + } + fm += 1; + fc += 1; + forward_len += 1; + } + + // Reverse matching + let mut reverse_len: usize = 0; + if match_offset > 0 && chunk_offset > 0 { + let mut rm = match_offset - 1; + let mut rc = chunk_offset - 1; + while rm > history_offset && rc > 0 && buf[rm] == buf[rc] { + reverse_len += 1; + if rm == 0 || rc == 0 { + break; + } + rm -= 1; + rc -= 1; + } + } + + let total_len = reverse_len + forward_len; + if total_len < 11 { + return Ok(None); + } + + let match_start = match_offset - reverse_len; + let chunk_start = chunk_offset - reverse_len; + + Ok(Some(XCrushMatchInfo { + match_offset: match_start as u32, + chunk_offset: chunk_start as u32, + match_length: total_len as u32, + })) + } + + /// Finds all matches between computed signatures and existing chunks + /// in the hash table. + /// + /// Populates `original_matches` with the best match for each signature + /// position. Returns the number of matches found. + /// + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "offsets bounded by 2MB history buffer, fit in u32; u16 sig sizes widen to usize" + )] + fn find_all_matches( + &mut self, + signature_index: usize, + history_offset: usize, + src_size: usize, + ) -> Result { + let mut j: usize = 0; + let mut src_offset: usize = 0; + let mut prev_match_end: usize = 0; + + for i in 0..signature_index { + let sig_size = self.signatures[i].size; + if sig_size == 0 { + return Err(BulkError::InvalidCompressedData("XCRUSH: signature size is zero")); + } + + let offset = (src_offset + history_offset) as u32; + + // Make a copy of the signature for insert + let sig_copy = self.signatures[i]; + let prev_chunk_idx = self.insert_chunk(&sig_copy, offset)?; + + if let Some(mut chunk_idx) = prev_chunk_idx { + if src_offset + history_offset + usize::from(sig_size) >= prev_match_end { + let mut max_match_length: usize = 0; + let mut best_match: Option = None; + let mut chunk_count: usize = 0; + + loop { + let chunk_offset = self.chunks[chunk_idx as usize].offset as usize; + + if chunk_offset < history_offset + || chunk_offset < offset as usize + || chunk_offset > src_size + history_offset + { + let result = self.find_match_length( + offset as usize, + chunk_offset, + history_offset, + src_size, + max_match_length, + )?; + + if let Some(info) = result { + let match_len = info.match_length as usize; + if match_len > max_match_length { + max_match_length = match_len; + best_match = Some(info); + if match_len > 256 { + break; + } + } + } + } + + chunk_count += 1; + if chunk_count > 4 { + break; + } + + match self.find_next_matching_chunk(chunk_idx)? { + Some(next) => chunk_idx = next, + None => break, + } + } + + if let Some(best) = best_match { + self.original_matches[j] = best; + + if (self.original_matches[j].match_offset as usize) < history_offset { + return Err(BulkError::InvalidCompressedData("XCRUSH: match offset before history")); + } + + prev_match_end = self.original_matches[j].match_length as usize + + self.original_matches[j].match_offset as usize; + j += 1; + + if j >= MAX_MATCH_COUNT { + return Err(BulkError::InvalidCompressedData("XCRUSH: too many matches")); + } + } + } + } + + src_offset += usize::from(sig_size); + if src_offset > src_size { + return Err(BulkError::InvalidCompressedData("XCRUSH: src_offset exceeds src_size")); + } + } + + if src_offset > src_size { + return Err(BulkError::InvalidCompressedData( + "XCRUSH: final src_offset exceeds src_size", + )); + } + + Ok(j) + } + + /// Optimizes matches by removing overlaps and adjusting boundaries. + /// + /// Takes the raw matches from `original_matches` and produces a + /// non-overlapping set in `optimized_matches`. + /// + /// Returns the total match length across all optimized matches. + /// + #[expect(clippy::as_conversions, reason = "u32 match_length widen to usize for total")] + fn optimize_matches(&mut self) -> Result { + let mut j: usize = 0; + let mut prev_match_end: u32 = 0; + let mut total_match_length: usize = 0; + let original_match_count = self.original_match_count; + + for i in 0..original_match_count { + let orig = self.original_matches[i]; + + if orig.match_offset <= prev_match_end { + // Overlapping: only include if the extension is large enough + if orig.match_offset < prev_match_end && orig.match_length + orig.match_offset > prev_match_end + 6 { + let match_diff = prev_match_end - orig.match_offset; + + if orig.match_length <= match_diff { + return Err(BulkError::InvalidCompressedData( + "XCRUSH: optimized match length underflow", + )); + } + if match_diff >= 20000 { + return Err(BulkError::InvalidCompressedData("XCRUSH: match diff too large")); + } + + self.optimized_matches[j] = XCrushMatchInfo { + match_offset: orig.match_offset + match_diff, + chunk_offset: orig.chunk_offset + match_diff, + match_length: orig.match_length - match_diff, + }; + + prev_match_end = self.optimized_matches[j].match_length + self.optimized_matches[j].match_offset; + total_match_length += self.optimized_matches[j].match_length as usize; + j += 1; + } + } else { + // Non-overlapping: include as-is + self.optimized_matches[j] = orig; + prev_match_end = orig.match_length + orig.match_offset; + total_match_length += orig.match_length as usize; + j += 1; + } + } + + self.optimized_match_count = j; + Ok(total_match_length) + } + + /// Generates the Level-1 compressed output format. + /// + /// Writes the match count, match details array, and literal data into + /// the output buffer. + /// + /// Returns the total size of the compressed output. + /// + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "match count fits u16 (< MAX_MATCH_COUNT=1000); \ + match_offset-history_offset fits u16; u32 match fields widen to usize" + )] + fn generate_output(&self, output_buffer: &mut [u8], history_offset: usize) -> Result { + let match_count = self.optimized_match_count; + let output_size = output_buffer.len(); + + if output_size < 2 { + return Err(BulkError::InvalidCompressedData( + "XCRUSH: output buffer too small for header", + )); + } + + // Write match count (u16 LE) + let count_bytes = (match_count as u16).to_le_bytes(); + output_buffer[0] = count_bytes[0]; + output_buffer[1] = count_bytes[1]; + + // Match details start at offset 2, each entry is 8 bytes + let match_details_end = 2 + match_count * 8; + let mut literals_pos = match_details_end; + + if literals_pos > output_size { + return Err(BulkError::InvalidCompressedData( + "XCRUSH: match details exceed output buffer", + )); + } + + // Write match detail entries + for mi in 0..match_count { + let d = 2 + mi * 8; + let m = &self.optimized_matches[mi]; + + let match_length = m.match_length as u16; + let match_output_offset = (m.match_offset as usize) + .checked_sub(history_offset) + .ok_or(BulkError::InvalidCompressedData("XCRUSH: match offset before history"))? + as u16; + let match_history_offset = m.chunk_offset; + + output_buffer[d..d + 2].copy_from_slice(&match_length.to_le_bytes()); + output_buffer[d + 2..d + 4].copy_from_slice(&match_output_offset.to_le_bytes()); + output_buffer[d + 4..d + 8].copy_from_slice(&match_history_offset.to_le_bytes()); + } + + // Write literal data (bytes between and after matches) + let mut current_offset = history_offset; + + for mi in 0..match_count { + let m = &self.optimized_matches[mi]; + let match_offset = m.match_offset as usize; + let match_length = m.match_length as usize; + + if match_offset > current_offset { + let literal_len = match_offset - current_offset; + if literals_pos + literal_len >= output_size { + return Err(BulkError::InvalidCompressedData( + "XCRUSH: literal data exceeds output buffer", + )); + } + output_buffer[literals_pos..literals_pos + literal_len] + .copy_from_slice(&self.history_buffer[current_offset..match_offset]); + literals_pos += literal_len; + current_offset = match_offset + match_length; + } else if match_offset == current_offset { + current_offset = match_offset + match_length; + } else { + return Err(BulkError::InvalidCompressedData( + "XCRUSH: match offset before current position", + )); + } + } + + // Copy trailing literals + // INVARIANT: current_offset <= self.history_offset; match lengths must not + // exceed the current block boundary (enforced by find_match_length using + // `fm >= buf_end`). A violation here indicates a bug in match generation. + debug_assert!( + current_offset <= self.history_offset, + "XCRUSH: current_offset ({current_offset}) exceeds history_offset ({})", + self.history_offset, + ); + if current_offset > self.history_offset { + return Err(BulkError::InvalidCompressedData( + "XCRUSH: match extends past block boundary", + )); + } + let trailing_len = self.history_offset - current_offset; + if literals_pos + trailing_len >= output_size { + return Err(BulkError::InvalidCompressedData( + "XCRUSH: trailing literals exceed output buffer", + )); + } + output_buffer[literals_pos..literals_pos + trailing_len] + .copy_from_slice(&self.history_buffer[current_offset..self.history_offset]); + literals_pos += trailing_len; + + Ok(literals_pos) + } + + /// Resets the XCRUSH context. + /// + /// Zeros the signature, chunk, and match arrays. + /// If `flush` is `true`, sets `history_offset` to `history_buffer_size + 1` + /// (sentinel indicating a flush). Otherwise sets `history_offset` to 0. + /// Also resets the inner MPPC context. + pub(crate) fn reset(&mut self, flush: bool) { + self.signature_index = 0; + self.signature_count = MAX_SIGNATURE_COUNT; + for sig in self.signatures.iter_mut() { + *sig = XCrushSignature::default(); + } + self.compression_flags = 0; + self.chunk_head = 1; + self.chunk_tail = 1; + for chunk in self.chunks.iter_mut() { + *chunk = XCrushChunk::default(); + } + self.next_chunks.fill(0); + for m in self.original_matches.iter_mut() { + *m = XCrushMatchInfo::default(); + } + for m in self.optimized_matches.iter_mut() { + *m = XCrushMatchInfo::default(); + } + self.original_match_count = 0; + self.optimized_match_count = 0; + + if flush { + self.history_offset = self.history_buffer_size + 1; + } else { + self.history_offset = 0; + } + + self.mppc.reset(flush); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_xcrush_context_new_decompressor() { + let ctx = XCrushContext::new(); + assert_eq!(ctx.history_buffer_size, HISTORY_BUFFER_SIZE); + assert_eq!(ctx.history_offset, 0); + assert_eq!(ctx.signature_index, 0); + assert_eq!(ctx.signature_count, MAX_SIGNATURE_COUNT); + assert_eq!(ctx.chunk_head, 1); + assert_eq!(ctx.chunk_tail, 1); + assert_eq!(ctx.compression_flags, 0); + assert_eq!(ctx.original_match_count, 0); + assert_eq!(ctx.optimized_match_count, 0); + } + + #[test] + fn test_xcrush_context_new_compressor() { + let ctx = XCrushContext::new(); + assert_eq!(ctx.history_buffer_size, HISTORY_BUFFER_SIZE); + assert_eq!(ctx.history_offset, 0); + } + + #[test] + fn test_xcrush_context_reset_no_flush() { + let mut ctx = XCrushContext::new(); + ctx.history_offset = 12345; + ctx.signature_index = 42; + ctx.chunk_head = 100; + ctx.chunk_tail = 200; + ctx.compression_flags = 0xFF; + ctx.original_match_count = 5; + ctx.optimized_match_count = 3; + + ctx.reset(false); + + assert_eq!(ctx.history_offset, 0); + assert_eq!(ctx.signature_index, 0); + assert_eq!(ctx.signature_count, MAX_SIGNATURE_COUNT); + assert_eq!(ctx.chunk_head, 1); + assert_eq!(ctx.chunk_tail, 1); + assert_eq!(ctx.compression_flags, 0); + assert_eq!(ctx.original_match_count, 0); + assert_eq!(ctx.optimized_match_count, 0); + } + + #[test] + fn test_xcrush_context_reset_flush() { + let mut ctx = XCrushContext::new(); + ctx.reset(true); + + assert_eq!(ctx.history_offset, HISTORY_BUFFER_SIZE + 1); + assert_eq!(ctx.signature_index, 0); + assert_eq!(ctx.chunk_head, 1); + assert_eq!(ctx.chunk_tail, 1); + } + + // ======================== + // L1 decompression tests + // ======================== + + #[test] + fn test_decompress_l1_no_compression() { + let mut ctx = XCrushContext::new(); + let data = b"hello, world!"; + let result = ctx + .decompress_l1(data, flags::L1_NO_COMPRESSION | flags::L1_PACKET_AT_FRONT) + .unwrap(); + assert_eq!(result, b"hello, world!"); + assert_eq!(ctx.history_offset, 13); + } + + #[test] + fn test_decompress_l1_compressed_no_matches() { + let mut ctx = XCrushContext::new(); + // Build a compressed packet with 0 matches: just literals + // Format: [match_count: u16 LE] [match_details...] [literals...] + let mut packet = Vec::new(); + packet.extend_from_slice(&0u16.to_le_bytes()); // 0 matches + packet.extend_from_slice(b"test data"); // all literals + let result = ctx + .decompress_l1(&packet, flags::L1_COMPRESSED | flags::L1_PACKET_AT_FRONT) + .unwrap(); + assert_eq!(result, b"test data"); + } + + #[test] + fn test_decompress_l1_compressed_with_match() { + let mut ctx = XCrushContext::new(); + + // Pre-populate history buffer with "ABCDEFGH" at offset 0 + ctx.history_buffer[..8].copy_from_slice(b"ABCDEFGH"); + ctx.history_offset = 8; + + // Build a compressed packet: + // - 1 match: length=4, output_offset=5, history_offset=2 (copies "CDEF" from history) + // - Literals: "Hello" (placed at output offset 0-4) + let mut packet = Vec::new(); + packet.extend_from_slice(&1u16.to_le_bytes()); // 1 match + // Match detail: MatchLength=4, MatchOutputOffset=5, MatchHistoryOffset=2 + packet.extend_from_slice(&4u16.to_le_bytes()); + packet.extend_from_slice(&5u16.to_le_bytes()); + packet.extend_from_slice(&2u32.to_le_bytes()); + // Literals: "Hello" (5 bytes before the match) + packet.extend_from_slice(b"Hello"); + + let result = ctx.decompress_l1(&packet, flags::L1_COMPRESSED).unwrap(); + + // Expected output: "Hello" + "CDEF" = "HelloCDEF" + assert_eq!(result, b"HelloCDEF"); + } + + #[test] + fn test_decompress_l1_empty_input_error() { + let mut ctx = XCrushContext::new(); + let result = ctx.decompress_l1(&[], flags::L1_COMPRESSED); + assert!(result.is_err()); + } + + #[test] + fn test_decompress_l1_invalid_flags_error() { + let mut ctx = XCrushContext::new(); + // Neither L1_NO_COMPRESSION nor L1_COMPRESSED set + let result = ctx.decompress_l1(b"data", 0); + assert!(result.is_err()); + } + + // ======================== + // Full decompress tests + // ======================== + + #[test] + fn test_decompress_no_l2_no_l1_compression() { + let mut ctx = XCrushContext::new(); + // Header: [L1_flags, L2_flags] + data + // L1_NO_COMPRESSION(0x02) | L1_PACKET_AT_FRONT(0x04) = 0x06 + let mut packet = vec![0x06u8, 0x00u8]; + packet.extend_from_slice(b"raw data here"); + + let result = ctx.decompress(&packet, 0).unwrap(); + assert_eq!(result, b"raw data here"); + } + + #[test] + fn test_decompress_too_short_error() { + let mut ctx = XCrushContext::new(); + let result = ctx.decompress(&[0x00], 0); + assert!(result.is_err()); + } + + #[test] + fn test_decompress_flushed_clears_history() { + let mut ctx = XCrushContext::new(); + // Write some data to history + ctx.history_buffer[0] = 0xFF; + ctx.history_offset = 100; + + // L1_NO_COMPRESSION(0x02) | L1_PACKET_AT_FRONT(0x04) = 0x06 + let mut packet = vec![0x06u8, 0x00u8]; + packet.extend_from_slice(b"test"); + + let result = ctx.decompress(&packet, flags::PACKET_FLUSHED).unwrap(); + assert_eq!(result, b"test"); + // History should have been cleared + assert_eq!(ctx.history_buffer[0], b't'); // first byte of "test" + } + + // ======================== + // Chunk computation tests + // ======================== + + #[test] + fn test_update_hash_small() { + // Deterministic: same input should give same hash + let data = [0x41u8, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48]; + let h1 = XCrushContext::update_hash(&data, 8); + let h2 = XCrushContext::update_hash(&data, 8); + assert_eq!(h1, h2); + // Seed 5381 for size <= 32 + assert_ne!(h1, 5381); // hash should have changed from seed + } + + #[test] + fn test_update_hash_large_uses_different_seed() { + let data = [0xAA; 64]; + let h_small = XCrushContext::update_hash(&data, 32); + let h_large = XCrushContext::update_hash(&data, 33); // > 32: seed 5413, only hashes first 32 + // Different seeds should (very likely) produce different results + assert_ne!(h_small, h_large); + } + + #[test] + fn test_compute_signatures_small_input() { + let mut ctx = XCrushContext::new(); + // Input < 128 bytes: should return 0 + let data = [0u8; 100]; + let count = ctx.compute_signatures(&data); + assert_eq!(count, 0); + } + + #[test] + fn test_compute_signatures_128_bytes() { + let mut ctx = XCrushContext::new(); + // Exactly 128 bytes: should produce at least 1 signature (the final chunk) + let mut data = [0u8; 128]; + // Fill with some non-zero data to exercise the hash + for (i, b) in data.iter_mut().enumerate() { + *b = u8::try_from(i & 0xFF).unwrap(); + } + let count = ctx.compute_signatures(&data); + // Should have at least 1 signature (the trailing chunk) + assert!(count >= 1, "expected at least 1 signature, got {count}"); + } + + #[test] + fn test_compute_signatures_large_input() { + let mut ctx = XCrushContext::new(); + // ~1 KB of sequential data + let mut data = [0u8; 1024]; + for (i, b) in data.iter_mut().enumerate() { + *b = u8::try_from(i.wrapping_mul(17) & 0xFF).unwrap(); + } + let count = ctx.compute_signatures(&data); + // Should have some signatures (depends on rolling hash behavior) + assert!(count >= 1, "expected at least 1 signature, got {count}"); + // All signatures should have non-zero size + for sig in &ctx.signatures[..count] { + assert!(sig.size > 0, "signature size should be > 0"); + } + } + + #[test] + fn test_compute_signatures_deterministic() { + let mut ctx1 = XCrushContext::new(); + let mut ctx2 = XCrushContext::new(); + let data = b"The quick brown fox jumps over the lazy dog repeatedly and repeatedly and repeatedly until we get enough data to reach the minimum threshold for xcrush chunk computation which is 128 bytes of input data."; + let count1 = ctx1.compute_signatures(data); + let count2 = ctx2.compute_signatures(data); + assert_eq!(count1, count2); + for i in 0..count1 { + assert_eq!(ctx1.signatures[i].seed, ctx2.signatures[i].seed); + assert_eq!(ctx1.signatures[i].size, ctx2.signatures[i].size); + } + } + + // ======================== + // XCRUSH compression/decompression round-trip tests + // ======================== + + /// Helper: compress data with XCRUSH, then decompress, and verify round-trip. + fn assert_xcrush_roundtrip(input: &[u8], label: &str) { + let mut compressor = XCrushContext::new(); + let mut decompressor = XCrushContext::new(); + let mut output_buf = vec![0u8; 65536]; + + let (compressed_size, outer_flags) = compressor + .compress(input, &mut output_buf) + .unwrap_or_else(|e| panic!("[{label}] compress failed: {e}")); + + if outer_flags == 0 { + // Compression didn't help — original data should be used + // Verify the original data itself + return; + } + + assert!( + outer_flags & flags::PACKET_COMPRESSED != 0, + "[{label}] expected PACKET_COMPRESSED in flags" + ); + + let result = decompressor + .decompress(&output_buf[..compressed_size], outer_flags) + .unwrap_or_else(|e| panic!("[{label}] decompress failed: {e}")); + + assert_eq!( + result, + input, + "[{label}] round-trip mismatch: decompressed {} bytes, expected {}", + result.len(), + input.len() + ); + } + + /// Test: XCRUSH compress bells data (49 bytes). + /// + /// Since BELLS is only 49 bytes (≤ 50-byte threshold), XCRUSH does NOT + /// compress it and returns the original source data with flags=0. + #[test] + fn test_xcrush_compress_bells() { + use super::test_data::{TEST_BELLS_DATA, TEST_BELLS_DATA_XCRUSH}; + + let mut ctx = XCrushContext::new(); + let mut output_buf = vec![0u8; 65536]; + + let (size, flags_out) = ctx.compress(TEST_BELLS_DATA, &mut output_buf).unwrap(); + + // Bells is < 50 bytes, so XCRUSH falls back to uncompressed. + // Returns flags=0 and points to the original source data. + assert_eq!(flags_out, 0, "bells should not be compressed (flags=0)"); + assert_eq!( + size, + TEST_BELLS_DATA_XCRUSH.len(), + "uncompressed size should match expected" + ); + } + + /// Test: XCRUSH compress island data (386 bytes). + /// + /// Verifies compressed output for the island test data. + #[test] + fn test_xcrush_compress_island() { + use super::test_data::{TEST_ISLAND_DATA, TEST_ISLAND_DATA_XCRUSH}; + + let mut ctx = XCrushContext::new(); + let mut output_buf = vec![0u8; 65536]; + + let (size, flags_out) = ctx.compress(TEST_ISLAND_DATA, &mut output_buf).unwrap(); + + assert!( + flags_out & flags::PACKET_COMPRESSED != 0, + "island should be compressed (PACKET_COMPRESSED expected in flags)" + ); + + assert_eq!( + size, + TEST_ISLAND_DATA_XCRUSH.len(), + "compressed size mismatch: got {size}, expected {}", + TEST_ISLAND_DATA_XCRUSH.len(), + ); + + assert_eq!( + &output_buf[..size], + TEST_ISLAND_DATA_XCRUSH, + "compressed output does not match expected bytes" + ); + } + + /// Round-trip test with the Island text (386 bytes). + #[test] + fn test_xcrush_roundtrip_island() { + use super::test_data::TEST_ISLAND_DATA; + assert_xcrush_roundtrip(TEST_ISLAND_DATA, "island"); + } + + /// Round-trip test with a ~500 byte text input. + #[test] + fn test_xcrush_roundtrip_500b_text() { + let mut data = Vec::new(); + let phrases: [&[u8]; 4] = [ + b"The quick brown fox jumps over the lazy dog. ".as_slice(), + b"Pack my box with five dozen liquor jugs. ".as_slice(), + b"How vexingly quick daft zebras jump. ".as_slice(), + b"Crazy Frederick bought many very exquisite opal jewels. ".as_slice(), + ]; + while data.len() < 500 { + for phrase in &phrases { + data.extend_from_slice(phrase); + } + } + data.truncate(500); + assert_xcrush_roundtrip(&data, "500b_text"); + } + + /// Round-trip test with a ~1 KB repeating pattern (overlapping LZ77 match). + #[test] + fn test_xcrush_roundtrip_1kb_pattern() { + let pattern = b"ABCDEFGHIJKLMNOP"; + let mut data = Vec::new(); + for _ in 0..64 { + data.extend_from_slice(pattern); + } + assert_xcrush_roundtrip(&data, "1kb_pattern"); + } + + /// Round-trip test with a ~4 KB pseudo-random data. + #[test] + fn test_xcrush_roundtrip_4kb() { + let mut data = vec![0u8; 4096]; + for (i, b) in data.iter_mut().enumerate() { + *b = u8::try_from((i.wrapping_mul(37).wrapping_add(113)) & 0xFF).unwrap(); + } + assert_xcrush_roundtrip(&data, "4kb_random"); + } + + /// Round-trip test with the maximum XCRUSH input (16 KB). + #[test] + fn test_xcrush_roundtrip_16kb() { + let mut data = vec![0u8; 16384]; + // Fill with text-like content that will compress well + let phrase = b"The quick brown fox jumps over the lazy dog. "; + for chunk in data.chunks_mut(phrase.len()) { + let copy_len = chunk.len().min(phrase.len()); + chunk[..copy_len].copy_from_slice(&phrase[..copy_len]); + } + assert_xcrush_roundtrip(&data, "16kb_text"); + } +} diff --git a/crates/ironrdp-bulk/src/xcrush/test_data.rs b/crates/ironrdp-bulk/src/xcrush/test_data.rs new file mode 100644 index 0000000000..86d8402397 --- /dev/null +++ b/crates/ironrdp-bulk/src/xcrush/test_data.rs @@ -0,0 +1,50 @@ +// XCRUSH test data. + +/// "for.whom.the.bell.tolls,.the.bell.tolls.for.thee!" (49 bytes) +pub(super) const TEST_BELLS_DATA: &[u8] = b"for.whom.the.bell.tolls,.the.bell.tolls.for.thee!"; + +/// Expected XCRUSH-compressed output for BELLS data. +/// +/// Since BELLS is only 49 bytes (≤ 50-byte threshold), XCRUSH does NOT +/// compress it, returning the original source data with flags=0. +/// The "expected output" is therefore identical to the input. +pub(super) const TEST_BELLS_DATA_XCRUSH: &[u8] = b"for.whom.the.bell.tolls,.the.bell.tolls.for.thee!"; + +/// Island text (386 bytes) used as XCRUSH compression test input. +pub(super) const TEST_ISLAND_DATA: &[u8] = b"No man is an island entire of itself; every man \ +is a piece of the continent, a part of the main; \ +if a clod be washed away by the sea, Europe \ +is the less, as well as if a promontory were, as\ +well as any manner of thy friends or of thine \ +own were; any man's death diminishes me, \ +because I am involved in mankind. \ +And therefore never send to know for whom \ +the bell tolls; it tolls for thee."; + +/// Expected XCRUSH-compressed output for the Island text (299 bytes). +/// +/// Format: [L1_flags, L2_flags, MPPC-compressed-L1-data...] +/// - L1_flags = 0x12 = L1_INNER_COMPRESSION | L1_COMPRESSED +/// - L2_flags = 0x61 = PACKET_AT_FRONT | PACKET_COMPRESSED | RDP5 +#[rustfmt::skip] +pub(super) const TEST_ISLAND_DATA_XCRUSH: &[u8] = &[ + 0x12, 0x61, 0x4e, 0x6f, 0x20, 0x6d, 0x61, 0x6e, 0x20, 0x69, 0x73, 0x20, 0xf8, 0xd2, 0xd8, 0xc2, + 0xdc, 0xc8, 0x40, 0xca, 0xdc, 0xe8, 0xd2, 0xe4, 0xca, 0x40, 0xde, 0xcc, 0x40, 0xd2, 0xe8, 0xe6, + 0xca, 0xd8, 0xcc, 0x76, 0x40, 0xca, 0xec, 0xca, 0xe4, 0xf3, 0xfa, 0x71, 0x20, 0x70, 0x69, 0x65, + 0x63, 0xfc, 0x12, 0xe8, 0xd0, 0xca, 0x40, 0xc6, 0xdf, 0xfb, 0xcd, 0xdf, 0xd0, 0x58, 0x40, 0xc2, + 0x40, 0xe0, 0xc2, 0xe4, 0xe9, 0xfe, 0x63, 0xec, 0xc3, 0x6b, 0x0b, 0x4b, 0x71, 0xd9, 0x03, 0x4b, + 0x37, 0xd7, 0x31, 0xb6, 0x37, 0xb2, 0x10, 0x31, 0x32, 0x90, 0x3b, 0xb0, 0xb9, 0xb4, 0x32, 0xb2, + 0x10, 0x30, 0xbb, 0xb0, 0xbc, 0x90, 0x31, 0x3c, 0x90, 0x7e, 0x68, 0x73, 0x65, 0x61, 0x2c, 0x20, + 0x45, 0x75, 0x72, 0x6f, 0x70, 0x65, 0xf2, 0x34, 0x7d, 0x38, 0x6c, 0x65, 0x73, 0x73, 0xf0, 0x69, + 0xcc, 0x81, 0xdd, 0x95, 0xb1, 0xb0, 0x81, 0x85, 0xcf, 0xc0, 0x94, 0xe0, 0xe4, 0xde, 0xdb, 0xe2, + 0xb3, 0x7f, 0x92, 0x4e, 0xec, 0xae, 0x4c, 0xbf, 0x86, 0x3f, 0x06, 0x0c, 0x2d, 0xde, 0x5d, 0x96, + 0xe6, 0x57, 0x2f, 0x1e, 0x53, 0xc9, 0x03, 0x33, 0x93, 0x4b, 0x2b, 0x73, 0x23, 0x99, 0x03, 0x7f, + 0xd2, 0xb6, 0x96, 0xef, 0x38, 0x1d, 0xdb, 0xbc, 0x24, 0x72, 0x65, 0x3b, 0xf5, 0x5b, 0xf8, 0x49, + 0x3b, 0x99, 0x03, 0x23, 0x2b, 0x0b, 0xa3, 0x41, 0x03, 0x23, 0x4b, 0x6b, 0x4b, 0x73, 0x4f, 0x96, + 0xce, 0x64, 0x0d, 0xbe, 0x19, 0x31, 0x32, 0xb1, 0xb0, 0xba, 0xb9, 0xb2, 0x90, 0x24, 0x90, 0x30, + 0xb6, 0x90, 0x34, 0xb7, 0x3b, 0x37, 0xb6, 0x3b, 0x79, 0xd4, 0xd2, 0xdd, 0xec, 0x18, 0x6b, 0x69, + 0x6e, 0x64, 0x2e, 0x20, 0x41, 0xf7, 0x33, 0xcd, 0x47, 0x26, 0x56, 0x66, 0xff, 0x74, 0x9b, 0xbd, + 0xbf, 0x04, 0x0e, 0x7e, 0x31, 0x10, 0x3a, 0x37, 0x90, 0x35, 0xb7, 0x37, 0xbb, 0x90, 0x7d, 0x81, + 0x03, 0xbb, 0x43, 0x7b, 0x6f, 0xa8, 0xe5, 0x8b, 0xd0, 0xf0, 0xe8, 0xde, 0xd8, 0xd8, 0xe7, 0xec, + 0xf3, 0xa7, 0xe4, 0x7c, 0xa7, 0xe2, 0x9f, 0x01, 0x99, 0x4b, 0x80, +]; diff --git a/crates/ironrdp-cfg/CHANGELOG.md b/crates/ironrdp-cfg/CHANGELOG.md new file mode 100644 index 0000000000..62378530ec --- /dev/null +++ b/crates/ironrdp-cfg/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [[0.1.0](https://github.com/Devolutions/IronRDP/releases/tag/ironrdp-cfg-v0.1.0)] - 2026-07-10 + +Initial release. diff --git a/crates/ironrdp-cfg/Cargo.toml b/crates/ironrdp-cfg/Cargo.toml index 270e330f48..4c5d35ce3d 100644 --- a/crates/ironrdp-cfg/Cargo.toml +++ b/crates/ironrdp-cfg/Cargo.toml @@ -3,8 +3,8 @@ name = "ironrdp-cfg" version = "0.1.0" readme = "README.md" description = "IronRDP utilities for ironrdp-cfgstore" -publish = false # TODO: publish edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true diff --git a/crates/ironrdp-cfg/src/lib.rs b/crates/ironrdp-cfg/src/lib.rs index e9a00061e0..5c8743f4bf 100644 --- a/crates/ironrdp-cfg/src/lib.rs +++ b/crates/ironrdp-cfg/src/lib.rs @@ -1,63 +1,1122 @@ -// QUESTION: consider auto-generating this file based on a reference file? -// https://gist.github.com/awakecoding/838c7fe2ed3a6208e3ca5d8af25363f6 +mod target_addr; +use std::path::PathBuf; + +pub use target_addr::{ParseTargetAddrError, TargetAddr, TargetHost}; use ironrdp_propertyset::PropertySet; +/// Property keys whose values are secrets and must never be surfaced verbatim. +/// +/// Matching is case-insensitive, so a single lowercase entry covers casing variants such as +/// `GatewayPassword`/`gatewaypassword` and `ClearTextPassword`/`cleartextpassword`. +const SECRET_KEYS: &[&str] = &[ + "cleartextpassword", // plaintext RDP account password + "gatewaypassword", // RD gateway password (both casings) + "ironrdp_rdcleanpathtoken", // RDCleanPath authentication token +]; + +/// Returns `true` when `key` names a property whose value is a secret (password or token). +/// +/// Consumers that expose property sets to untrusted readers (logs, IPC responses, dumps) should +/// redact the value of any key for which this returns `true`. The comparison is case-insensitive. +pub fn is_secret_key(key: &str) -> bool { + SECRET_KEYS.iter().any(|secret| key.eq_ignore_ascii_case(secret)) +} + +/// Error returned when the `server port` property value is outside the valid port range (1–65535). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidServerPort; + +impl core::fmt::Display for InvalidServerPort { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("server port value is out of the valid port range (1-65535)") + } +} + +impl core::error::Error for InvalidServerPort {} + +/// Error returned when a desktop dimension or scale factor property value is out of range. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidDesktopSize; + +impl core::fmt::Display for InvalidDesktopSize { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("desktop size property value is out of range") + } +} + +impl core::error::Error for InvalidDesktopSize {} + +/// Controls whether and how an RD Gateway server is used. +/// +/// Corresponds to the `gatewayusagemethod` `.rdp` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(i64)] +pub enum GatewayUsageMethod { + /// Do not use an RD Gateway server. + /// + /// RDC UI: "Bypass RD Gateway server for local addresses" is cleared. + Direct = 0, + + /// Always use the RD Gateway server. + /// + /// RDC UI: bypass-local is cleared. + UseAlways = 1, + + /// Use an RD Gateway server if a direct connection cannot be made. + /// + /// Windows semantics are "try direct, use gateway if direct fails". + /// + /// IronRDP currently does not implement that two-step fallback, and if + /// an explicit gateway hostname is present, it selects it eagerly as the best + /// available approximation. + /// + /// RDC UI: bypass-local is selected. + #[default] + Detect = 2, + + /// Use the default RD Gateway settings. + UseDefaultSettings = 3, + + /// Do not use an RD Gateway server. + /// + /// RDC UI: bypass-local is selected. + DirectBypassLocal = 4, +} + +impl GatewayUsageMethod { + /// Returns `true` when the file explicitly requires routing through a gateway server. + /// + /// This is only true for `gatewayusagemethod:i:1`. + /// `Detect` / value 2 may use a gateway, but does not require one. + /// `UseDefaultSettings` / value 3 delegates the decision to client/default policy. + pub fn is_gateway_required(self) -> bool { + matches!(self, Self::UseAlways) + } + + /// Returns `true` when this mode may result in gateway usage. + /// + /// This includes explicit gateway use, detect/on-demand gateway use, + /// and default settings, because defaults or policy may require a gateway. + pub fn may_use_gateway(self) -> bool { + matches!(self, Self::UseAlways | Self::Detect | Self::UseDefaultSettings) + } + + /// Returns the raw integer value for writing to a `.rdp` property set. + #[expect( + clippy::as_conversions, + reason = "the enum is #[repr(i64)] with explicit discriminants" + )] + pub fn as_i64(self) -> i64 { + self as i64 + } +} + +impl TryFrom for GatewayUsageMethod { + type Error = UnknownGatewayUsageMethod; + + fn try_from(value: i64) -> Result { + match value { + 0 => Ok(Self::Direct), + 1 => Ok(Self::UseAlways), + 2 => Ok(Self::Detect), + 3 => Ok(Self::UseDefaultSettings), + 4 => Ok(Self::DirectBypassLocal), + _ => Err(UnknownGatewayUsageMethod(value)), + } + } +} + +/// Error returned when a `gatewayusagemethod` value is not a recognized variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnknownGatewayUsageMethod(pub i64); + +impl core::fmt::Display for UnknownGatewayUsageMethod { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "unknown gatewayusagemethod value: {}", self.0) + } +} + +impl core::error::Error for UnknownGatewayUsageMethod {} + +/// Controls which credentials are used to authenticate to the RD Gateway. +/// +/// Corresponds to the `gatewaycredentialssource` `.rdp` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i64)] +pub enum GatewayCredentialsSource { + /// 0: Use the same credentials as the RDP server (pass-through / NTLM). + UseServerCredentials = 0, + /// 1: Use the gateway-specific user credentials. + UseUserCredentials = 1, + /// 2: Use credentials stored in a profile. + UseProfile = 2, + /// 3: Prompt the user for gateway credentials. + Prompt = 3, + /// 4: Use a smart card. + SmartCard = 4, + /// 5: Use the logged-on user's credentials. + UseLogonCredentials = 5, +} + +impl TryFrom for GatewayCredentialsSource { + type Error = UnknownGatewayCredentialsSource; + + fn try_from(value: i64) -> Result { + match value { + 0 => Ok(Self::UseServerCredentials), + 1 => Ok(Self::UseUserCredentials), + 2 => Ok(Self::UseProfile), + 3 => Ok(Self::Prompt), + 4 => Ok(Self::SmartCard), + 5 => Ok(Self::UseLogonCredentials), + _ => Err(UnknownGatewayCredentialsSource(value)), + } + } +} + +impl GatewayCredentialsSource { + /// Returns the raw integer value for writing to a `.rdp` property set. + #[expect( + clippy::as_conversions, + reason = "the enum is #[repr(i64)] with explicit discriminants" + )] + pub fn as_i64(self) -> i64 { + self as i64 + } +} + +/// Error returned when a `gatewaycredentialssource` value is not a recognized variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnknownGatewayCredentialsSource(pub i64); + +impl core::fmt::Display for UnknownGatewayCredentialsSource { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "unknown gatewaycredentialssource value: {}", self.0) + } +} + +impl core::error::Error for UnknownGatewayCredentialsSource {} + +/// Controls where audio is played during a remote session. +/// +/// Corresponds to the `audiomode` `.rdp` property. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i64)] +pub enum AudioMode { + /// 0: Redirect audio to the local (client) machine. + RedirectToClient = 0, + /// 1: Play audio on the remote computer. + PlayOnServer = 1, + /// 2: Do not play audio. + Disabled = 2, +} + +impl TryFrom for AudioMode { + type Error = UnknownAudioMode; + + fn try_from(value: i64) -> Result { + match value { + 0 => Ok(Self::RedirectToClient), + 1 => Ok(Self::PlayOnServer), + 2 => Ok(Self::Disabled), + _ => Err(UnknownAudioMode(value)), + } + } +} + +impl AudioMode { + /// Returns the raw integer value for writing to a `.rdp` property set. + #[expect( + clippy::as_conversions, + reason = "the enum is #[repr(i64)] with explicit discriminants" + )] + pub fn as_i64(self) -> i64 { + self as i64 + } +} + +/// Error returned when an `audiomode` value is not a recognized variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnknownAudioMode(pub i64); + +impl core::fmt::Display for UnknownAudioMode { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "unknown audiomode value: {}", self.0) + } +} + +impl core::error::Error for UnknownAudioMode {} + +/// Name-to-pipe mapping for a single DVC proxy channel. +#[derive(Clone, Debug)] +pub struct DvcPipeProxy { + pub channel_name: String, + pub pipe_name: String, +} + +/// Error returned when a DVC pipe proxy spec is missing the `=` delimiter between the channel +/// name and the pipe name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DvcPipeSpecMissingDelimiter; + +impl core::fmt::Display for DvcPipeSpecMissingDelimiter { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("DVC pipe proxy spec is missing the '=' delimiter") + } +} + +impl core::error::Error for DvcPipeSpecMissingDelimiter {} + +/// Typed accessors for the RDP properties IronRDP understands. +/// +/// Every property is exposed as a triplet of methods sharing the same underlying key: a getter +/// returning the parsed value (if present and valid), a `set_*` mutator writing it, and a `clear_*` +/// mutator removing it. +/// +/// Methods are grouped into three sections: +/// +/// - **Microsoft standard keys** — keys defined by the `.rdp` file format and Microsoft tooling. +/// - **IronRDP extensions** — IronRDP-specific keys, prefixed with `ironrdp_` to avoid colliding +/// with Microsoft keys. +/// - **Multi-key helpers** — convenience mutators acting on several related keys at once. +/// +/// Within each section, properties are ordered alphabetically by their getter name. pub trait PropertySetExt { - fn full_address(&self) -> Option<&str>; + // ── Microsoft standard keys ─────────────────────────────────────────────── + + /// Alternate target server address (`alternate full address`). + fn alternate_full_address(&self) -> Result, ParseTargetAddrError>; + /// Sets the `alternate full address` property. + fn set_alternate_full_address(&mut self, value: &TargetAddr); + /// Removes the `alternate full address` property. + fn clear_alternate_full_address(&mut self); + + /// Alternate shell to launch on the server instead of the desktop (`alternate shell`). + fn alternate_shell(&self) -> Option<&str>; + /// Sets the `alternate shell` property. + fn set_alternate_shell(&mut self, value: impl Into); + /// Removes the `alternate shell` property. + fn clear_alternate_shell(&mut self); + + /// Audio output redirection mode (`audiomode`). + fn audio_mode(&self) -> Result, UnknownAudioMode>; + /// Sets the `audiomode` property. + fn set_audio_mode(&mut self, value: AudioMode); + /// Removes the `audiomode` property. + fn clear_audio_mode(&mut self); + + /// Target RDP server password in clear text (`ClearTextPassword`). + /// + /// This is an MsRdpEx addition and a secret; use for testing only. + fn clear_text_password(&self) -> Option<&str>; + /// Sets the `ClearTextPassword` property. + fn set_clear_text_password(&mut self, value: impl Into); + /// Removes the `ClearTextPassword` property. + fn clear_clear_text_password(&mut self); + + /// Whether bulk compression is enabled (`compression`). + fn compression(&self) -> Option; + /// Sets the `compression` property. + fn set_compression(&mut self, value: bool); + /// Removes the `compression` property. + fn clear_compression(&mut self); - fn server_port(&self) -> Option; + /// Requested desktop height in pixels (`desktopheight`). + fn desktop_height(&self) -> Result, InvalidDesktopSize>; + /// Sets the `desktopheight` property. + fn set_desktop_height(&mut self, value: u16); + /// Removes the `desktopheight` property. + fn clear_desktop_height(&mut self); - fn alternate_full_address(&self) -> Option<&str>; + /// Requested desktop scale factor as a percentage (`desktopscalefactor`). + fn desktop_scale_factor(&self) -> Result, InvalidDesktopSize>; + /// Sets the `desktopscalefactor` property. + fn set_desktop_scale_factor(&mut self, value: u32); + /// Removes the `desktopscalefactor` property. + fn clear_desktop_scale_factor(&mut self); + /// Requested desktop width in pixels (`desktopwidth`). + fn desktop_width(&self) -> Result, InvalidDesktopSize>; + /// Sets the `desktopwidth` property. + fn set_desktop_width(&mut self, value: u16); + /// Removes the `desktopwidth` property. + fn clear_desktop_width(&mut self); + + /// Domain of the RDP account credentials (`domain`). + fn domain(&self) -> Option<&str>; + /// Sets the `domain` property. + fn set_domain(&mut self, value: String); + /// Removes the `domain` property. + fn clear_domain(&mut self); + + /// Whether CredSSP/NLA support is enabled (`enablecredsspsupport`). + fn enable_credssp_support(&self) -> Option; + /// Sets the `enablecredsspsupport` property. + fn set_enable_credssp_support(&mut self, enabled: bool); + /// Removes the `enablecredsspsupport` property. + fn clear_enable_credssp_support(&mut self); + + /// Target server address (`full address`). + fn full_address(&self) -> Result, ParseTargetAddrError>; + /// Sets the `full address` property. + fn set_full_address(&mut self, value: &TargetAddr); + /// Removes the `full address` property. + fn clear_full_address(&mut self); + + /// RD gateway credentials source (`gatewaycredentialssource`). + fn gateway_credentials_source(&self) -> Result, UnknownGatewayCredentialsSource>; + /// Sets the `gatewaycredentialssource` property. + fn set_gateway_credentials_source(&mut self, value: GatewayCredentialsSource); + /// Removes the `gatewaycredentialssource` property. + fn clear_gateway_credentials_source(&mut self); + + /// RD gateway endpoint hostname (`gatewayhostname`). fn gateway_hostname(&self) -> Option<&str>; + /// Sets the `gatewayhostname` property. + fn set_gateway_hostname(&mut self, value: impl Into); + /// Removes the `gatewayhostname` property. + fn clear_gateway_hostname(&mut self); + /// RD gateway password (`GatewayPassword`; secret). + /// + /// Reads either the `GatewayPassword` or `gatewaypassword` casing. + fn gateway_password(&self) -> Option<&str>; + /// Sets the `GatewayPassword` property (and removes the `gatewaypassword` casing). + fn set_gateway_password(&mut self, value: impl Into); + /// Removes the `GatewayPassword` property (both casings). + fn clear_gateway_password(&mut self); + + /// RD gateway usage method (`gatewayusagemethod`). + fn gateway_usage_method(&self) -> Result, UnknownGatewayUsageMethod>; + /// Sets the `gatewayusagemethod` property. + fn set_gateway_usage_method(&mut self, value: GatewayUsageMethod); + /// Removes the `gatewayusagemethod` property. + fn clear_gateway_usage_method(&mut self); + + /// RD gateway username (`gatewayusername`). + fn gateway_username(&self) -> Option<&str>; + /// Sets the `gatewayusername` property. + fn set_gateway_username(&mut self, value: impl Into); + /// Removes the `gatewayusername` property. + fn clear_gateway_username(&mut self); + + /// Kerberos KDC proxy name (`kdcproxyname`). + fn kdc_proxy_name(&self) -> Option<&str>; + /// Sets the `kdcproxyname` property. + fn set_kdc_proxy_name(&mut self, value: impl Into); + /// Removes the `kdcproxyname` property. + fn clear_kdc_proxy_name(&mut self); + + /// Kerberos KDC proxy URL (`kdcproxyurl`). + /// + /// Reads either the `kdcproxyurl` or `KDCProxyURL` casing. + fn kdc_proxy_url(&self) -> Option<&str>; + /// Sets the `kdcproxyurl` property (and removes the `KDCProxyURL` casing). + fn set_kdc_proxy_url(&mut self, value: impl Into); + /// Removes the `kdcproxyurl` property (both casings). + fn clear_kdc_proxy_url(&mut self); + + /// Whether clipboard redirection is requested (`redirectclipboard`). + fn redirect_clipboard(&self) -> Option; + /// Sets the `redirectclipboard` property. + fn set_redirect_clipboard(&mut self, value: bool); + /// Removes the `redirectclipboard` property. + fn clear_redirect_clipboard(&mut self); + + /// RemoteApp application name (`remoteapplicationname`). fn remote_application_name(&self) -> Option<&str>; + /// Sets the `remoteapplicationname` property. + fn set_remote_application_name(&mut self, value: impl Into); + /// Removes the `remoteapplicationname` property. + fn clear_remote_application_name(&mut self); + /// RemoteApp executable path or alias (`remoteapplicationprogram`). fn remote_application_program(&self) -> Option<&str>; + /// Sets the `remoteapplicationprogram` property. + fn set_remote_application_program(&mut self, value: impl Into); + /// Removes the `remoteapplicationprogram` property. + fn clear_remote_application_program(&mut self); - fn kdc_proxy_url(&self) -> Option<&str>; + /// Target server port (`server port`). + fn server_port(&self) -> Result, InvalidServerPort>; + /// Sets the `server port` property. + fn set_server_port(&mut self, value: u16); + /// Removes the `server port` property. + fn clear_server_port(&mut self); + /// Working directory for the alternate shell (`shell working directory`). + fn shell_working_directory(&self) -> Option<&str>; + /// Sets the `shell working directory` property. + fn set_shell_working_directory(&mut self, value: impl Into); + /// Removes the `shell working directory` property. + fn clear_shell_working_directory(&mut self); + + /// Username of the RDP account credentials (`username`). fn username(&self) -> Option<&str>; + /// Sets the `username` property. + fn set_username(&mut self, value: impl Into); + /// Removes the `username` property. + fn clear_username(&mut self); - /// Target RDP server password - use for testing only - fn clear_text_password(&self) -> Option<&str>; + // ── IronRDP extensions ──────────────────────────────────────────────────── + + /// Automatically log on by passing the `INFO_AUTOLOGON` flag (`ironrdp_autologon`). + fn autologon(&self) -> Option; + /// Sets the `ironrdp_autologon` property. + fn set_autologon(&mut self, enabled: bool); + /// Removes the `ironrdp_autologon` property. + fn clear_autologon(&mut self); + + /// Color depth in bits per pixel, e.g. 16 or 32 (`ironrdp_colordepth`). + fn color_depth(&self) -> Option; + /// Sets the `ironrdp_colordepth` property. + fn set_color_depth(&mut self, depth: u32); + /// Removes the `ironrdp_colordepth` property. + fn clear_color_depth(&mut self); + + /// Bulk compression level: 0=K8, 1=K64, 2=Rdp6, 3=Rdp61 (`ironrdp_compressionlevel`). + fn compression_level(&self) -> Option; + /// Sets the `ironrdp_compressionlevel` property. + fn set_compression_level(&mut self, level: u32); + /// Removes the `ironrdp_compressionlevel` property. + fn clear_compression_level(&mut self); + + /// DVC pipe proxy specifications (`ironrdp_dvcpipeproxy`). + /// + /// The underlying value is a comma-separated list of `=` entries. + fn dvc_pipe_proxies(&self) -> impl Iterator>; + /// Sets the `ironrdp_dvcpipeproxy` property from an iterator of specifications. + fn set_dvc_pipe_proxies(&mut self, specs: T) + where + T: IntoIterator; + /// Removes the `ironrdp_dvcpipeproxy` property. + fn clear_dvc_pipe_proxies(&mut self); + + /// DVC client plugin DLL paths, comma-separated; Windows only (`ironrdp_dvcplugin`). + fn dvc_plugins(&self) -> impl Iterator; + /// Sets the `ironrdp_dvcplugin` property from an iterator of paths. + fn set_dvc_plugins<'a, T>(&mut self, paths: T) + where + T: IntoIterator; + /// Removes the `ironrdp_dvcplugin` property. + fn clear_dvc_plugins(&mut self); + + /// Enable the QOI bitmap codec (`ironrdp_qoi`). + fn enable_qoi(&self) -> Option; + /// Sets the `ironrdp_qoi` property. + fn set_enable_qoi(&mut self, enabled: bool); + /// Removes the `ironrdp_qoi` property. + fn clear_enable_qoi(&mut self); + + /// Enable the QOIZ bitmap codec (`ironrdp_qoiz`). + fn enable_qoiz(&self) -> Option; + /// Sets the `ironrdp_qoiz` property. + fn set_enable_qoiz(&mut self, enabled: bool); + /// Removes the `ironrdp_qoiz` property. + fn clear_enable_qoiz(&mut self); + + /// Enable RDPDR device redirection (`ironrdp_rdpdr`). + fn enable_rdpdr(&self) -> Option; + /// Sets the `ironrdp_rdpdr` property. + fn set_enable_rdpdr(&mut self, enabled: bool); + /// Removes the `ironrdp_rdpdr` property. + fn clear_enable_rdpdr(&mut self); + + /// Enable smart-card redirection within RDPDR (`ironrdp_smartcard`). + fn enable_smartcard(&self) -> Option; + /// Sets the `ironrdp_smartcard` property. + fn set_enable_smartcard(&mut self, enabled: bool); + /// Removes the `ironrdp_smartcard` property. + fn clear_enable_smartcard(&mut self); + + /// Enable TLS + graphical login; default enabled (`ironrdp_tls`). + fn enable_tls(&self) -> Option; + /// Sets the `ironrdp_tls` property. + fn set_enable_tls(&mut self, enabled: bool); + /// Removes the `ironrdp_tls` property. + fn clear_enable_tls(&mut self); + + /// Idle anti-lock fake events interval in minutes (`ironrdp_fakeeventsinterval`). + fn fake_events_interval(&self) -> Option; + /// Sets the `ironrdp_fakeeventsinterval` property. + fn set_fake_events_interval(&mut self, minutes: u32); + /// Removes the `ironrdp_fakeeventsinterval` property. + fn clear_fake_events_interval(&mut self); + + /// RDCleanPath authentication token; secret (`ironrdp_rdcleanpathtoken`). + fn rdcleanpath_token(&self) -> Option<&str>; + /// Sets the `ironrdp_rdcleanpathtoken` property. + fn set_rdcleanpath_token(&mut self, value: impl Into); + /// Removes the `ironrdp_rdcleanpathtoken` property. + fn clear_rdcleanpath_token(&mut self); + + /// RDCleanPath proxy URL (`ironrdp_rdcleanpathurl`). + fn rdcleanpath_url(&self) -> Option<&str>; + /// Sets the `ironrdp_rdcleanpathurl` property. + fn set_rdcleanpath_url(&mut self, value: impl Into); + /// Removes the `ironrdp_rdcleanpathurl` property. + fn clear_rdcleanpath_url(&mut self); + + /// Render the server-side pointer; default enabled (`ironrdp_serverpointer`). + fn server_pointer(&self) -> Option; + /// Sets the `ironrdp_serverpointer` property. + fn set_server_pointer(&mut self, enabled: bool); + /// Removes the `ironrdp_serverpointer` property. + fn clear_server_pointer(&mut self); + + // ── Multi-key helpers ───────────────────────────────────────────────────── + + /// Removes every gateway-related key (`gatewayhostname`, `gatewayusagemethod`, + /// `gatewayusername`, and both `GatewayPassword` casings). + fn clear_gateway(&mut self); + + /// Removes every RDCleanPath-related key (`ironrdp_rdcleanpathurl` and + /// `ironrdp_rdcleanpathtoken`). + fn clear_rdcleanpath(&mut self); } impl PropertySetExt for PropertySet { - fn full_address(&self) -> Option<&str> { - self.get::<&str>("full address") + // ── Microsoft standard keys ─────────────────────────────────────────────── + + fn alternate_full_address(&self) -> Result, ParseTargetAddrError> { + self.get::<&str>("alternate full address") + .map(|s| s.parse()) + .transpose() } - fn server_port(&self) -> Option { - self.get::("server port") + fn set_alternate_full_address(&mut self, value: &TargetAddr) { + self.insert("alternate full address", value.to_string()); } - fn alternate_full_address(&self) -> Option<&str> { - self.get::<&str>("alternate full address") + fn clear_alternate_full_address(&mut self) { + self.remove("alternate full address"); + } + + fn alternate_shell(&self) -> Option<&str> { + self.get::<&str>("alternate shell") + } + + fn set_alternate_shell(&mut self, value: impl Into) { + self.insert("alternate shell", value.into()); + } + + fn clear_alternate_shell(&mut self) { + self.remove("alternate shell"); + } + + fn audio_mode(&self) -> Result, UnknownAudioMode> { + self.get::("audiomode").map(AudioMode::try_from).transpose() + } + + fn set_audio_mode(&mut self, value: AudioMode) { + self.insert("audiomode", value.as_i64()); + } + + fn clear_audio_mode(&mut self) { + self.remove("audiomode"); + } + + fn clear_text_password(&self) -> Option<&str> { + self.get::<&str>("ClearTextPassword") + } + + fn set_clear_text_password(&mut self, value: impl Into) { + self.insert("ClearTextPassword", value.into()); + } + + fn clear_clear_text_password(&mut self) { + self.remove("ClearTextPassword"); + } + + fn compression(&self) -> Option { + self.get::("compression") + } + + fn set_compression(&mut self, value: bool) { + self.insert("compression", value); + } + + fn clear_compression(&mut self) { + self.remove("compression"); + } + + fn desktop_height(&self) -> Result, InvalidDesktopSize> { + self.get::("desktopheight") + .map(|v| u16::try_from(v).map_err(|_| InvalidDesktopSize)) + .transpose() + } + + fn set_desktop_height(&mut self, value: u16) { + self.insert("desktopheight", value); + } + + fn clear_desktop_height(&mut self) { + self.remove("desktopheight"); + } + + fn desktop_scale_factor(&self) -> Result, InvalidDesktopSize> { + self.get::("desktopscalefactor") + .map(|v| u32::try_from(v).map_err(|_| InvalidDesktopSize)) + .transpose() + } + + fn set_desktop_scale_factor(&mut self, value: u32) { + self.insert("desktopscalefactor", value); + } + + fn clear_desktop_scale_factor(&mut self) { + self.remove("desktopscalefactor"); + } + + fn desktop_width(&self) -> Result, InvalidDesktopSize> { + self.get::("desktopwidth") + .map(|v| u16::try_from(v).map_err(|_| InvalidDesktopSize)) + .transpose() + } + + fn set_desktop_width(&mut self, value: u16) { + self.insert("desktopwidth", value); + } + + fn clear_desktop_width(&mut self) { + self.remove("desktopwidth"); + } + + fn domain(&self) -> Option<&str> { + self.get::<&str>("domain") + } + + fn set_domain(&mut self, value: String) { + self.insert("domain", value); + } + + fn clear_domain(&mut self) { + self.remove("domain"); + } + + fn enable_credssp_support(&self) -> Option { + self.get::("enablecredsspsupport") + } + + fn set_enable_credssp_support(&mut self, enabled: bool) { + self.insert("enablecredsspsupport", i64::from(enabled)); + } + + fn clear_enable_credssp_support(&mut self) { + self.remove("enablecredsspsupport"); + } + + fn full_address(&self) -> Result, ParseTargetAddrError> { + self.get::<&str>("full address").map(|s| s.parse()).transpose() + } + + fn set_full_address(&mut self, value: &TargetAddr) { + self.insert("full address", value.to_string()); + } + + fn clear_full_address(&mut self) { + self.remove("full address"); + } + + fn gateway_credentials_source(&self) -> Result, UnknownGatewayCredentialsSource> { + self.get::("gatewaycredentialssource") + .map(GatewayCredentialsSource::try_from) + .transpose() + } + + fn set_gateway_credentials_source(&mut self, value: GatewayCredentialsSource) { + self.insert("gatewaycredentialssource", value.as_i64()); + } + + fn clear_gateway_credentials_source(&mut self) { + self.remove("gatewaycredentialssource"); } fn gateway_hostname(&self) -> Option<&str> { self.get::<&str>("gatewayhostname") } + fn set_gateway_hostname(&mut self, value: impl Into) { + self.insert("gatewayhostname", value.into()); + } + + fn clear_gateway_hostname(&mut self) { + self.remove("gatewayhostname"); + } + + fn gateway_password(&self) -> Option<&str> { + self.get::<&str>("GatewayPassword") + .or_else(|| self.get::<&str>("gatewaypassword")) + } + + fn set_gateway_password(&mut self, value: impl Into) { + self.insert("GatewayPassword", value.into()); + self.remove("gatewaypassword"); + } + + fn clear_gateway_password(&mut self) { + self.remove("GatewayPassword"); + self.remove("gatewaypassword"); + } + + fn gateway_usage_method(&self) -> Result, UnknownGatewayUsageMethod> { + self.get::("gatewayusagemethod") + .map(GatewayUsageMethod::try_from) + .transpose() + } + + fn set_gateway_usage_method(&mut self, value: GatewayUsageMethod) { + self.insert("gatewayusagemethod", value.as_i64()); + } + + fn clear_gateway_usage_method(&mut self) { + self.remove("gatewayusagemethod"); + } + + fn gateway_username(&self) -> Option<&str> { + self.get::<&str>("gatewayusername") + } + + fn set_gateway_username(&mut self, value: impl Into) { + self.insert("gatewayusername", value.into()); + } + + fn clear_gateway_username(&mut self) { + self.remove("gatewayusername"); + } + + fn kdc_proxy_name(&self) -> Option<&str> { + self.get::<&str>("kdcproxyname") + } + + fn set_kdc_proxy_name(&mut self, value: impl Into) { + self.insert("kdcproxyname", value.into()); + } + + fn clear_kdc_proxy_name(&mut self) { + self.remove("kdcproxyname"); + } + + fn kdc_proxy_url(&self) -> Option<&str> { + self.get::<&str>("kdcproxyurl") + .or_else(|| self.get::<&str>("KDCProxyURL")) + } + + fn set_kdc_proxy_url(&mut self, value: impl Into) { + self.insert("kdcproxyurl", value.into()); + self.remove("KDCProxyURL"); + } + + fn clear_kdc_proxy_url(&mut self) { + self.remove("kdcproxyurl"); + self.remove("KDCProxyURL"); + } + + fn redirect_clipboard(&self) -> Option { + self.get::("redirectclipboard") + } + + fn set_redirect_clipboard(&mut self, value: bool) { + self.insert("redirectclipboard", value); + } + + fn clear_redirect_clipboard(&mut self) { + self.remove("redirectclipboard"); + } + fn remote_application_name(&self) -> Option<&str> { self.get::<&str>("remoteapplicationname") } + fn set_remote_application_name(&mut self, value: impl Into) { + self.insert("remoteapplicationname", value.into()); + } + + fn clear_remote_application_name(&mut self) { + self.remove("remoteapplicationname"); + } + fn remote_application_program(&self) -> Option<&str> { self.get::<&str>("remoteapplicationprogram") } - fn kdc_proxy_url(&self) -> Option<&str> { - self.get::<&str>("kdcproxyurl") + fn set_remote_application_program(&mut self, value: impl Into) { + self.insert("remoteapplicationprogram", value.into()); + } + + fn clear_remote_application_program(&mut self) { + self.remove("remoteapplicationprogram"); + } + + fn server_port(&self) -> Result, InvalidServerPort> { + self.get::("server port") + .map(|p| u16::try_from(p).ok().filter(|&p| p != 0).ok_or(InvalidServerPort)) + .transpose() + } + + fn set_server_port(&mut self, value: u16) { + self.insert("server port", value); + } + + fn clear_server_port(&mut self) { + self.remove("server port"); + } + + fn shell_working_directory(&self) -> Option<&str> { + self.get::<&str>("shell working directory") + } + + fn set_shell_working_directory(&mut self, value: impl Into) { + self.insert("shell working directory", value.into()); + } + + fn clear_shell_working_directory(&mut self) { + self.remove("shell working directory"); } fn username(&self) -> Option<&str> { self.get::<&str>("username") } - fn clear_text_password(&self) -> Option<&str> { - self.get::<&str>("ClearTextPassword") + fn set_username(&mut self, value: impl Into) { + self.insert("username", value.into()); + } + + fn clear_username(&mut self) { + self.remove("username"); + } + + // ── IronRDP extensions ──────────────────────────────────────────────────── + + fn autologon(&self) -> Option { + self.get::("ironrdp_autologon") + } + + fn set_autologon(&mut self, enabled: bool) { + self.insert("ironrdp_autologon", enabled); + } + + fn clear_autologon(&mut self) { + self.remove("ironrdp_autologon"); + } + + fn color_depth(&self) -> Option { + self.get::("ironrdp_colordepth") + } + + fn set_color_depth(&mut self, depth: u32) { + self.insert("ironrdp_colordepth", i64::from(depth)); + } + + fn clear_color_depth(&mut self) { + self.remove("ironrdp_colordepth"); + } + + fn compression_level(&self) -> Option { + self.get::("ironrdp_compressionlevel") + } + + fn set_compression_level(&mut self, level: u32) { + self.insert("ironrdp_compressionlevel", i64::from(level)); + } + + fn clear_compression_level(&mut self) { + self.remove("ironrdp_compressionlevel"); + } + + fn dvc_pipe_proxies(&self) -> impl Iterator> { + self.get::<&str>("ironrdp_dvcpipeproxy") + .into_iter() + .flat_map(|value| value.split(',')) + .filter_map(|mut mapping| { + mapping = mapping.trim(); + + if mapping.is_empty() { + return None; + } + + match mapping.split_once('=') { + Some((channel, pipe)) => Some(Ok(DvcPipeProxy { + channel_name: channel.to_owned(), + pipe_name: pipe.to_owned(), + })), + None => Some(Err(DvcPipeSpecMissingDelimiter)), + } + }) + } + + fn set_dvc_pipe_proxies(&mut self, specs: T) + where + T: IntoIterator, + { + let mut value = String::new(); + + for spec in specs { + if !value.is_empty() { + value.push(','); + } + + value.push_str(&spec.channel_name); + value.push('='); + value.push_str(&spec.pipe_name); + } + + self.insert("ironrdp_dvcpipeproxy", value); + } + + fn clear_dvc_pipe_proxies(&mut self) { + self.remove("ironrdp_dvcpipeproxy"); + } + + fn dvc_plugins(&self) -> impl Iterator { + self.get::<&str>("ironrdp_dvcplugin") + .into_iter() + .flat_map(|value| value.split(',')) + .map(PathBuf::from) + } + + fn set_dvc_plugins<'a, T>(&mut self, paths: T) + where + T: IntoIterator, + { + let mut value = String::new(); + + for path in paths.into_iter().flat_map(|path| path.to_str()) { + if !value.is_empty() { + value.push(','); + } + + value.push_str(path); + } + + self.insert("ironrdp_dvcplugin", value); + } + + fn clear_dvc_plugins(&mut self) { + self.remove("ironrdp_dvcplugin"); + } + + fn enable_qoi(&self) -> Option { + self.get::("ironrdp_qoi") + } + + fn set_enable_qoi(&mut self, enabled: bool) { + self.insert("ironrdp_qoi", enabled); + } + + fn clear_enable_qoi(&mut self) { + self.remove("ironrdp_qoi"); + } + + fn enable_qoiz(&self) -> Option { + self.get::("ironrdp_qoiz") + } + + fn set_enable_qoiz(&mut self, enabled: bool) { + self.insert("ironrdp_qoiz", enabled); + } + + fn clear_enable_qoiz(&mut self) { + self.remove("ironrdp_qoiz"); + } + + fn enable_rdpdr(&self) -> Option { + self.get::("ironrdp_rdpdr") + } + + fn set_enable_rdpdr(&mut self, enabled: bool) { + self.insert("ironrdp_rdpdr", enabled); + } + + fn clear_enable_rdpdr(&mut self) { + self.remove("ironrdp_rdpdr"); + } + + fn enable_smartcard(&self) -> Option { + self.get::("ironrdp_smartcard") + } + + fn set_enable_smartcard(&mut self, enabled: bool) { + self.insert("ironrdp_smartcard", enabled); + } + + fn clear_enable_smartcard(&mut self) { + self.remove("ironrdp_smartcard"); + } + + fn enable_tls(&self) -> Option { + self.get::("ironrdp_tls") + } + + fn set_enable_tls(&mut self, enabled: bool) { + self.insert("ironrdp_tls", enabled); + } + + fn clear_enable_tls(&mut self) { + self.remove("ironrdp_tls"); + } + + fn fake_events_interval(&self) -> Option { + self.get::("ironrdp_fakeeventsinterval") + } + + fn set_fake_events_interval(&mut self, minutes: u32) { + self.insert("ironrdp_fakeeventsinterval", i64::from(minutes)); + } + + fn clear_fake_events_interval(&mut self) { + self.remove("ironrdp_fakeeventsinterval"); + } + + fn rdcleanpath_token(&self) -> Option<&str> { + self.get::<&str>("ironrdp_rdcleanpathtoken") + } + + fn set_rdcleanpath_token(&mut self, value: impl Into) { + self.insert("ironrdp_rdcleanpathtoken", value.into()); + } + + fn clear_rdcleanpath_token(&mut self) { + self.remove("ironrdp_rdcleanpathtoken"); + } + + fn rdcleanpath_url(&self) -> Option<&str> { + self.get::<&str>("ironrdp_rdcleanpathurl") + } + + fn set_rdcleanpath_url(&mut self, value: impl Into) { + self.insert("ironrdp_rdcleanpathurl", value.into()); + } + + fn clear_rdcleanpath_url(&mut self) { + self.remove("ironrdp_rdcleanpathurl"); + } + + fn server_pointer(&self) -> Option { + self.get::("ironrdp_serverpointer") + } + + fn set_server_pointer(&mut self, enabled: bool) { + self.insert("ironrdp_serverpointer", enabled); + } + + fn clear_server_pointer(&mut self) { + self.remove("ironrdp_serverpointer"); + } + + // ── Multi-key helpers ───────────────────────────────────────────────────── + + fn clear_gateway(&mut self) { + self.remove("gatewayhostname"); + self.remove("gatewayusagemethod"); + self.remove("gatewayusername"); + self.remove("gatewaypassword"); + self.remove("GatewayPassword"); + } + + fn clear_rdcleanpath(&mut self) { + self.remove("ironrdp_rdcleanpathurl"); + self.remove("ironrdp_rdcleanpathtoken"); } } diff --git a/crates/ironrdp-cfg/src/target_addr.rs b/crates/ironrdp-cfg/src/target_addr.rs new file mode 100644 index 0000000000..2e047063f0 --- /dev/null +++ b/crates/ironrdp-cfg/src/target_addr.rs @@ -0,0 +1,132 @@ +use core::fmt; +use core::net::{IpAddr, Ipv6Addr}; +use core::str::FromStr; + +/// The host component of an RDP target address. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TargetHost { + /// A resolved IP address (IPv4 or IPv6). + Ip(IpAddr), + /// A hostname or domain name. + Domain(String), +} + +impl fmt::Display for TargetHost { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + // IPv6 addresses must be bracketed in .rdp file format and in URI contexts. + Self::Ip(IpAddr::V6(ip)) => write!(f, "[{ip}]"), + Self::Ip(ip) => write!(f, "{ip}"), + Self::Domain(host) => write!(f, "{host}"), + } + } +} + +/// A parsed target address from an RDP file `full address` or `alternate full address` property. +/// +/// The `.rdp` file format represents IPv6 addresses with square brackets — `[::1]` or +/// `[::1]:port`. This type handles all address variants (hostname, IPv4, bracketed IPv6) +/// with an optional embedded port. +/// +/// When the port is absent ([`port`] is `None`), the `server port` property should be +/// consulted separately via [`PropertySetExt::server_port`]. +/// +/// [`PropertySetExt::server_port`]: crate::PropertySetExt::server_port +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TargetAddr { + /// The host component. + pub host: TargetHost, + /// Port embedded in the address string, if any. + /// + /// This does not account for the `server port` property; callers must combine both. + pub port: Option, +} + +impl fmt::Display for TargetAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.host)?; + if let Some(port) = self.port { + write!(f, ":{port}")?; + } + Ok(()) + } +} + +/// Error returned when a `full address` string cannot be parsed as a [`TargetAddr`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParseTargetAddrError { + /// A `[` was found with no matching `]`. + UnclosedBracket, + /// The content between `[` and `]` is not a valid IPv6 address. + InvalidIpv6Addr, + /// The port suffix is not a valid `u16`. + InvalidPort, + /// Unexpected characters follow the closing `]`. + UnexpectedTrailing, +} + +impl fmt::Display for ParseTargetAddrError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnclosedBracket => f.write_str("unclosed '[' in RDP address"), + Self::InvalidIpv6Addr => f.write_str("invalid IPv6 address in RDP address"), + Self::InvalidPort => f.write_str("invalid port in RDP address"), + Self::UnexpectedTrailing => f.write_str("unexpected characters after ']' in RDP address"), + } + } +} + +impl core::error::Error for ParseTargetAddrError {} + +impl FromStr for TargetAddr { + type Err = ParseTargetAddrError; + + fn from_str(s: &str) -> Result { + // Bracketed IPv6: "[addr]:port" or "[addr]" + if let Some(rest) = s.strip_prefix('[') { + let (ipv6_str, rest) = rest.split_once(']').ok_or(ParseTargetAddrError::UnclosedBracket)?; + let ip: Ipv6Addr = ipv6_str.parse().map_err(|_| ParseTargetAddrError::InvalidIpv6Addr)?; + let port = match rest { + "" => None, + s if s.starts_with(':') => { + let port_str = s.strip_prefix(':').expect("already checked starts_with ':'"); + Some(port_str.parse::().map_err(|_| ParseTargetAddrError::InvalidPort)?) + } + _ => return Err(ParseTargetAddrError::UnexpectedTrailing), + }; + return Ok(TargetAddr { + host: TargetHost::Ip(IpAddr::V6(ip)), + port, + }); + } + + // Bare IP address (no port) — must be checked before rsplit_once(':') because unbracketed + // IPv6 like "::1" or "fe80::1" would otherwise be misparsed (trailing segment treated as port). + if let Ok(ip) = s.parse::() { + return Ok(TargetAddr { + host: TargetHost::Ip(ip), + port: None, + }); + } + + // "hostname:port" — use rsplit_once to separate on the last colon. + // Any colon present after a non-IP address is unambiguously a port separator in the + // .rdp format, so a non-numeric or out-of-range suffix is an error rather than a + // fallback to a bare hostname. + if let Some((host, port_str)) = s.rsplit_once(':') { + let port = port_str.parse::().map_err(|_| ParseTargetAddrError::InvalidPort)?; + let host = if let Ok(ip) = host.parse::() { + TargetHost::Ip(ip) + } else { + TargetHost::Domain(host.to_owned()) + }; + return Ok(TargetAddr { host, port: Some(port) }); + } + + // Bare hostname without port. + Ok(TargetAddr { + host: TargetHost::Domain(s.to_owned()), + port: None, + }) + } +} diff --git a/crates/ironrdp-client-glutin/Cargo.toml b/crates/ironrdp-client-glutin/Cargo.toml index 062600cf98..f81ac35d91 100644 --- a/crates/ironrdp-client-glutin/Cargo.toml +++ b/crates/ironrdp-client-glutin/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" readme = "README.md" description = "GPU-accelerated RDP client using glutin" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true diff --git a/crates/ironrdp-client-glutin/src/config.rs b/crates/ironrdp-client-glutin/src/config.rs index 64e587232f..e0179dca2e 100644 --- a/crates/ironrdp-client-glutin/src/config.rs +++ b/crates/ironrdp-client-glutin/src/config.rs @@ -16,6 +16,7 @@ pub struct Config { pub addr: String, pub input: InputConfig, pub gfx_dump_file: Option, + pub openh264_path: PathBuf, } #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] @@ -139,6 +140,10 @@ struct Args { /// Enables dumping the gfx stream to a file location #[clap(long, value_parser)] gfx_dump_file: Option, + + /// Path to Cisco's prebuilt OpenH264 shared library (libopenh264.so / openh264.dll) + #[clap(long, value_parser, default_value = "libopenh264.so")] + openh264_path: PathBuf, } impl Config { @@ -181,6 +186,7 @@ impl Config { addr: args.addr, input, gfx_dump_file: args.gfx_dump_file, + openh264_path: args.openh264_path, } } } diff --git a/crates/ironrdp-client-glutin/src/gui.rs b/crates/ironrdp-client-glutin/src/gui.rs index 45e5d4249e..66a50abf74 100644 --- a/crates/ironrdp-client-glutin/src/gui.rs +++ b/crates/ironrdp-client-glutin/src/gui.rs @@ -72,6 +72,7 @@ pub enum UserEvent {} pub fn launch_gui( context: UiContext, gfx_dump_file: Option, + openh264_path: PathBuf, graphic_receiver: Receiver, stream: Arc>, ) -> Result<(), RdpError> { @@ -79,7 +80,7 @@ pub fn launch_gui( tokio::spawn(async move { handle_input_events(receiver, stream).await }); - let renderer = Renderer::new(context.window, graphic_receiver, gfx_dump_file); + let renderer = Renderer::new(context.window, graphic_receiver, gfx_dump_file, openh264_path); // We handle events differently between targets let mut last_position: Option> = None; diff --git a/crates/ironrdp-client-glutin/src/main.rs b/crates/ironrdp-client-glutin/src/main.rs index c65aa9a51f..2e948391f8 100644 --- a/crates/ironrdp-client-glutin/src/main.rs +++ b/crates/ironrdp-client-glutin/src/main.rs @@ -163,7 +163,7 @@ async fn launch_client( } } }); - gui::launch_gui(gui, config.gfx_dump_file, receiver, writer.clone())?; + gui::launch_gui(gui, config.gfx_dump_file, config.openh264_path, receiver, writer.clone())?; active_stage_handle.await.map_err(|e| RdpError::Io(e.into()))? } diff --git a/crates/ironrdp-client/CHANGELOG.md b/crates/ironrdp-client/CHANGELOG.md new file mode 100644 index 0000000000..cdbc79f701 --- /dev/null +++ b/crates/ironrdp-client/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [[0.1.0](https://github.com/Devolutions/IronRDP/releases/tag/ironrdp-client-v0.1.0)] - 2026-07-10 + +Initial release. diff --git a/crates/ironrdp-client/Cargo.toml b/crates/ironrdp-client/Cargo.toml index d00f4ebc9b..281c1e941c 100644 --- a/crates/ironrdp-client/Cargo.toml +++ b/crates/ironrdp-client/Cargo.toml @@ -2,7 +2,7 @@ name = "ironrdp-client" version = "0.1.0" readme = "README.md" -description = "Portable RDP client without GPU acceleration" +description = "Portable RDP client engine without GPU acceleration" edition.workspace = true license.workspace = true homepage.workspace = true @@ -10,85 +10,90 @@ repository.workspace = true authors.workspace = true keywords.workspace = true categories.workspace = true -default-run = "ironrdp-client" - -# Not publishing for now. -publish = false [lib] doctest = false test = false -[[bin]] -name = "ironrdp-client" -test = false - [features] -default = ["rustls"] -rustls = ["ironrdp-tls/rustls", "tokio-tungstenite/rustls-tls-native-roots", "ironrdp-mstsgu/rustls"] -native-tls = ["ironrdp-tls/native-tls", "tokio-tungstenite/native-tls", "ironrdp-mstsgu/native-tls"] -qoi = ["ironrdp/qoi"] -qoiz = ["ironrdp/qoiz"] +default = [] -[dependencies] -# Protocols -ironrdp = { path = "../ironrdp", version = "0.11", features = [ - "session", - "input", - "graphics", - "dvc", - "svc", +rustls = [ + "ironrdp-tls/rustls", + "tokio-tungstenite/rustls-tls-native-roots", + "ironrdp-mstsgu?/rustls", +] + +native-tls = [ + "ironrdp-tls/native-tls", + "tokio-tungstenite/native-tls", + "ironrdp-mstsgu?/native-tls", +] + +sound = ["dep:ironrdp-rdpsnd", "dep:ironrdp-rdpsnd-native"] +clipboard = ["dep:ironrdp-cliprdr", "dep:ironrdp-cliprdr-native"] +rdpdr = ["dep:ironrdp-rdpdr"] +smartcard = ["rdpdr"] +gateway = ["dep:ironrdp-mstsgu"] +qoi = ["ironrdp-connector/qoi", "ironrdp-session/qoi"] +qoiz = ["ironrdp-connector/qoiz", "ironrdp-session/qoiz"] +dvc-pipe-proxy = ["dep:ironrdp-dvc-pipe-proxy"] +dvc-com-plugin = ["dep:ironrdp-dvc-com-plugin"] + +all = [ + "sound", + "clipboard", "rdpdr", - "rdpsnd", - "cliprdr", - "displaycontrol", - "connector", -] } -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } -ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.3" } -ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.3" } -ironrdp-tls = { path = "../ironrdp-tls", version = "0.1" } -ironrdp-mstsgu = { path = "../ironrdp-mstsgu" } -ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.6", features = ["reqwest"] } -ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath" -ironrdp-dvc-pipe-proxy.path = "../ironrdp-dvc-pipe-proxy" -ironrdp-propertyset.path = "../ironrdp-propertyset" -ironrdp-rdpfile.path = "../ironrdp-rdpfile" -ironrdp-cfg.path = "../ironrdp-cfg" + "smartcard", + "gateway", + "dvc-pipe-proxy", + "dvc-com-plugin", +] + +[dependencies] +# Protocols (core features always on) +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.10" } # public +ironrdp-session = { path = "../ironrdp-session", version = "0.11" } # public +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.9" } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.8" } +ironrdp-echo = { path = "../ironrdp-echo", version = "0.4" } +ironrdp-tls = { path = "../ironrdp-tls", version = "0.2" } +ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.10", features = ["reqwest"] } +ironrdp-rdcleanpath = { path = "../ironrdp-rdcleanpath", version = "0.2" } +ironrdp-cfg = { path = "../ironrdp-cfg", version = "0.1" } +ironrdp-propertyset = { path = "../ironrdp-propertyset", version = "0.1" } # public -# Windowing and rendering -winit = { version = "0.30", features = ["rwh_06"] } -softbuffer = "0.4" +# Optional protocol crates (activated by features above) +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.7", optional = true } # public +ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.7", optional = true } +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.9", optional = true } -# CLI -clap = { version = "4.5", features = ["derive", "cargo"] } -proc-exit = "2" -inquire = "0.7" +# Optional backend crates (activated by features above) +ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.7", optional = true } +ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.7", optional = true } +ironrdp-mstsgu = { path = "../ironrdp-mstsgu", version = "0.0.1", optional = true } +ironrdp-dvc-pipe-proxy = { path = "../ironrdp-dvc-pipe-proxy", version = "0.5", optional = true } # Logging tracing = { version = "0.1", features = ["log"] } -tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Async, futures -tokio = { version = "1", features = ["full"] } -tokio-util = { version = "0.7" } -tokio-tungstenite = "0.27" -# transport = { git = "https://github.com/Devolutions/devolutions-gateway", rev = "06e91dfe82751a6502eaf74b6a99663f06f0236d" } +tokio = { version = "1", features = ["macros", "net", "io-util", "sync", "rt", "time"] } +tokio-tungstenite = "0.29" futures-util = { version = "0.3", features = ["sink"] } # Utils -whoami = "1.6" anyhow = "1" smallvec = "1.15" -tap = "1" -semver = "1" -raw-window-handle = "0.6" -uuid = { version = "1.18" } -x509-cert = { version = "0.2", default-features = false, features = ["std"] } url = "2" +x509-cert = { version = "0.2", default-features = false, features = ["std"] } [target.'cfg(windows)'.dependencies] -windows = { version = "0.61", features = ["Win32_Foundation"] } +ironrdp-dvc-com-plugin = { path = "../ironrdp-dvc-com-plugin", version = "0.1", optional = true } [lints] workspace = true diff --git a/crates/ironrdp-client/README.md b/crates/ironrdp-client/README.md index 95a9f90f6b..595900155f 100644 --- a/crates/ironrdp-client/README.md +++ b/crates/ironrdp-client/README.md @@ -1,48 +1,18 @@ # IronRDP client -Portable RDP client without GPU acceleration. +Reusable RDP client engine library built on top of the IronRDP crates suite. -This is a a full-fledged RDP client based on IronRDP crates suite, and implemented using -non-blocking, asynchronous I/O. Portability is achieved by using softbuffer for rendering -and winit for windowing. +This crate is **library-only**: it exposes the `Config`, the `RdpClient` +runtime, input/output event types, the WebSocket transport, and the session driver. It is +consumed by `ironrdp-viewer` (the portable GUI client binary) and by any other embedder +(for example, a headless agent). -## Sample usage +The library is winit-agnostic. Output events are emitted on a bounded +`tokio::sync::mpsc::Sender` channel: the embedder is responsible +for consuming them and dispatching them to whatever event loop or runtime it wishes. -```shell -ironrdp-client --username --password -``` - -## Configuring log filter directives - -The `IRONRDP_LOG` environment variable is used to set the log filter directives. - -```shell -IRONRDP_LOG="info,ironrdp_connector=trace" ironrdp-client --username --password -``` - -See [`tracing-subscriber`’s documentation][tracing-doc] for more details. - -[tracing-doc]: https://docs.rs/tracing-subscriber/0.3.17/tracing_subscriber/filter/struct.EnvFilter.html#directives - -## Support for `SSLKEYLOGFILE` - -This client supports reading the `SSLKEYLOGFILE` environment variable. -When set, the TLS encryption secrets for the session will be dumped to the file specified -by the environment variable. -This file can be read by Wireshark so that in can decrypt the packets. - -### Example - -```shell -SSLKEYLOGFILE=/tmp/tls-secrets ironrdp-client --username --password -``` - -### Usage in Wireshark - -See this [awakecoding's repository][awakecoding-repository] explaining how to use the file in wireshark. +For the end-user RDP client binary, see [`ironrdp-viewer`](../ironrdp-viewer). This crate is part of the [IronRDP] project. [IronRDP]: https://github.com/Devolutions/IronRDP -[awakecoding-repository]: https://github.com/awakecoding/wireshark-rdp#sslkeylogfile - diff --git a/crates/ironrdp-client/src/clipboard.rs b/crates/ironrdp-client/src/clipboard.rs index a58716a948..cfa2c9cd11 100644 --- a/crates/ironrdp-client/src/clipboard.rs +++ b/crates/ironrdp-client/src/clipboard.rs @@ -1,17 +1,17 @@ -use ironrdp::cliprdr::backend::{ClipboardMessage, ClipboardMessageProxy}; +use ironrdp_cliprdr::backend::{ClipboardMessage, ClipboardMessageProxy}; use tokio::sync::mpsc; use tracing::error; use crate::rdp::RdpInputEvent; -/// Shim for sending and receiving CLIPRDR events as `RdpInputEvent` +/// Shim that forwards CLIPRDR events into the `RdpInputEvent` channel. #[derive(Clone, Debug)] -pub struct ClientClipboardMessageProxy { +pub(crate) struct ClientClipboardMessageProxy { tx: mpsc::UnboundedSender, } impl ClientClipboardMessageProxy { - pub fn new(tx: mpsc::UnboundedSender) -> Self { + pub(crate) fn new(tx: mpsc::UnboundedSender) -> Self { Self { tx } } } @@ -19,7 +19,7 @@ impl ClientClipboardMessageProxy { impl ClipboardMessageProxy for ClientClipboardMessageProxy { fn send_clipboard_message(&self, message: ClipboardMessage) { if self.tx.send(RdpInputEvent::Clipboard(message)).is_err() { - error!("Failed to send os clipboard message, receiver is closed"); + error!("Failed to send clipboard message; receiver is closed"); } } } diff --git a/crates/ironrdp-client/src/config.rs b/crates/ironrdp-client/src/config.rs index a24d7ccac1..46778e4080 100644 --- a/crates/ironrdp-client/src/config.rs +++ b/crates/ironrdp-client/src/config.rs @@ -1,81 +1,336 @@ -#![allow(clippy::print_stdout)] - -use core::num::ParseIntError; +use core::fmt; use core::str::FromStr; +use core::time::Duration; +#[cfg(all(windows, feature = "dvc-com-plugin"))] use std::path::PathBuf; +use std::sync::Arc; use anyhow::Context as _; -use clap::clap_derive::ValueEnum; -use clap::Parser; -use ironrdp::connector::{self, Credentials}; -use ironrdp::pdu::rdp::capability_sets::{client_codecs_capabilities, MajorPlatformType}; -use ironrdp::pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; -use ironrdp_mstsgu::GwConnectTarget; -use tap::prelude::*; +use ironrdp_cfg::PropertySetExt as _; +use ironrdp_propertyset::PropertySet; use url::Url; -const DEFAULT_WIDTH: u16 = 1920; -const DEFAULT_HEIGHT: u16 = 1080; +// ── Extension registry ──────────────────────────────────────────────────────── -#[derive(Clone, Debug)] +type StaticChannelFn = Arc; +type DvcChannelFn = Arc; + +/// Private registry of user-supplied static and dynamic virtual channel factories. +/// +/// Cloneable via `Arc`; the factory closures are shared across reconnects. +#[derive(Default)] +pub(crate) struct ExtensionRegistry { + pub(crate) static_channels: Vec, + pub(crate) dvc_channels: Vec, +} + +impl Clone for ExtensionRegistry { + fn clone(&self) -> Self { + Self { + static_channels: self.static_channels.clone(), + dvc_channels: self.dvc_channels.clone(), + } + } +} + +impl fmt::Debug for ExtensionRegistry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ExtensionRegistry") + .field("static_channels", &self.static_channels.len()) + .field("dvc_channels", &self.dvc_channels.len()) + .finish() + } +} + +// ── Public configuration types ──────────────────────────────────────────────── + +/// Fully resolved client configuration. +/// +/// This is the typed surface consumed by [`crate::rdp::RdpClient`]. Build it with +/// [`ConfigBuilder`]; producing a `Config` from CLI arguments, `.rdp` files, or interactive +/// prompts is the consumer's responsibility (see `ironrdp-viewer` for a reference front-end). +/// +/// The struct is opaque: fields are read-only via accessors so a built `Config` cannot drift into +/// an inconsistent state (e.g. mutating the connector without updating the originating +/// [`PropertySet`]). +#[derive(Clone)] pub struct Config { - pub log_file: Option, - pub gw: Option, - pub destination: Destination, - pub connector: connector::Config, - pub clipboard_type: ClipboardType, - pub rdcleanpath: Option, - - /// DVC channel <-> named pipe proxy configuration. + pub(crate) connector: ironrdp_connector::Config, + pub(crate) destination: Destination, + pub(crate) transport: Transport, + pub(crate) kerberos_config: Option, + pub(crate) fake_events_interval: Option, + pub(crate) channels: ChannelConfig, + + /// DVC channel ↔ named-pipe proxy configuration. + /// + /// Each entry causes IronRDP to forward that DVC channel's traffic to/from the + /// named pipe, allowing out-of-process DVC logic. + #[cfg(feature = "dvc-pipe-proxy")] + pub(crate) dvc_pipe_proxies: Vec, + + /// Paths to DVC client plugin DLLs to load (Windows only). /// - /// Each configured proxy enables IronRDP to connect to DVC channel and create a named pipe - /// server, which will be used for proxying DVC messages to/from user-defined DVC logic - /// implemented as named pipe clients (either in the same process or in a different process). - pub dvc_pipe_proxies: Vec, + /// Each DLL is loaded via `LoadLibraryW` and its `VirtualChannelGetInstance` export is + /// called to obtain DVC plugin COM objects. Example: `C:\Windows\System32\webauthn.dll`. + #[cfg(all(windows, feature = "dvc-com-plugin"))] + pub(crate) dvc_plugins: Vec, + + /// The merged PropertySet that produced this config, shared (read-only) with channel factories. + /// + /// Well-known secret properties are stripped when calling [`ConfigBuilder::build`]. + pub(crate) properties: PropertySet, + + pub(crate) extensions: ExtensionRegistry, +} + +impl Config { + /// Connector configuration handed to the RDP connection sequence. + pub fn connector(&self) -> &ironrdp_connector::Config { + &self.connector + } + + /// Resolved RDP target (host + port). + pub fn destination(&self) -> &Destination { + &self.destination + } + + /// Selected transport (Direct, Gateway, or RDCleanPath). + pub fn transport(&self) -> &Transport { + &self.transport + } + + /// Optional Kerberos/KDC proxy configuration. + pub fn kerberos_config(&self) -> Option<&ironrdp_connector::credssp::KerberosConfig> { + self.kerberos_config.as_ref() + } + + /// Idle anti-lock fake-events interval, if enabled. + pub fn fake_events_interval(&self) -> Option { + self.fake_events_interval + } + + /// Channel/codec runtime toggles. + pub fn channels(&self) -> &ChannelConfig { + &self.channels + } + + /// DVC named-pipe proxy mappings. + #[cfg(feature = "dvc-pipe-proxy")] + pub fn dvc_pipe_proxies(&self) -> &[DvcProxyInfo] { + &self.dvc_pipe_proxies + } + + /// DVC client plugin DLL paths (Windows only). + #[cfg(all(windows, feature = "dvc-com-plugin"))] + pub fn dvc_plugins(&self) -> &[PathBuf] { + &self.dvc_plugins + } + + /// Merged `.rdp` PropertySet that produced this config. + pub fn properties(&self) -> &PropertySet { + &self.properties + } +} + +impl fmt::Debug for Config { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut s = f.debug_struct("Config"); + s.field("connector", &self.connector); + s.field("destination", &self.destination); + s.field("transport", &self.transport); + s.field("kerberos_config", &self.kerberos_config); + s.field("fake_events_interval", &self.fake_events_interval); + s.field("channels", &self.channels); + #[cfg(feature = "dvc-pipe-proxy")] + s.field("dvc_pipe_proxies", &self.dvc_pipe_proxies); + #[cfg(all(windows, feature = "dvc-com-plugin"))] + s.field("dvc_plugins", &self.dvc_plugins); + s.field("extensions", &self.extensions); + s.finish() + } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +/// Resolved clipboard backend selection. +/// +/// Platform-specific details (e.g., which native clipboard backend to use) are handled +/// internally by the library when [`Enable`](ClipboardType::Enable) is selected. +#[cfg(feature = "clipboard")] +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ClipboardType { - Default, + /// Enable clipboard redirection (use the best available backend). + Enable, + /// Disable clipboard redirection entirely. + Disable, + /// Use a stub clipboard backend (for testing or headless usage). + // FIXME: the `Stub` concept arguably shouldn't live in ironrdp-client. Investigate whether it + // can move out via the extension/backend API, so the stub backend stays in ironrdp-viewer as a + // debugging tool. Note that other consumers (e.g. ironrdp-agent) may need their own custom + // backend that is not integrated with the host system's clipboard either; the design should + // accommodate plugging in arbitrary CliprdrBackendFactory implementations rather than baking + // specific variants into the client. Stub, - #[cfg(windows)] - Windows, - None, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] -enum KeyboardType { - IbmPcXt, - OlivettiIco, - IbmPcAt, - IbmEnhanced, - Nokia1050, - Nokia9140, - Japanese, +/// Channel and codec runtime toggles. +/// +/// Each field is only present when the corresponding Cargo feature is enabled. +/// The defaults for all optional fields are `true` (enabled) when the feature is on. +// TODO: Also add flags for all the channels that are not behind Cargo feature flags. +// Examples: ECHO and Display Control virtual channels. +#[derive(Clone, Debug)] +pub struct ChannelConfig { + /// Enable the RDPSND (audio) virtual channel. + #[cfg(feature = "sound")] + pub sound: bool, + + /// Clipboard redirection mode. + #[cfg(feature = "clipboard")] + pub clipboard: ClipboardType, + + /// Device-redirection (RDPDR) configuration. + #[cfg(feature = "rdpdr")] + pub rdpdr: RdpdrConfig, + + /// Enable QOI bitmap codec. + /// + /// When `false`, the QOI codec is removed from `connector.bitmap.codecs` before connecting + /// even if the `qoi` feature is compiled in. + #[cfg(feature = "qoi")] + pub qoi: bool, + + /// Enable QOIZ (QOI with zlib) bitmap codec. + #[cfg(feature = "qoiz")] + pub qoiz: bool, } -impl KeyboardType { - fn parse(keyboard_type: KeyboardType) -> ironrdp::pdu::gcc::KeyboardType { - match keyboard_type { - KeyboardType::IbmEnhanced => ironrdp::pdu::gcc::KeyboardType::IbmEnhanced, - KeyboardType::IbmPcAt => ironrdp::pdu::gcc::KeyboardType::IbmPcAt, - KeyboardType::IbmPcXt => ironrdp::pdu::gcc::KeyboardType::IbmPcXt, - KeyboardType::OlivettiIco => ironrdp::pdu::gcc::KeyboardType::OlivettiIco, - KeyboardType::Nokia1050 => ironrdp::pdu::gcc::KeyboardType::Nokia1050, - KeyboardType::Nokia9140 => ironrdp::pdu::gcc::KeyboardType::Nokia9140, - KeyboardType::Japanese => ironrdp::pdu::gcc::KeyboardType::Japanese, +#[cfg_attr( + not(any(feature = "sound", feature = "clipboard", feature = "qoi", feature = "qoiz")), + expect( + clippy::derivable_impls, + reason = "fields setting non-default values are feature-gated; the impl is only trivially derivable in some feature combinations" + ) +)] +impl Default for ChannelConfig { + fn default() -> Self { + Self { + #[cfg(feature = "sound")] + sound: true, + #[cfg(feature = "clipboard")] + clipboard: ClipboardType::Enable, + #[cfg(feature = "rdpdr")] + rdpdr: RdpdrConfig::default(), + #[cfg(feature = "qoi")] + qoi: true, + #[cfg(feature = "qoiz")] + qoiz: true, } } } -fn parse_hex(input: &str) -> Result { - if input.starts_with("0x") { - u32::from_str_radix(input.get(2..).unwrap_or(""), 16) - } else { - input.parse::() +/// RDPDR (device redirection) runtime configuration. +#[cfg(feature = "rdpdr")] +#[derive(Clone, Debug)] +pub struct RdpdrConfig { + /// Enable device redirection at all. + pub enabled: bool, + + /// Enable smart-card redirection within RDPDR. + #[cfg(feature = "smartcard")] + pub smartcard: bool, +} + +#[cfg(feature = "rdpdr")] +impl Default for RdpdrConfig { + fn default() -> Self { + Self { + enabled: true, + #[cfg(feature = "smartcard")] + smartcard: true, + } } } +/// Fully-resolved transport selection for an established RDP connection. +/// +/// This is the form stored in [`Config`] and consumed by the connection code: every variant +/// carries all the data the transport needs, including any credentials. To *configure* a transport +/// on a [`ConfigBuilder`], use the granular [`TransportKind`] instead — it carries only the +/// addressing, leaving secrets to be supplied (or prompted for) separately. +#[derive(Clone, Debug, Default)] +pub enum Transport { + /// Plain TCP → TLS direct connection to the RDP server. + #[default] + Direct, + + /// Connect via an RDS gateway (MS-TSGU / MSTSGU). + /// + /// The target RDP server is derived from [`Config::destination`]; the gateway + /// only needs its own endpoint and credentials. + /// + /// NOTE: the destination port is currently not forwarded to the gateway. + /// If `ironrdp-mstsgu` hardcodes port 3389, open a follow-up issue. + #[cfg(feature = "gateway")] + Gateway(GatewayConfig), + + /// Connect via an RDCleanPath proxy (WebSocket-based). + RDCleanPath(RDCleanPathConfig), +} + +/// Transport selection used to configure a [`ConfigBuilder`]. +/// +/// Only the *addressing* of the transport is provided here (the gateway endpoint, the RDCleanPath +/// URL). The associated secrets — gateway username/password and the RDCleanPath authentication +/// token — are supplied through their own dedicated `with_*` methods +/// ([`with_gateway_username`](ConfigBuilder::with_gateway_username), +/// [`with_gateway_password`](ConfigBuilder::with_gateway_password), +/// [`with_rdcleanpath_token`](ConfigBuilder::with_rdcleanpath_token)). +/// +/// Decoupling addressing from secrets means the latter can be tracked as [`MissingField`]s and +/// resolved independently (e.g. prompted interactively) instead of having to be known up-front when +/// the transport is selected. The builder assembles the resolved [`Transport`] from this selection +/// and the collected credentials in [`build`](ConfigBuilder::build). +#[derive(Clone, Debug, Default)] +pub enum TransportKind { + /// Plain TCP → TLS direct connection to the RDP server. + #[default] + Direct, + + /// Connect via an RDS gateway (MS-TSGU / MSTSGU). + /// + /// Gateway credentials are supplied separately via + /// [`with_gateway_username`](ConfigBuilder::with_gateway_username) / + /// [`with_gateway_password`](ConfigBuilder::with_gateway_password). + #[cfg(feature = "gateway")] + Gateway { + /// Gateway endpoint address (e.g., `"rdg.contoso.com:443"`). + endpoint: String, + }, + + /// Connect via an RDCleanPath proxy (WebSocket-based). + /// + /// The authentication token is supplied separately via + /// [`with_rdcleanpath_token`](ConfigBuilder::with_rdcleanpath_token). + RDCleanPath { + /// RDCleanPath proxy URL. + url: Url, + }, +} + +/// Endpoint and credentials for a fully-resolved RDS gateway connection. +#[cfg(feature = "gateway")] +#[derive(Clone, Debug)] +pub struct GatewayConfig { + /// Gateway endpoint address (e.g., `"rdg.contoso.com:443"`). + pub endpoint: String, + /// Gateway username. + pub username: String, + /// Gateway password. + pub password: String, +} + +// ── Destination ─────────────────────────────────────────────────────────────── + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Destination { name: String, @@ -88,7 +343,7 @@ impl Destination { let addr = addr.into(); - if let Some(idx) = addr.rfind(':') { + if let Some(addr_split) = addr.rsplit_once(':') { if let Ok(sock_addr) = addr.parse::() { Ok(Self { name: sock_addr.ip().to_string(), @@ -101,8 +356,8 @@ impl Destination { }) } else { Ok(Self { - name: addr[..idx].to_owned(), - port: addr[idx + 1..].parse().context("invalid port")?, + name: addr_split.0.to_owned(), + port: addr_split.1.parse().context("invalid port")?, }) } } else { @@ -120,6 +375,28 @@ impl Destination { pub fn port(&self) -> u16 { self.port } + + /// Construct a `Destination` from already-validated components. + /// + /// Intended for front-ends that have already resolved the host and port from their own + /// configuration sources (CLI flags, `.rdp` files, IPC schemas). + pub fn from_parts(name: impl Into, port: u16) -> Self { + Self { + name: name.into(), + port, + } + } +} + +impl fmt::Display for Destination { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // IPv6 addresses must be bracketed in host:port notation. + if self.name.parse::().is_ok() { + write!(f, "[{}]:{}", self.name, self.port) + } else { + write!(f, "{}:{}", self.name, self.port) + } + } } impl FromStr for Destination { @@ -130,24 +407,30 @@ impl FromStr for Destination { } } -impl From for connector::ServerName { +impl From for ironrdp_connector::ServerName { fn from(value: Destination) -> Self { Self::new(value.name) } } -impl From<&Destination> for connector::ServerName { +impl From<&Destination> for ironrdp_connector::ServerName { fn from(value: &Destination) -> Self { Self::new(&value.name) } } +// ── RDCleanPath & DVC proxy ─────────────────────────────────────────────────── + +/// URL and authentication token for a fully-resolved RDCleanPath connection. #[derive(Clone, Debug)] pub struct RDCleanPathConfig { + /// RDCleanPath proxy URL. pub url: Url, + /// RDCleanPath authentication token (secret). pub auth_token: String, } +/// Name-to-pipe mapping for a single DVC proxy channel. #[derive(Clone, Debug)] pub struct DvcProxyInfo { pub channel_name: String, @@ -158,327 +441,1006 @@ impl FromStr for DvcProxyInfo { type Err = anyhow::Error; fn from_str(s: &str) -> Result { - let mut parts = s.split('='); - let channel_name = parts - .next() - .ok_or_else(|| anyhow::anyhow!("missing DVC channel name"))? - .to_owned(); - let pipe_name = parts - .next() - .ok_or_else(|| anyhow::anyhow!("missing DVC proxy pipe name"))? - .to_owned(); - + let (channel_name, pipe_name) = s + .split_once('=') + .context("missing '=' delimiter in DVC proxy specification")?; Ok(Self { - channel_name, - pipe_name, + channel_name: channel_name.to_owned(), + pipe_name: pipe_name.to_owned(), }) } } -/// Devolutions IronRDP client -#[derive(Parser, Debug)] -#[clap(author = "Devolutions", about = "Devolutions-IronRDP client")] -#[clap(version, long_about = None)] -struct Args { - /// A file with IronRDP client logs - #[clap(short, long, value_parser)] - log_file: Option, - - #[clap(long, value_parser)] - gw_endpoint: Option, - #[clap(long, value_parser)] - gw_user: Option, - #[clap(long, value_parser)] - gw_pass: Option, - - /// An address on which the client will connect. - destination: Option, +// ── ConfigBuilder ───────────────────────────────────────────────────────────── + +const RDP_DEFAULT_PORT: u16 = 3389; +const DEFAULT_WIDTH: u16 = 1280; +const DEFAULT_HEIGHT: u16 = 720; + +/// A configuration value that the consumer must supply before [`ConfigBuilder::build`] can succeed. +/// +/// Query the outstanding ones with [`ConfigBuilder::missing`], resolve each (prompt the user, or +/// derive a value), set it via the matching `with_*` method, then build. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MissingField { + /// Target server address (host[:port]). + ServerAddress, + /// RDP account user name. + Username, + /// RDP account password. + Password, + /// Gateway user name (only when a gateway transport is selected). + GatewayUsername, + /// Gateway password (only when a gateway transport is selected). + GatewayPassword, + /// RDCleanPath authentication token (only when an RDCleanPath transport is selected). + RDCleanPathToken, + /// Client build number (frontend-derived). + ClientBuild, + /// Client directory path (frontend-derived). + ClientDir, + /// Client platform (frontend-derived). + Platform, + /// Client computer name (frontend-derived). + ClientName, +} - /// Path to a .rdp file to read the configuration from. - #[clap(long)] - rdp_file: Option, +impl fmt::Display for MissingField { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::ServerAddress => "server address", + Self::Username => "username", + Self::Password => "password", + Self::GatewayUsername => "gateway username", + Self::GatewayPassword => "gateway password", + Self::RDCleanPathToken => "RDCleanPath token", + Self::ClientBuild => "client build", + Self::ClientDir => "client dir", + Self::Platform => "platform", + Self::ClientName => "client name", + }; + f.write_str(s) + } +} - /// A target RDP server user name - #[clap(short, long)] +/// Builder for [`Config`]. +/// +/// No defaults are created up-front for required values; they are tracked as unset until provided. +/// Truly optional settings receive sensible defaults inside [`build`](ConfigBuilder::build). Use +/// [`missing`](ConfigBuilder::missing) to discover which required fields still need a value. +/// +/// # Duplicate-channel behaviour +/// +/// * **Static channels** are keyed by the concrete processor `TypeId`; registering two factories +/// with the same concrete type silently shadows the earlier one via +/// [`ironrdp_connector::ClientConnector::attach_static_channel`]. +/// * **DVC channels** are keyed by channel name; duplicate names follow +/// [`ironrdp_dvc::DrdynvcClient`]'s overwrite semantics. +/// +/// # Custom-channel configuration keys +/// +/// Factory closures registered with [`with_static_channel`](Self::with_static_channel) and +/// [`with_dvc`](Self::with_dvc) receive the merged [`PropertySet`], so a custom channel can read +/// its own settings (enabled/disabled, endpoints, flags) straight from the `.rdp` file. Which keys +/// to read is entirely up to the channel: there is no enforced naming scheme. By convention, +/// IronRDP's own extension keys use an `ironrdp_` prefix to avoid colliding with standard Microsoft +/// keys, and custom channels are encouraged (but not required) to namespace their keys similarly +/// (e.g. `mycorp_mychannel_enabled`). A channel may equally reuse a standard MS key when that fits, +/// or adopt a completely different pattern if warranted — these are only conventions. +#[derive(Default)] +pub struct ConfigBuilder { + // Required (no default). + destination: Option, username: Option, - - /// An optional target RDP server domain name - #[clap(short, long)] + password: Option, + client_build: Option, + client_dir: Option, + client_name: Option, + platform: Option, + gateway_username: Option, + gateway_password: Option, + + // Optional (defaulted at build time). domain: Option, + enable_tls: Option, + enable_credssp: Option, + keyboard_type: Option, + keyboard_subtype: Option, + keyboard_functional_keys_count: Option, + ime_file_name: Option, + dig_product_id: Option, + desktop_width: Option, + desktop_height: Option, + desktop_scale_factor: Option, + color_depth: Option, + codecs: Vec, + autologon: Option, + enable_server_pointer: Option, + pointer_software_rendering: Option, + enable_audio_playback: Option, + compression_type: Option, + compression_enabled: Option, + alternate_shell: Option, + work_dir: Option, + + transport: TransportKind, + rdcleanpath_token: Option, + kerberos_config: Option, + fake_events_interval: Option, + channels: ChannelConfig, + #[cfg(feature = "dvc-pipe-proxy")] + dvc_pipe_proxies: Vec, + #[cfg(all(windows, feature = "dvc-com-plugin"))] + dvc_plugins: Vec, + properties: PropertySet, + extensions: ExtensionRegistry, +} - /// A target RDP server user password - #[clap(short, long)] - password: Option, +impl ConfigBuilder { + pub fn new() -> Self { + Self::default() + } - /// Proxy URL to connect to for the RDCleanPath - #[clap(long, requires("rdcleanpath_token"))] - rdcleanpath_url: Option, + #[must_use] + pub fn with_destination(mut self, destination: Destination) -> Self { + // Classify the host so the persisted `full address` follows TargetAddr's formatting rules + // (notably, IPv6 addresses must be bracketed). A bare `TargetHost::Domain` would drop the + // brackets and desynchronize the PropertySet from its own canonical formatting. + let host = match destination.name.parse::() { + Ok(ip) => ironrdp_cfg::TargetHost::Ip(ip), + Err(_) => ironrdp_cfg::TargetHost::Domain(destination.name.clone()), + }; + self.properties + .set_full_address(&ironrdp_cfg::TargetAddr { host, port: None }); + self.properties.set_server_port(destination.port); + self.properties.clear_alternate_full_address(); + self.destination = Some(destination); + self + } - /// Authentication token to insert in the RDCleanPath packet - #[clap(long, requires("rdcleanpath_url"))] - rdcleanpath_token: Option, + #[must_use] + pub fn with_username(mut self, username: impl Into) -> Self { + let username = username.into(); + self.username = Some(username.clone()); + self.properties.set_username(username); + self + } - /// The keyboard type - #[clap(long, value_enum, default_value_t = KeyboardType::IbmEnhanced)] - keyboard_type: KeyboardType, + /// Set the domain used by the RDP account credentials. Upserts the `domain` property. + #[must_use] + pub fn with_domain(mut self, domain: impl Into) -> Self { + let domain = domain.into(); + self.properties.set_domain(domain.clone()); + self.domain = Some(domain); + self + } - /// The keyboard subtype (an original equipment manufacturer-dependent value) - #[clap(long, default_value_t = 0)] - keyboard_subtype: u32, + #[must_use] + pub fn with_password(mut self, password: impl Into) -> Self { + self.password = Some(password.into()); + self + } - /// The number of function keys on the keyboard - #[clap(long, default_value_t = 12)] - keyboard_functional_keys_count: u32, + #[must_use] + pub fn with_gateway_username(mut self, username: impl Into) -> Self { + let username = username.into(); + self.gateway_username = Some(username.clone()); + self.properties.set_gateway_username(username); + self + } - /// The input method editor (IME) file name associated with the active input locale - #[clap(long, default_value_t = String::from(""))] - ime_file_name: String, + #[must_use] + pub fn with_gateway_password(mut self, password: impl Into) -> Self { + self.gateway_password = Some(password.into()); + self + } - /// Contains a value that uniquely identifies the client - #[clap(long, default_value_t = String::from(""))] - dig_product_id: String, + #[must_use] + pub fn with_client_build(mut self, build: u32) -> Self { + self.client_build = Some(build); + self + } - /// Enable thin client - #[clap(long)] - thin_client: bool, + #[must_use] + pub fn with_client_dir(mut self, dir: impl Into) -> Self { + self.client_dir = Some(dir.into()); + self + } - /// Enable small cache - #[clap(long)] - small_cache: bool, + #[must_use] + pub fn with_client_name(mut self, name: impl Into) -> Self { + self.client_name = Some(name.into()); + self + } - /// Set required color depth. Currently only 32 and 16 bit color depths are supported - #[clap(long)] - color_depth: Option, + #[must_use] + pub fn with_platform(mut self, platform: ironrdp_pdu::rdp::capability_sets::MajorPlatformType) -> Self { + self.platform = Some(platform); + self + } + + #[must_use] + pub fn with_keyboard_type(mut self, ty: ironrdp_pdu::gcc::KeyboardType) -> Self { + self.keyboard_type = Some(ty); + self + } + + #[must_use] + pub fn with_keyboard_subtype(mut self, subtype: u32) -> Self { + self.keyboard_subtype = Some(subtype); + self + } - /// Ignore mouse pointer messages sent by the server. Increases performance when enabled, as the - /// client could skip costly software rendering of the pointer with alpha blending - #[clap(long)] - no_server_pointer: bool, + #[must_use] + pub fn with_keyboard_functional_keys_count(mut self, count: u32) -> Self { + self.keyboard_functional_keys_count = Some(count); + self + } + + #[must_use] + pub fn with_ime_file_name(mut self, name: impl Into) -> Self { + self.ime_file_name = Some(name.into()); + self + } + + #[must_use] + pub fn with_dig_product_id(mut self, id: impl Into) -> Self { + self.dig_product_id = Some(id.into()); + self + } - /// Enabled capability versions. Each bit represents enabling a capability version - /// starting from V8 to V10_7 - #[clap(long, value_parser = parse_hex, default_value_t = 0)] - capabilities: u32, + #[must_use] + pub fn with_color_depth(mut self, depth: u32) -> Self { + self.color_depth = Some(depth); + self.properties.set_color_depth(depth); + self + } + + /// Set the desktop width (in pixels) to request. Upserts the `desktopwidth` property. + /// + /// Together with [`with_desktop_height`](Self::with_desktop_height) this becomes the initial + /// [`DesktopSize`](ironrdp_connector::DesktopSize) advertised to the server. + #[must_use] + pub fn with_desktop_width(mut self, width: u16) -> Self { + self.desktop_width = Some(width); + self.properties.set_desktop_width(width); + self + } - /// Automatically logon to the server by passing the INFO_AUTOLOGON flag + /// Set the desktop height (in pixels) to request. Upserts the `desktopheight` property. /// - /// This flag is ignored if CredSSP authentication is used. - /// You can use `--no-credssp` to ensure it’s not. - #[clap(long)] - autologon: bool, + /// Together with [`with_desktop_width`](Self::with_desktop_width) this becomes the initial + /// [`DesktopSize`](ironrdp_connector::DesktopSize) advertised to the server. + #[must_use] + pub fn with_desktop_height(mut self, height: u16) -> Self { + self.desktop_height = Some(height); + self.properties.set_desktop_height(height); + self + } - /// Disable TLS + Graphical login (legacy authentication method) + /// Set the desktop scale factor (percentage, typically 100–500) to request. Upserts the + /// `desktopscalefactor` property. /// - /// Disabling this in order to enforce usage of CredSSP (NLA) is recommended. - #[clap(long)] - no_tls: bool, + /// This becomes the `desktop_scale_factor` in the `TS_UD_CS_CORE` GCC structure. + #[must_use] + pub fn with_desktop_scale_factor(mut self, scale: u32) -> Self { + self.desktop_scale_factor = Some(scale); + self.properties.set_desktop_scale_factor(scale); + self + } - /// Disable TLS + Network Level Authentication (NLA) using CredSSP + /// Enable or disable TLS + Network Level Authentication (NLA) using CredSSP. Upserts the + /// `enablecredsspsupport` property. /// - /// NLA is used to authenticates RDP clients and servers before sending credentials over the network. - /// It’s not recommended to disable this. - #[clap(long, alias = "no-nla")] - no_credssp: bool, + /// NLA allows authentication to be performed before session establishment, considerably + /// reducing the attack surface compared to the legacy TLS security protocol. When connecting to + /// NLA-capable servers it is recommended to also disable plain TLS via + /// [`with_tls(false)`](Self::with_tls). + #[doc(alias("with_nla", "with_enable_credssp"))] + #[must_use] + pub fn with_credssp(mut self, enabled: bool) -> Self { + self.enable_credssp = Some(enabled); + self.properties.set_enable_credssp_support(enabled); + self + } - /// The clipboard type - #[clap(long, value_enum, default_value_t = ClipboardType::Default)] - clipboard_type: ClipboardType, + /// Set the bitmap codecs (e.g. `["remotefx:on"]`). Not reflected in the PropertySet. + #[must_use] + pub fn with_codecs(mut self, codecs: Vec) -> Self { + self.codecs = codecs; + self + } - /// The bitmap codecs to use (remotefx:on, ...) - #[clap(long, num_args = 1.., value_delimiter = ',')] - codecs: Vec, + #[must_use] + pub fn with_autologon(mut self, enabled: bool) -> Self { + self.autologon = Some(enabled); + self.properties.set_autologon(enabled); + self + } - /// Add DVC channel named pipe proxy + /// Enable or disable TLS + Graphical login (legacy security protocol; also called SSL). Upserts + /// the `ironrdp_tls` property. /// - /// The format is `=`, e.g., `ChannelName=PipeName` where `ChannelName` is the name of the channel, - /// and `PipeName` is the name of the named pipe to connect to (without OS-specific prefix). - /// `` will automatically be prefixed with `\\.\pipe\` on Windows. - #[clap(long)] - dvc_proxy: Vec, -} - -impl Config { - pub fn parse_args() -> anyhow::Result { - use ironrdp_cfg::PropertySetExt as _; + /// When this security protocol is negotiated, the RDP server shows a graphical login screen and + /// the full connection sequence is performed with all static channels joined and active. This + /// exposes a wide attack surface (MITM, server-side and client-side takeover, file stealing) and + /// is being phased out. Set this to `false` to effectively enforce usage of NLA/CredSSP on the + /// client side (see [`with_credssp`](Self::with_credssp)). + #[doc(alias("with_enable_tls"))] + #[must_use] + pub fn with_tls(mut self, enabled: bool) -> Self { + self.enable_tls = Some(enabled); + self.properties.set_enable_tls(enabled); + self + } - let args = Args::parse(); + #[must_use] + pub fn with_server_pointer(mut self, enabled: bool) -> Self { + self.enable_server_pointer = Some(enabled); + self.properties.set_server_pointer(enabled); + self + } - let mut properties = ironrdp_propertyset::PropertySet::new(); + /// Enable or disable software pointer rendering. When enabled, the session composites the + /// remote cursor directly into the decoded framebuffer (instead of emitting it as separate + /// pointer events for a hardware/overlay cursor). Useful for headless clients that have no + /// overlay of their own and want the cursor captured in the frame. + #[must_use] + pub fn with_pointer_software_rendering(mut self, enabled: bool) -> Self { + self.pointer_software_rendering = Some(enabled); + self + } - if let Some(rdp_file) = args.rdp_file { - let input = - std::fs::read_to_string(&rdp_file).with_context(|| format!("failed to read {}", rdp_file.display()))?; + /// Enable or disable bulk compression support. Upserts the `compression` property. + #[must_use] + pub fn with_compression(mut self, enabled: bool) -> Self { + self.compression_enabled = Some(enabled); + self.properties.set_compression(enabled); + self + } - if let Err(errors) = ironrdp_rdpfile::load(&mut properties, &input) { - for e in errors { - #[expect(clippy::print_stderr)] - { - eprintln!("Error when reading {}: {e}", rdp_file.display()) - } - } - } + /// Set the bulk compression type directly. Upserts the `ironrdp_compressionlevel` property, + /// or clears it when `ty` is `None`. + /// + /// When set, the `INFO_COMPRESSION` flag is included in the Client Info PDU and the specified + /// compression type is advertised. The server may then send compressed PDUs using any + /// compression algorithm up to and including this level: + /// + /// - `None` — no compression (default) + /// - `Some(K8)` — MPPC with 8 KB history (RDP 4.0) + /// - `Some(K64)` — MPPC with 64 KB history (RDP 5.0) + /// - `Some(Rdp6)` — NCRUSH (RDP 6.0) + /// - `Some(Rdp61)` — XCRUSH (RDP 6.1) + #[must_use] + pub fn with_compression_type(mut self, ty: Option) -> Self { + self.compression_type = ty; + if let Some(ty) = ty { + self.properties.set_compression_level(level_from_compression_type(ty)); + } else { + self.properties.clear_compression_level(); } + self + } - let mut gw: Option = None; - if let Some(gw_addr) = args.gw_endpoint { - gw = Some(GwConnectTarget { - gw_endpoint: gw_addr, - gw_user: String::new(), - gw_pass: String::new(), - server: String::new(), // TODO: non-standard port? also dont use here? - }); + /// Set the bulk compression type from a level (0–3). Out-of-range levels are ignored. + /// + /// The level maps to a [`CompressionType`](ironrdp_pdu::rdp::client_info::CompressionType): + /// `0` → `K8`, `1` → `K64`, `2` → `Rdp6`, `3` → `Rdp61`. See + /// [`with_compression_type`](Self::with_compression_type) for the semantics of each level. + #[must_use] + pub fn with_compression_level(self, level: u32) -> Self { + match compression_type_from_level(level) { + Ok(ty) => self.with_compression_type(Some(ty)), + Err(_) => self, } + } - if let Some(ref mut gw) = gw { - gw.gw_user = if let Some(gw_user) = args.gw_user { - gw_user - } else { - inquire::Text::new("Gateway username:") - .prompt() - .context("Username prompt")? - }; + /// Select the transport. Upserts the corresponding addressing properties + /// (`ironrdp_rdcleanpathurl`, `gatewayhostname`/`gatewayusagemethod`), clearing the other + /// transport's properties so the PropertySet stays consistent. + /// + /// Secrets are *not* set here: supply gateway credentials via + /// [`with_gateway_username`](Self::with_gateway_username) / + /// [`with_gateway_password`](Self::with_gateway_password) and the RDCleanPath token via + /// [`with_rdcleanpath_token`](Self::with_rdcleanpath_token). + #[must_use] + pub fn with_transport(mut self, transport: TransportKind) -> Self { + match &transport { + TransportKind::Direct => { + self.properties.clear_rdcleanpath(); + self.properties.clear_gateway(); + } + TransportKind::RDCleanPath { url } => { + self.properties.clear_gateway(); + self.properties.set_rdcleanpath_url(url.to_string()); + } + #[cfg(feature = "gateway")] + TransportKind::Gateway { endpoint } => { + self.properties.clear_rdcleanpath(); + self.properties.set_gateway_hostname(endpoint.clone()); + self.properties + .set_gateway_usage_method(ironrdp_cfg::GatewayUsageMethod::UseAlways); + } + } + self.transport = transport; + self + } - gw.gw_pass = if let Some(gw_pass) = args.gw_pass { - gw_pass - } else { - inquire::Password::new("Gateway password:") - .without_confirmation() - .prompt() - .context("Password prompt")? - }; - }; + /// Set the RDCleanPath authentication token (only meaningful with an RDCleanPath transport). + /// + /// The token is a secret: like the gateway password, it is *not* mirrored into the PropertySet, + /// and [`build`](Self::build) strips any token loaded from a `.rdp` file before exposing + /// [`Config::properties`]. + #[must_use] + pub fn with_rdcleanpath_token(mut self, token: impl Into) -> Self { + self.rdcleanpath_token = Some(token.into()); + self + } - let destination = if let Some(destination) = args.destination { - destination - } else if let Some(destination) = properties.full_address() { - if let Some(port) = properties.server_port() { - format!("{destination}:{port}").parse() - } else { - destination.parse() - } - .context("invalid destination")? + /// Set the kerberos config. Upserts the `kdcproxyurl` property (or clears it when the config + /// has no KDC proxy URL); `hostname` is derived from the client name and not stored separately. + #[must_use] + pub fn with_kerberos_config(mut self, cfg: ironrdp_connector::credssp::KerberosConfig) -> Self { + if let Some(url) = &cfg.kdc_proxy_url { + self.properties.set_kdc_proxy_url(url.to_string()); } else { - inquire::Text::new("Server address:") - .prompt() - .context("Address prompt")? - .pipe(Destination::new)? - }; - - if let Some(ref mut gw) = gw { - gw.server = destination.name.clone(); // TODO + self.properties.clear_kdc_proxy_url(); } + self.kerberos_config = Some(cfg); + self + } - let username = if let Some(username) = args.username { - username - } else if let Some(username) = properties.username() { - username.to_owned() - } else { - inquire::Text::new("Username:").prompt().context("Username prompt")? - }; + #[must_use] + pub fn with_fake_events_interval(mut self, interval: Duration) -> Self { + self.fake_events_interval = Some(interval); + self.properties + .set_fake_events_interval(u32::try_from(interval.as_secs() / 60).unwrap_or(u32::MAX)); + self + } - let password = if let Some(password) = args.password { - password - } else if let Some(password) = properties.clear_text_password() { - password.to_owned() + /// Enable or disable RDPSND (audio) playback. + #[cfg(feature = "sound")] + #[must_use] + pub fn with_sound(mut self, enabled: bool) -> Self { + self.channels.sound = enabled; + self.properties.set_audio_mode(if enabled { + ironrdp_cfg::AudioMode::RedirectToClient } else { - inquire::Password::new("Password:") - .without_confirmation() - .prompt() - .context("Password prompt")? - }; + ironrdp_cfg::AudioMode::Disabled + }); + self + } + + /// Set the CLIPRDR (clipboard) redirection mode. + #[cfg(feature = "clipboard")] + #[must_use] + pub fn with_clipboard(mut self, mode: ClipboardType) -> Self { + self.channels.clipboard = mode; + self.properties + .set_redirect_clipboard(matches!(mode, ClipboardType::Enable)); + self + } + + /// Enable or disable RDPDR (device redirection). + #[cfg(feature = "rdpdr")] + #[must_use] + pub fn with_rdpdr(mut self, enabled: bool) -> Self { + self.channels.rdpdr.enabled = enabled; + self.properties.set_enable_rdpdr(enabled); + self + } + + /// Enable or disable smart-card redirection within RDPDR. + #[cfg(feature = "smartcard")] + #[must_use] + pub fn with_smartcard(mut self, enabled: bool) -> Self { + self.channels.rdpdr.smartcard = enabled; + self.properties.set_enable_smartcard(enabled); + self + } + + /// Enable or disable QOI bitmap codec at runtime. + #[cfg(feature = "qoi")] + #[must_use] + pub fn with_qoi(mut self, enabled: bool) -> Self { + self.channels.qoi = enabled; + self.properties.set_enable_qoi(enabled); + self + } + + /// Enable or disable QOIZ bitmap codec at runtime. + #[cfg(feature = "qoiz")] + #[must_use] + pub fn with_qoiz(mut self, enabled: bool) -> Self { + self.channels.qoiz = enabled; + self.properties.set_enable_qoiz(enabled); + self + } + + // TODO: It can be useful to have a method for enabling or disabling all the extra channels at once. + // Example: in tests, disable all + enable only the required channel. + + /// Add a DVC pipe proxy channel. + #[cfg(feature = "dvc-pipe-proxy")] + #[must_use] + pub fn with_dvc_pipe_proxy(mut self, info: DvcProxyInfo) -> Self { + self.dvc_pipe_proxies.push(info); + self.properties + .set_dvc_pipe_proxies(self.dvc_pipe_proxies.iter().map(|p| ironrdp_cfg::DvcPipeProxy { + channel_name: p.channel_name.clone(), + pipe_name: p.pipe_name.clone(), + })); + self + } + + /// Add a DVC COM plugin DLL path (Windows only). + #[cfg(all(windows, feature = "dvc-com-plugin"))] + #[must_use] + pub fn with_dvc_plugin(mut self, path: impl Into) -> Self { + self.dvc_plugins.push(path.into()); + self.properties + .set_dvc_plugins(self.dvc_plugins.iter().map(PathBuf::as_path)); + self + } + + /// Register a factory for a user-defined static virtual channel. + /// + /// `factory` is called once per connection attempt with the shared (read-only) [`PropertySet`], + /// so the channel can parametrize itself from the standard frontend config. Return `None` to + /// disable the channel. Duplicate processor types follow `attach_static_channel` overwrite semantics. + #[must_use] + pub fn with_static_channel(mut self, factory: F) -> Self + where + F: Fn(&PropertySet) -> Option

+ Send + Sync + 'static, + P: ironrdp_svc::SvcClientProcessor + 'static, + { + let cb: StaticChannelFn = Arc::new(move |connector: &mut ironrdp_connector::ClientConnector, ps| { + if let Some(processor) = factory(ps) { + connector.attach_static_channel(processor); + } + }); + self.extensions.static_channels.push(cb); + self + } - let codecs: Vec<_> = args.codecs.iter().map(|s| s.as_str()).collect(); - let codecs = match client_codecs_capabilities(&codecs) { - Ok(codecs) => codecs, - Err(help) => { - print!("{help}"); - std::process::exit(0); + /// Register a factory for a user-defined dynamic virtual channel. + /// + /// `factory` is called once per connection attempt with the shared (read-only) [`PropertySet`], + /// so the channel can parametrize itself from the standard frontend config. Return `None` to + /// disable the channel. Duplicate channel names follow `DrdynvcClient` overwrite semantics. + #[must_use] + pub fn with_dvc(mut self, factory: F) -> Self + where + F: Fn(&PropertySet) -> Option

+ Send + Sync + 'static, + P: ironrdp_dvc::DvcProcessor + 'static, + { + let cb: DvcChannelFn = Arc::new(move |drdynvc, ps| { + if let Some(processor) = factory(ps) { + drdynvc.attach_dynamic_channel(processor); } - }; - let mut bitmap = connector::BitmapConfig { - color_depth: 32, + }); + self.extensions.dvc_channels.push(cb); + self + } + + /// List the required fields that still need a value before [`build`](Self::build) can succeed. + /// + /// Gateway credentials are only required when a gateway transport is selected. + pub fn missing(&self) -> Vec { + let mut missing = Vec::new(); + if self.destination.is_none() { + missing.push(MissingField::ServerAddress); + } + if self.username.is_none() { + missing.push(MissingField::Username); + } + if self.password.is_none() { + missing.push(MissingField::Password); + } + #[cfg(feature = "gateway")] + if matches!(self.transport, TransportKind::Gateway { .. }) { + if self.gateway_username.is_none() { + missing.push(MissingField::GatewayUsername); + } + if self.gateway_password.is_none() { + missing.push(MissingField::GatewayPassword); + } + } + if matches!(self.transport, TransportKind::RDCleanPath { .. }) && self.rdcleanpath_token.is_none() { + missing.push(MissingField::RDCleanPathToken); + } + if self.client_build.is_none() { + missing.push(MissingField::ClientBuild); + } + if self.client_dir.is_none() { + missing.push(MissingField::ClientDir); + } + if self.platform.is_none() { + missing.push(MissingField::Platform); + } + if self.client_name.is_none() { + missing.push(MissingField::ClientName); + } + missing + } + + /// Build the [`Config`], filling optional settings with sensible defaults. + /// + /// Fails if any required field is unset; inspect [`missing`](Self::missing) beforehand to resolve them. + #[expect( + clippy::missing_panics_doc, + reason = "a panic here would be a bug (secrets are guaranteed present by missing()), not documented behavior" + )] + pub fn build(self) -> anyhow::Result { + use ironrdp_pdu::rdp::capability_sets::client_codecs_capabilities; + use ironrdp_pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; + + let missing = self.missing(); + if !missing.is_empty() { + anyhow::bail!( + "missing required configuration: {}", + missing + .iter() + .map(MissingField::to_string) + .collect::>() + .join(", ") + ); + } + + let codecs: Vec<&str> = self.codecs.iter().map(String::as_str).collect(); + let codecs = client_codecs_capabilities(&codecs).map_err(|help| anyhow::anyhow!("{help}"))?; + let color_depth = self.color_depth.unwrap_or(32); + if color_depth != 16 && color_depth != 32 { + anyhow::bail!("invalid color depth: only 16 and 32 bit color depths are supported"); + } + let bitmap = ironrdp_connector::BitmapConfig { + color_depth, lossy_compression: true, codecs, }; - if let Some(color_depth) = args.color_depth { - if color_depth != 16 && color_depth != 32 { - anyhow::bail!("Invalid color depth. Only 16 and 32 bit color depths are supported."); - } - bitmap.color_depth = color_depth; + // Resolve the granular transport selection into the bundled form, folding in the separately + // tracked secrets (gateway credentials, RDCleanPath token). + #[expect( + clippy::unwrap_used, + reason = "the transport secrets are guaranteed present by the missing() check above" + )] + let transport = match self.transport { + TransportKind::Direct => Transport::Direct, + #[cfg(feature = "gateway")] + TransportKind::Gateway { endpoint } => Transport::Gateway(GatewayConfig { + endpoint, + username: self.gateway_username.unwrap(), + password: self.gateway_password.unwrap(), + }), + TransportKind::RDCleanPath { url } => Transport::RDCleanPath(RDCleanPathConfig { + url, + auth_token: self.rdcleanpath_token.unwrap(), + }), }; - let clipboard_type = if args.clipboard_type == ClipboardType::Default { - #[cfg(windows)] - { - ClipboardType::Windows - } - #[cfg(not(windows))] - { - ClipboardType::None - } + let client_name = self.client_name.unwrap_or_default(); + let kerberos_config = self + .kerberos_config + .or_else(|| kerberos_config_from_properties(&self.properties, &client_name)); + + // Bulk compression is enabled by default. We default to MPPC 64K (RDP5) rather than the + // richer XCRUSH (RDP6.1) because it is the most universally supported and lowest-state + // codec, and FastPath decompression is the only fully wired path. + // FIXME: bump the default to RDP6.1 (XCRUSH) once slow-path bulk decompression is wired + // (see ironrdp-session x224 path); until then a stateful codec risks silent corruption. + let compression_type = if self.compression_enabled.unwrap_or(true) { + Some( + self.compression_type + .unwrap_or(ironrdp_pdu::rdp::client_info::CompressionType::K64), + ) } else { - args.clipboard_type + None }; - let connector = connector::Config { - credentials: Credentials::UsernamePassword { username, password }, - domain: args.domain, - enable_tls: !args.no_tls, - enable_credssp: !args.no_credssp, - keyboard_type: KeyboardType::parse(args.keyboard_type), - keyboard_subtype: args.keyboard_subtype, - keyboard_layout: 0, // the server SHOULD use the default active input locale identifier - keyboard_functional_keys_count: args.keyboard_functional_keys_count, - ime_file_name: args.ime_file_name, - dig_product_id: args.dig_product_id, - desktop_size: connector::DesktopSize { - width: DEFAULT_WIDTH, - height: DEFAULT_HEIGHT, + let connector = ironrdp_connector::Config { + credentials: ironrdp_connector::Credentials::UsernamePassword { + username: self.username.unwrap_or_default(), + password: self.password.unwrap_or_default(), }, - desktop_scale_factor: 0, // Default to 0 per FreeRDP - bitmap: Some(bitmap), - client_build: semver::Version::parse(env!("CARGO_PKG_VERSION")) - .map(|version| version.major * 100 + version.minor * 10 + version.patch) - .unwrap_or(0) - .pipe(u32::try_from) - .unwrap(), - client_name: whoami::fallible::hostname().unwrap_or_else(|_| "ironrdp".to_owned()), - // NOTE: hardcode this value like in freerdp - // https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70 - client_dir: "C:\\Windows\\System32\\mstscax.dll".to_owned(), - platform: match whoami::platform() { - whoami::Platform::Windows => MajorPlatformType::WINDOWS, - whoami::Platform::Linux => MajorPlatformType::UNIX, - whoami::Platform::MacOS => MajorPlatformType::MACINTOSH, - whoami::Platform::Ios => MajorPlatformType::IOS, - whoami::Platform::Android => MajorPlatformType::ANDROID, - _ => MajorPlatformType::UNSPECIFIED, + domain: self.domain, + enable_tls: self.enable_tls.unwrap_or(true), + enable_credssp: self.enable_credssp.unwrap_or(true), + keyboard_type: self + .keyboard_type + .unwrap_or(ironrdp_pdu::gcc::KeyboardType::IbmEnhanced), + keyboard_subtype: self.keyboard_subtype.unwrap_or(0), + keyboard_layout: 0, + keyboard_functional_keys_count: self.keyboard_functional_keys_count.unwrap_or(12), + ime_file_name: self.ime_file_name.unwrap_or_default(), + dig_product_id: self.dig_product_id.unwrap_or_default(), + desktop_size: ironrdp_connector::DesktopSize { + width: self.desktop_width.unwrap_or(DEFAULT_WIDTH), + height: self.desktop_height.unwrap_or(DEFAULT_HEIGHT), }, + desktop_scale_factor: self.desktop_scale_factor.unwrap_or(0), + bitmap: Some(bitmap), + client_build: self.client_build.unwrap_or_default(), + client_name, + client_dir: self.client_dir.unwrap_or_default(), + platform: self + .platform + .unwrap_or(ironrdp_pdu::rdp::capability_sets::MajorPlatformType::UNSPECIFIED), hardware_id: None, license_cache: None, - enable_server_pointer: !args.no_server_pointer, - autologon: args.autologon, - enable_audio_playback: true, + enable_server_pointer: self.enable_server_pointer.unwrap_or(true), + autologon: self.autologon.unwrap_or(false), + enable_audio_playback: self.enable_audio_playback.unwrap_or(true), request_data: None, - pointer_software_rendering: false, + pointer_software_rendering: self.pointer_software_rendering.unwrap_or(false), + multitransport_flags: None, + compression_type, performance_flags: PerformanceFlags::default(), timezone_info: TimezoneInfo::default(), + alternate_shell: self.alternate_shell.unwrap_or_default(), + work_dir: self.work_dir.unwrap_or_default(), }; - let rdcleanpath = args - .rdcleanpath_url - .zip(args.rdcleanpath_token) - .map(|(url, auth_token)| RDCleanPathConfig { url, auth_token }); - - Ok(Self { - log_file: args.log_file, - gw, - destination, + // To avoid easily leaking secrets, strip any known secret property before returning the resulting Config. + let mut properties = self.properties; + let detected_secrets = properties + .iter() + .filter(|(key, _)| ironrdp_cfg::is_secret_key(key)) + .map(|(key, _)| key.clone().into_owned()) + .collect::>(); + detected_secrets.into_iter().for_each(|key| { + properties.remove(&key); + }); + + Ok(Config { connector, - clipboard_type, - rdcleanpath, - dvc_pipe_proxies: args.dvc_proxy, + destination: self.destination.context("server address is required")?, + transport, + kerberos_config, + fake_events_interval: self.fake_events_interval, + channels: self.channels, + #[cfg(feature = "dvc-pipe-proxy")] + dvc_pipe_proxies: self.dvc_pipe_proxies, + #[cfg(all(windows, feature = "dvc-com-plugin"))] + dvc_plugins: self.dvc_plugins, + properties, + extensions: self.extensions, }) } + + /// Build a [`Config`] from a `.rdp` [`PropertySet`], leaving anything not expressible as a + /// property unset (query [`missing`](Self::missing) to resolve the rest). + pub fn from_property_set(ps: &PropertySet) -> anyhow::Result { + ConfigBuilder::new().with_property_set(ps) + } + + /// Overlay a `.rdp` [`PropertySet`] on top of the current builder. + /// + /// Only properties present in `ps` set values, so this can be layered: + /// `explicit setters → PropertySet → more setters`, last writer wins. Resolution rules: + /// `full address` beats `alternate full address`, an embedded port beats `server port`, and + /// transport precedence is RDCleanPath > Gateway > Direct. + pub fn with_property_set(mut self, ps: &PropertySet) -> anyhow::Result { + #[cfg(feature = "gateway")] + use ironrdp_cfg::GatewayUsageMethod; + use ironrdp_cfg::{AudioMode, TargetHost}; + + self.properties.merge(ps); + + let target = ps.full_address().context("invalid 'full address'")?.or(ps + .alternate_full_address() + .context("invalid 'alternate full address'")?); + if let Some(target) = target { + let port = target + .port + .or(ps.server_port().context("invalid 'server port'")?) + .unwrap_or(RDP_DEFAULT_PORT); + let name = match target.host { + TargetHost::Ip(ip) => ip.to_string(), + TargetHost::Domain(host) => host, + }; + self.destination = Some(Destination::from_parts(name, port)); + } + + if let Some(username) = ps.username() { + self.username = Some(username.to_owned()); + } + if let Some(password) = ps.clear_text_password() { + self.password = Some(password.to_owned()); + } + if let Some(domain) = ps.domain() { + self.domain = Some(domain.to_owned()); + } + if let Some(enable_credssp) = ps.enable_credssp_support() { + self.enable_credssp = Some(enable_credssp); + } + if let Some(enable_tls) = ps.enable_tls() { + self.enable_tls = Some(enable_tls); + } + if let Some(server_pointer) = ps.server_pointer() { + self.enable_server_pointer = Some(server_pointer); + } + if let Some(autologon) = ps.autologon() { + self.autologon = Some(autologon); + } + if let Some(scale) = ps.desktop_scale_factor().ok().flatten() { + self.desktop_scale_factor = Some(scale); + } + if let Some(width) = ps.desktop_width().ok().flatten() { + self.desktop_width = Some(width); + } + if let Some(height) = ps.desktop_height().ok().flatten() { + self.desktop_height = Some(height); + } + if let Some(shell) = ps.alternate_shell() { + self.alternate_shell = Some(shell.to_owned()); + } + if let Some(dir) = ps.shell_working_directory() { + self.work_dir = Some(dir.to_owned()); + } + if let Some(minutes) = ps.fake_events_interval() { + self.fake_events_interval = Some(Duration::from_secs(u64::from(minutes) * 60)); + } + if let Some(level) = ps.compression_level() { + self.compression_type = Some(compression_type_from_level(level)?); + } + if let Some(enabled) = ps.compression() { + self.compression_enabled = Some(enabled); + } + if let Some(depth) = ps.color_depth() { + self.color_depth = Some(depth); + } + match ps.audio_mode() { + Ok(Some(AudioMode::PlayOnServer | AudioMode::Disabled)) => self.enable_audio_playback = Some(false), + Ok(Some(AudioMode::RedirectToClient)) => self.enable_audio_playback = Some(true), + _ => {} + } + + // Transport: RDCleanPath > Gateway > Direct. + if let Some((url, token)) = ps.rdcleanpath_url().zip(ps.rdcleanpath_token()) { + let url = Url::parse(url).context("invalid 'ironrdp_rdcleanpathurl'")?; + self.transport = TransportKind::RDCleanPath { url }; + self.rdcleanpath_token = Some(token.to_owned()); + } else { + #[cfg(feature = "gateway")] + { + let gateway_usage = ps + .gateway_usage_method() + .context("invalid Gateway usage method")? + .unwrap_or_default(); + + let gateway_hostname = ps.gateway_hostname(); + + let select_gateway_transport = match gateway_usage { + // Explicit gateway use. + GatewayUsageMethod::UseAlways => true, + + // Approximation of Windows "try direct, then gateway" behavior. + GatewayUsageMethod::Detect => gateway_hostname.is_some(), + + // IronRDP does not currently resolve MSTSC/client/GPO default gateway policy. + GatewayUsageMethod::UseDefaultSettings => false, + + // Explicit no-gateway modes. + GatewayUsageMethod::Direct | GatewayUsageMethod::DirectBypassLocal => false, + }; + + if select_gateway_transport { + let endpoint = gateway_hostname.context("missing Gateway hostname")?; + + self.transport = TransportKind::Gateway { + endpoint: endpoint.to_owned(), + }; + + if let Some(user) = ps.gateway_username() { + self.gateway_username = Some(user.to_owned()); + } + + if let Some(pass) = ps.gateway_password() { + self.gateway_password = Some(pass.to_owned()); + } + } + } + } + + if let Some(redirect) = ps.redirect_clipboard() { + #[cfg(feature = "clipboard")] + { + self.channels.clipboard = if redirect { + ClipboardType::Enable + } else { + ClipboardType::Disable + }; + } + let _ = redirect; + } + #[cfg(feature = "sound")] + if matches!(ps.audio_mode(), Ok(Some(AudioMode::Disabled))) { + self.channels.sound = false; + } + #[cfg(feature = "rdpdr")] + if let Some(enabled) = ps.enable_rdpdr() { + self.channels.rdpdr.enabled = enabled; + } + #[cfg(feature = "smartcard")] + if let Some(enabled) = ps.enable_smartcard() { + self.channels.rdpdr.smartcard = enabled; + } + #[cfg(feature = "qoi")] + if let Some(enabled) = ps.enable_qoi() { + self.channels.qoi = enabled; + } + #[cfg(feature = "qoiz")] + if let Some(enabled) = ps.enable_qoiz() { + self.channels.qoiz = enabled; + } + + #[cfg(feature = "dvc-pipe-proxy")] + for (idx, proxy) in ps.dvc_pipe_proxies().enumerate() { + let proxy = proxy.with_context(|| format!("invalid DVC pipe proxy spec at idx {idx}"))?; + self.dvc_pipe_proxies.push(DvcProxyInfo { + channel_name: proxy.channel_name, + pipe_name: proxy.pipe_name, + }); + } + + #[cfg(all(windows, feature = "dvc-com-plugin"))] + self.dvc_plugins.extend(ps.dvc_plugins()); + + Ok(self) + } +} + +/// Map a bulk-compression level (0–3) to the corresponding [`CompressionType`]. +/// +/// 0 = MPPC 8K (RDP4), 1 = MPPC 64K (RDP5), 2 = NCRUSH (RDP6), 3 = XCRUSH (RDP6.1). +/// +/// [`CompressionType`]: ironrdp_pdu::rdp::client_info::CompressionType +fn compression_type_from_level(level: u32) -> anyhow::Result { + use ironrdp_pdu::rdp::client_info::CompressionType; + + match level { + 0 => Ok(CompressionType::K8), + 1 => Ok(CompressionType::K64), + 2 => Ok(CompressionType::Rdp6), + 3 => Ok(CompressionType::Rdp61), + _ => anyhow::bail!("invalid compression level: valid values are 0, 1, 2, 3"), + } +} + +fn level_from_compression_type(ty: ironrdp_pdu::rdp::client_info::CompressionType) -> u32 { + use ironrdp_pdu::rdp::client_info::CompressionType; + + match ty { + CompressionType::K8 => 0, + CompressionType::K64 => 1, + CompressionType::Rdp6 => 2, + CompressionType::Rdp61 => 3, + } +} + +/// Derive a Kerberos/KDC-proxy config from `kdcproxyurl`/`kdcproxyname`, using `client_name` as the +/// SPN hostname. Returns `None` if no KDC proxy is configured or the URL is invalid. +fn kerberos_config_from_properties( + ps: &PropertySet, + client_name: &str, +) -> Option { + use ironrdp_cfg::PropertySetExt as _; + + let kdc_proxy_url = ps.kdc_proxy_url().map(str::to_owned).or_else(|| { + ps.kdc_proxy_name().map(|name| { + if name.starts_with("http://") || name.starts_with("https://") { + name.to_owned() + } else { + format!("https://{name}/KdcProxy") + } + }) + })?; + + Url::parse(&kdc_proxy_url) + .ok() + .map(|url| ironrdp_connector::credssp::KerberosConfig { + kdc_proxy_url: Some(url), + hostname: client_name.to_owned(), + }) } diff --git a/crates/ironrdp-client/src/lib.rs b/crates/ironrdp-client/src/lib.rs index de4b5276d9..a567b8b762 100644 --- a/crates/ironrdp-client/src/lib.rs +++ b/crates/ironrdp-client/src/lib.rs @@ -1,7 +1,5 @@ #![cfg_attr(doc, doc = include_str!("../README.md"))] #![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] -#![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary - // No need to be as strict as in production libraries #![allow(clippy::arithmetic_side_effects)] #![allow(clippy::cast_lossless)] @@ -9,9 +7,10 @@ #![allow(clippy::cast_possible_wrap)] #![allow(clippy::cast_sign_loss)] -pub mod app; -pub mod clipboard; pub mod config; pub mod rdp; +#[cfg(all(windows, feature = "clipboard"))] +mod clipboard; + mod ws; diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index c3f7341a2b..0d44cb7ee0 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -1,40 +1,67 @@ +use core::net::SocketAddr; +use core::num::NonZeroU16; +use core::time::Duration; use std::sync::Arc; -use ironrdp::cliprdr::backend::{ClipboardMessage, CliprdrBackendFactory}; -use ironrdp::connector::connection_activation::ConnectionActivationState; -use ironrdp::connector::{ConnectionResult, ConnectorResult}; -use ironrdp::displaycontrol::client::DisplayControlClient; -use ironrdp::displaycontrol::pdu::MonitorLayoutEntry; -use ironrdp::graphics::image_processing::PixelFormat; -use ironrdp::graphics::pointer::DecodedPointer; -use ironrdp::pdu::input::fast_path::FastPathInputEvent; -use ironrdp::pdu::{pdu_other_err, PduResult}; -use ironrdp::session::image::DecodedImage; -use ironrdp::session::{fast_path, ActiveStage, ActiveStageOutput, GracefulDisconnectReason, SessionResult}; -use ironrdp::svc::SvcMessage; -use ironrdp::{cliprdr, connector, rdpdr, rdpsnd, session}; +use ironrdp_connector::connection_activation::ConnectionActivationState; +use ironrdp_connector::{ConnectionResult, ConnectorResult}; use ironrdp_core::WriteBuf; -use ironrdp_dvc_pipe_proxy::DvcNamedPipeProxy; -use ironrdp_rdpsnd_native::cpal; +use ironrdp_displaycontrol::client::DisplayControlClient; +use ironrdp_displaycontrol::pdu::MonitorLayoutEntry; +#[cfg(all(windows, feature = "dvc-com-plugin"))] +use ironrdp_dvc::DvcProcessor as _; +use ironrdp_echo::client::EchoClient; +use ironrdp_graphics::image_processing::PixelFormat; +use ironrdp_graphics::pointer::DecodedPointer; +use ironrdp_pdu::input::MousePdu; +use ironrdp_pdu::input::fast_path::FastPathInputEvent; +use ironrdp_pdu::input::mouse::PointerFlags; +#[cfg(any(feature = "dvc-pipe-proxy", all(windows, feature = "dvc-com-plugin")))] +use ironrdp_pdu::pdu_other_err; +use ironrdp_session::image::DecodedImage; +use ironrdp_session::{ActiveStageBuilder, ActiveStageOutput, GracefulDisconnectReason, SessionResult, fast_path}; +use ironrdp_svc::SvcMessage; use ironrdp_tokio::reqwest::ReqwestNetworkClient; -use ironrdp_tokio::{single_sequence_step_read, split_tokio_framed, FramedWrite}; -use rdpdr::NoopRdpdrBackend; +use ironrdp_tokio::{FramedWrite, single_sequence_step_read, split_tokio_framed}; use smallvec::SmallVec; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::TcpStream; use tokio::sync::mpsc; -use tracing::{debug, error, info, trace, warn}; -use winit::event_loop::EventLoopProxy; +#[cfg(any(feature = "clipboard", all(windows, feature = "dvc-com-plugin")))] +use tracing::error; +#[cfg(feature = "clipboard")] +use tracing::warn; +use tracing::{debug, info, trace}; + +#[cfg(feature = "clipboard")] +use crate::config::ClipboardType; +#[cfg(feature = "clipboard")] +use ironrdp_cliprdr::backend::{ClipboardMessage, CliprdrBackendFactory}; +#[cfg(all(windows, feature = "dvc-com-plugin"))] +use ironrdp_dvc_com_plugin::load_dvc_plugin; +#[cfg(feature = "dvc-pipe-proxy")] +use ironrdp_dvc_pipe_proxy::DvcNamedPipeProxy; +#[cfg(feature = "sound")] +use ironrdp_rdpsnd_native::cpal; -use crate::config::{Config, RDCleanPathConfig}; +use crate::config::{Config, RDCleanPathConfig, Transport}; + +// ── Public event types ──────────────────────────────────────────────────────── #[derive(Debug)] pub enum RdpOutputEvent { - Image { buffer: Vec, width: u16, height: u16 }, - ConnectionFailure(connector::ConnectorError), + Image { + buffer: Vec, + width: NonZeroU16, + height: NonZeroU16, + }, + ConnectionFailure(ironrdp_connector::ConnectorError), PointerDefault, PointerHidden, - PointerPosition { x: u16, y: u16 }, + PointerPosition { + x: u16, + y: u16, + }, PointerBitmap(Arc), Terminated(SessionResult), } @@ -45,11 +72,12 @@ pub enum RdpInputEvent { width: u16, height: u16, scale_factor: u32, - /// The physical size of the display in millimeters (width, height). + /// Physical display size in millimetres (width, height). physical_size: Option<(u32, u32)>, }, FastPath(SmallVec<[FastPathInputEvent; 2]>), Close, + #[cfg(feature = "clipboard")] Clipboard(ClipboardMessage), SendDvcMessages { channel_id: u32, @@ -57,74 +85,149 @@ pub enum RdpInputEvent { }, } -impl RdpInputEvent { - pub fn create_channel() -> (mpsc::UnboundedSender, mpsc::UnboundedReceiver) { - mpsc::unbounded_channel() - } -} +// ── RdpClient ───────────────────────────────────────────────────────────────── -pub struct DvcPipeProxyFactory { - rdp_input_sender: mpsc::UnboundedSender, +pub struct RdpClient { + config: Config, + output_event_sender: mpsc::Sender, + input_event_sender: mpsc::UnboundedSender, + input_event_receiver: mpsc::UnboundedReceiver, } -impl DvcPipeProxyFactory { - pub fn new(rdp_input_sender: mpsc::UnboundedSender) -> Self { - Self { rdp_input_sender } +impl RdpClient { + pub fn new(config: Config, output_event_sender: mpsc::Sender) -> Self { + let (input_event_sender, input_event_receiver) = mpsc::unbounded_channel(); + Self { + config, + output_event_sender, + input_event_sender, + input_event_receiver, + } } - pub fn create(&self, channel_name: String, pipe_name: String) -> DvcNamedPipeProxy { - let rdp_input_sender = self.rdp_input_sender.clone(); - - DvcNamedPipeProxy::new(&channel_name, &pipe_name, move |channel_id, messages| { - rdp_input_sender - .send(RdpInputEvent::SendDvcMessages { channel_id, messages }) - .map_err(|_error| pdu_other_err!("send DVC messages to the event loop",))?; - - Ok(()) - }) + /// Return a clone of the input-event sender for injecting keyboard, mouse, and clipboard + /// events from the GUI thread. + pub fn input_sender(&self) -> mpsc::UnboundedSender { + self.input_event_sender.clone() } -} -pub type WriteDvcMessageFn = Box PduResult<()> + Send + 'static>; + pub async fn run(mut self) { + // ── Clipboard initialisation (compile-time gated) ───────────────────── + // + // On Windows the WinClipboard object must outlive the entire connection loop, so we + // keep it alive via `_win_clipboard`. On non-Windows a StubClipboard backend is used + // and its ownership can be released immediately after the factory is extracted. + #[cfg(all(windows, feature = "clipboard"))] + #[expect( + clippy::collection_is_never_read, + reason = "binding owns the Windows clipboard so it stays alive for the connection's lifetime" + )] + let _win_clipboard; + + #[cfg(feature = "clipboard")] + let cliprdr_factory: Option>; + + #[cfg(feature = "clipboard")] + { + match self.config.channels.clipboard { + ClipboardType::Disable => { + cliprdr_factory = None; + #[cfg(windows)] + { + _win_clipboard = None; + } + } + ClipboardType::Stub => { + use ironrdp_cliprdr_native::StubClipboard; + let stub = StubClipboard::new(); + cliprdr_factory = Some(stub.backend_factory()); + #[cfg(windows)] + { + _win_clipboard = None; + } + } + ClipboardType::Enable => { + #[cfg(windows)] + { + use crate::clipboard::ClientClipboardMessageProxy; + use ironrdp_cliprdr_native::WinClipboard; + match WinClipboard::new(ClientClipboardMessageProxy::new(self.input_event_sender.clone())) { + Ok(win_cb) => { + cliprdr_factory = Some(win_cb.backend_factory()); + _win_clipboard = Some(win_cb); + } + Err(e) => { + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(ironrdp_connector::custom_err!( + "Windows clipboard initialization", + e + ))) + .await; + return; + } + } + } -pub struct RdpClient { - pub config: Config, - pub event_loop_proxy: EventLoopProxy, - pub input_event_receiver: mpsc::UnboundedReceiver, - pub cliprdr_factory: Option>, - pub dvc_pipe_proxy_factory: DvcPipeProxyFactory, -} + #[cfg(not(windows))] + { + use ironrdp_cliprdr_native::StubClipboard; + let stub = StubClipboard::new(); + cliprdr_factory = Some(stub.backend_factory()); + } + } + } + } -impl RdpClient { - pub async fn run(mut self) { + // Resolve the per-connection cliprdr factory reference once. `Option<&dyn …>` is `Copy`, + // so it can be threaded into every connect attempt across reconnects. + #[cfg(feature = "clipboard")] + let cliprdr_factory: CliprdrFactoryRef<'_> = cliprdr_factory.as_deref(); + #[cfg(not(feature = "clipboard"))] + let cliprdr_factory: CliprdrFactoryRef<'_> = core::marker::PhantomData; + + // ── Connection + session loop ───────────────────────────────────────── loop { - let (connection_result, framed) = if let Some(rdcleanpath) = self.config.rdcleanpath.as_ref() { - match connect_ws( - &self.config, - rdcleanpath, - self.cliprdr_factory.as_deref(), - &self.dvc_pipe_proxy_factory, - ) - .await - { - Ok(result) => result, - Err(e) => { - let _ = self.event_loop_proxy.send_event(RdpOutputEvent::ConnectionFailure(e)); - break; + let (connection_result, framed) = match &self.config.transport { + Transport::Direct => { + match connect_direct(&self.config, &self.input_event_sender, cliprdr_factory).await { + Ok(r) => r, + Err(e) => { + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(e)) + .await; + break; + } } } - } else { - match connect( - &self.config, - self.cliprdr_factory.as_deref(), - &self.dvc_pipe_proxy_factory, - ) - .await - { - Ok(result) => result, - Err(e) => { - let _ = self.event_loop_proxy.send_event(RdpOutputEvent::ConnectionFailure(e)); - break; + + #[cfg(feature = "gateway")] + Transport::Gateway(gw) => { + match connect_gateway(&self.config, gw, &self.input_event_sender, cliprdr_factory).await { + Ok(r) => r, + Err(e) => { + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(e)) + .await; + break; + } + } + } + + Transport::RDCleanPath(rdcp) => { + match connect_rdcleanpath_transport(&self.config, rdcp, &self.input_event_sender, cliprdr_factory) + .await + { + Ok(r) => r, + Err(e) => { + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(e)) + .await; + break; + } } } }; @@ -132,8 +235,9 @@ impl RdpClient { match active_session( framed, connection_result, - &self.event_loop_proxy, + &self.output_event_sender, &mut self.input_event_receiver, + self.config.fake_events_interval, ) .await { @@ -142,11 +246,14 @@ impl RdpClient { self.config.connector.desktop_size.height = height; } Ok(RdpControlFlow::TerminatedGracefully(reason)) => { - let _ = self.event_loop_proxy.send_event(RdpOutputEvent::Terminated(Ok(reason))); + let _ = self + .output_event_sender + .send(RdpOutputEvent::Terminated(Ok(reason))) + .await; break; } Err(e) => { - let _ = self.event_loop_proxy.send_event(RdpOutputEvent::Terminated(Err(e))); + let _ = self.output_event_sender.send(RdpOutputEvent::Terminated(Err(e))).await; break; } } @@ -154,189 +261,323 @@ impl RdpClient { } } -enum RdpControlFlow { - ReconnectWithNewSize { width: u16, height: u16 }, - TerminatedGracefully(GracefulDisconnectReason), -} - -trait AsyncReadWrite: AsyncRead + AsyncWrite {} - -impl AsyncReadWrite for T where T: AsyncRead + AsyncWrite {} - -type UpgradedFramed = ironrdp_tokio::TokioFramed>; - -async fn connect( +// ── Connector builder ───────────────────────────────────────────────────────── + +/// Reference to the cliprdr backend factory threaded into the connect helpers. +/// +/// Collapses to a zero-sized placeholder when the `clipboard` feature is disabled, so the +/// connect-helper signatures don't need `#[cfg]` on this parameter. +#[cfg(feature = "clipboard")] +type CliprdrFactoryRef<'a> = Option<&'a (dyn CliprdrBackendFactory + Send)>; +#[cfg(not(feature = "clipboard"))] +type CliprdrFactoryRef<'a> = core::marker::PhantomData<&'a ()>; + +/// Build a fully wired [`ironrdp_connector::ClientConnector`] with all feature-gated channels attached. +/// +/// This helper is used by all transport paths. The cliprdr backend is (re)built here, per +/// connection, from `cliprdr_factory`. +fn build_connector( config: &Config, - cliprdr_factory: Option<&(dyn CliprdrBackendFactory + Send)>, - dvc_pipe_proxy_factory: &DvcPipeProxyFactory, -) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> { - let dest = format!("{}:{}", config.destination.name(), config.destination.port()); + client_addr: SocketAddr, + input_sender: &mpsc::UnboundedSender, + cliprdr_factory: CliprdrFactoryRef<'_>, +) -> ironrdp_connector::ClientConnector { + // `input_sender` is only consumed by the optional DVC wirings below, and `cliprdr_factory` + // only by the optional CLIPRDR attachment; discard them explicitly when those are compiled out. + #[cfg(not(any(feature = "dvc-pipe-proxy", all(windows, feature = "dvc-com-plugin"))))] + let _ = input_sender; + #[cfg(not(feature = "clipboard"))] + let _ = cliprdr_factory; + + let mut drdynvc = ironrdp_dvc::DrdynvcClient::new() + .with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new()))) + .with_dynamic_channel(EchoClient::new()); + + // Attach DVC pipe proxies. + #[cfg(feature = "dvc-pipe-proxy")] + for proxy in &config.dvc_pipe_proxies { + let channel_name = proxy.channel_name.clone(); + let pipe_name = proxy.pipe_name.clone(); + trace!(%channel_name, %pipe_name, "Creating DVC pipe proxy"); + let sender = input_sender.clone(); + drdynvc = drdynvc.with_dynamic_channel(DvcNamedPipeProxy::new( + &channel_name, + &pipe_name, + move |channel_id, messages| { + sender + .send(RdpInputEvent::SendDvcMessages { channel_id, messages }) + .map_err(|_| pdu_other_err!("send DVC messages to the event loop"))?; + Ok(()) + }, + )); + } - let (client_addr, stream) = if let Some(ref gw_config) = config.gw { - let (gw, client_addr) = ironrdp_mstsgu::GwClient::connect(gw_config, &config.connector.client_name) - .await - .map_err(|e| connector::custom_err!("GW Connect", e))?; - (client_addr, tokio_util::either::Either::Left(gw)) - } else { - let stream = TcpStream::connect(dest) - .await - .map_err(|e| connector::custom_err!("TCP connect", e))?; - let client_addr = stream - .local_addr() - .map_err(|e| connector::custom_err!("get socket local address", e))?; - (client_addr, tokio_util::either::Either::Right(stream)) - }; - let mut framed = ironrdp_tokio::TokioFramed::new(stream); + // Load DVC COM plugins (Windows + dvc-com-plugin feature). + #[cfg(all(windows, feature = "dvc-com-plugin"))] + { + for plugin_path in &config.dvc_plugins { + info!(dll = %plugin_path.display(), "Loading DVC COM plugin"); + let sender_clone = input_sender.clone(); + match load_dvc_plugin(plugin_path, move || { + let sender = sender_clone.clone(); + Box::new(move |channel_id, messages| { + sender + .send(RdpInputEvent::SendDvcMessages { channel_id, messages }) + .map_err(|_| pdu_other_err!("send COM DVC messages to the event loop"))?; + Ok(()) + }) + }) { + Ok(channels) => { + for channel in channels { + info!(channel_name = %channel.channel_name(), "Registered COM DVC channel"); + drdynvc = drdynvc.with_dynamic_channel(channel); + } + } + Err(e) => { + error!(dll = %plugin_path.display(), error = %e, "Failed to load DVC COM plugin"); + } + } + } + } - let mut drdynvc = - ironrdp::dvc::DrdynvcClient::new().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new()))); + // Attach user-defined DVC channels from the extension registry. + for attach_dvc in &config.extensions.dvc_channels { + attach_dvc(&mut drdynvc, &config.properties); + } - // Instantiate all DVC proxies - for proxy in config.dvc_pipe_proxies.iter() { - let channel_name = proxy.channel_name.clone(); - let pipe_name = proxy.pipe_name.clone(); + // Clone the connector config so we can apply runtime overrides before handing it to the + // connector. We want to set `enable_audio_playback` consistently with `channels.sound`. + let mut connector_config = config.connector.clone(); - trace!(%channel_name, %pipe_name, "Creating DVC proxy"); + // If sound is disabled at runtime (or the feature is off) ensure the connector doesn't + // advertise audio support, which would confuse the server. + #[cfg(not(feature = "sound"))] + { + connector_config.enable_audio_playback = false; + } + #[cfg(feature = "sound")] + if !config.channels.sound { + connector_config.enable_audio_playback = false; + } - drdynvc = drdynvc.with_dynamic_channel(dvc_pipe_proxy_factory.create(channel_name, pipe_name)); + // Honor the runtime QOI/QOIZ codec toggles. Both codecs are compiled in and advertised by + // default, but can be disabled at runtime; when disabled we drop them from the advertised + // bitmap codec list so the server won't negotiate them. + #[cfg(any(feature = "qoi", feature = "qoiz"))] + if let Some(bitmap) = connector_config.bitmap.as_mut() { + use ironrdp_pdu::rdp::capability_sets::CodecProperty; + + bitmap.codecs.0.retain(|codec| match codec.property { + #[cfg(feature = "qoi")] + CodecProperty::Qoi => config.channels.qoi, + #[cfg(feature = "qoiz")] + CodecProperty::QoiZ => config.channels.qoiz, + _ => true, + }); } - let mut connector = connector::ClientConnector::new(config.connector.clone(), client_addr) - .with_static_channel(drdynvc) - .with_static_channel(rdpsnd::client::Rdpsnd::new(Box::new(cpal::RdpsndBackend::new()))) - .with_static_channel(rdpdr::Rdpdr::new(Box::new(NoopRdpdrBackend {}), "IronRDP".to_owned()).with_smartcard(0)); + let mut connector = + ironrdp_connector::ClientConnector::new(connector_config, client_addr).with_static_channel(drdynvc); - if let Some(builder) = cliprdr_factory { - let backend = builder.build_cliprdr_backend(); + // Attach RDPSND (audio). + #[cfg(feature = "sound")] + if config.channels.sound { + connector = connector.with_static_channel(ironrdp_rdpsnd::client::Rdpsnd::new(Box::new( + cpal::RdpsndBackend::new(), + ))); + } - let cliprdr = cliprdr::Cliprdr::new(backend); + // Attach RDPDR (device redirection). + #[cfg(feature = "rdpdr")] + if config.channels.rdpdr.enabled { + #[cfg_attr( + not(feature = "smartcard"), + expect( + unused_mut, + reason = "rdpdr_channel is only reassigned when the smartcard feature is enabled" + ) + )] + let mut rdpdr_channel = + ironrdp_rdpdr::Rdpdr::new(Box::new(ironrdp_rdpdr::NoopRdpdrBackend), "IronRDP".to_owned()); + #[cfg(feature = "smartcard")] + if config.channels.rdpdr.smartcard { + rdpdr_channel = rdpdr_channel.with_smartcard(0); + } + connector = connector.with_static_channel(rdpdr_channel); + } - connector.attach_static_channel(cliprdr); + // Attach CLIPRDR (clipboard redirection). The backend is built fresh per connection. + #[cfg(feature = "clipboard")] + if let Some(factory) = cliprdr_factory { + let backend = factory.build_cliprdr_backend(); + connector.attach_static_channel(ironrdp_cliprdr::Cliprdr::new(backend)); } - let should_upgrade = ironrdp_tokio::connect_begin(&mut framed, &mut connector).await?; + // Attach user-defined static channels from the extension registry. + for attach_sc in &config.extensions.static_channels { + attach_sc(&mut connector, &config.properties); + } - debug!("TLS upgrade"); + connector +} - // Ensure there is no leftover - let (initial_stream, leftover_bytes) = framed.into_inner(); +// ── Transport-specific connect helpers ──────────────────────────────────────── - let (upgraded_stream, server_public_key) = ironrdp_tls::upgrade(initial_stream, config.destination.name()) +trait AsyncReadWrite: AsyncRead + AsyncWrite {} +impl AsyncReadWrite for T where T: AsyncRead + AsyncWrite {} +type UpgradedFramed = ironrdp_tokio::TokioFramed>; + +/// Direct TCP → TLS connection (no gateway). +async fn connect_direct( + config: &Config, + input_sender: &mpsc::UnboundedSender, + cliprdr_factory: CliprdrFactoryRef<'_>, +) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> { + let dest = config.destination.to_string(); + let stream = TcpStream::connect(&dest) .await - .map_err(|e| connector::custom_err!("TLS upgrade", e))?; + .map_err(|e| ironrdp_connector::custom_err!("TCP connect", e))?; + let client_addr = stream + .local_addr() + .map_err(|e| ironrdp_connector::custom_err!("get socket local address", e))?; + let framed = ironrdp_tokio::TokioFramed::new(stream); - let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector); + let connector = build_connector(config, client_addr, input_sender, cliprdr_factory); - let erased_stream = Box::new(upgraded_stream) as Box; - let mut upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); + tls_handshake_and_finalize(framed, connector, config).await +} - let connection_result = ironrdp_tokio::connect_finalize( - upgraded, - &mut upgraded_framed, - connector, - (&config.destination).into(), - server_public_key, - Some(&mut ReqwestNetworkClient::new()), - None, - ) - .await?; +/// RDS gateway TCP → gateway auth → TLS connection. +#[cfg(feature = "gateway")] +async fn connect_gateway( + config: &Config, + gw: &crate::config::GatewayConfig, + input_sender: &mpsc::UnboundedSender, + cliprdr_factory: CliprdrFactoryRef<'_>, +) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> { + use ironrdp_mstsgu::GwConnectTarget; + + // Build the GwConnectTarget. `server` is the RDP target derived from `config.destination`. + // TODO: preserve the destination port; ironrdp-mstsgu may currently hard-code 3389. + let gw_target = GwConnectTarget { + gw_endpoint: gw.endpoint.clone(), + gw_user: gw.username.clone(), + gw_pass: gw.password.clone(), + server: config.destination.name().to_owned(), + }; - debug!(?connection_result); + let (gw_stream, client_addr) = ironrdp_mstsgu::GwClient::connect(&gw_target, &config.connector.client_name) + .await + .map_err(|e| ironrdp_connector::custom_err!("GW connect", e))?; - Ok((connection_result, upgraded_framed)) + let framed = ironrdp_tokio::TokioFramed::new(gw_stream); + + let connector = build_connector(config, client_addr, input_sender, cliprdr_factory); + + tls_handshake_and_finalize(framed, connector, config).await } -async fn connect_ws( +/// RDCleanPath WebSocket → RDCleanPath handshake connection. +async fn connect_rdcleanpath_transport( config: &Config, - rdcleanpath: &RDCleanPathConfig, - cliprdr_factory: Option<&(dyn CliprdrBackendFactory + Send)>, - dvc_pipe_proxy_factory: &DvcPipeProxyFactory, + rdcp: &RDCleanPathConfig, + input_sender: &mpsc::UnboundedSender, + cliprdr_factory: CliprdrFactoryRef<'_>, ) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> { - let hostname = rdcleanpath + let hostname = rdcp .url .host_str() - .ok_or_else(|| connector::general_err!("host missing from the URL"))?; - - let port = rdcleanpath.url.port_or_known_default().unwrap_or(443); + .ok_or_else(|| ironrdp_connector::general_err!("host missing from the URL"))?; + let port = rdcp.url.port_or_known_default().unwrap_or(443); let socket = TcpStream::connect((hostname, port)) .await - .map_err(|e| connector::custom_err!("TCP connect", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("TCP connect", e))?; socket .set_nodelay(true) - .map_err(|e| connector::custom_err!("set TCP_NODELAY", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("set TCP_NODELAY", e))?; let client_addr = socket .local_addr() - .map_err(|e| connector::custom_err!("get socket local address", e))?; + .map_err(|e| ironrdp_connector::custom_err!("get socket local address", e))?; - let (ws, _) = tokio_tungstenite::client_async_tls(rdcleanpath.url.as_str(), socket) + let (ws, _) = tokio_tungstenite::client_async_tls(rdcp.url.as_str(), socket) .await - .map_err(|e| connector::custom_err!("WS connect", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("WS connect", e))?; let ws = crate::ws::websocket_compat(ws); - let mut framed = ironrdp_tokio::TokioFramed::new(ws); - let mut drdynvc = - ironrdp::dvc::DrdynvcClient::new().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new()))); + let mut connector = build_connector(config, client_addr, input_sender, cliprdr_factory); - // Instantiate all DVC proxies - for proxy in config.dvc_pipe_proxies.iter() { - let channel_name = proxy.channel_name.clone(); - let pipe_name = proxy.pipe_name.clone(); + let destination = config.destination.to_string(); + let (upgraded, server_public_key) = + rdcleanpath_handshake(&mut framed, &mut connector, destination, rdcp.auth_token.clone(), None).await?; - trace!(%channel_name, %pipe_name, "Creating DVC proxy"); + let connection_result = ironrdp_tokio::connect_finalize( + upgraded, + connector, + &mut framed, + &mut ReqwestNetworkClient::new(), + (&config.destination).into(), + server_public_key, + config.kerberos_config.clone(), + ) + .await?; - drdynvc = drdynvc.with_dynamic_channel(dvc_pipe_proxy_factory.create(channel_name, pipe_name)); - } + let (ws, leftover_bytes) = framed.into_inner(); + let erased_stream: Box = Box::new(ws); + let upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); - let mut connector = connector::ClientConnector::new(config.connector.clone(), client_addr) - .with_static_channel(drdynvc) - .with_static_channel(rdpsnd::client::Rdpsnd::new(Box::new(cpal::RdpsndBackend::new()))) - .with_static_channel(rdpdr::Rdpdr::new(Box::new(NoopRdpdrBackend {}), "IronRDP".to_owned()).with_smartcard(0)); + Ok((connection_result, upgraded_framed)) +} - if let Some(builder) = cliprdr_factory { - let backend = builder.build_cliprdr_backend(); +// ── Shared TLS handshake ────────────────────────────────────────────────────── - let cliprdr = cliprdr::Cliprdr::new(backend); +async fn tls_handshake_and_finalize( + mut framed: ironrdp_tokio::TokioFramed, + mut connector: ironrdp_connector::ClientConnector, + config: &Config, +) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> +where + S: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static, +{ + let should_upgrade = ironrdp_tokio::connect_begin(&mut framed, &mut connector).await?; - connector.attach_static_channel(cliprdr); - } + debug!("TLS upgrade"); - let destination = format!("{}:{}", config.destination.name(), config.destination.port()); + let (initial_stream, leftover_bytes) = framed.into_inner(); - let (upgraded, server_public_key) = connect_rdcleanpath( - &mut framed, - &mut connector, - destination, - rdcleanpath.auth_token.clone(), - None, - ) - .await?; + let (tls_stream, tls_cert) = ironrdp_tls::upgrade(initial_stream, config.destination.name()) + .await + .map_err(|e| ironrdp_connector::custom_err!("TLS upgrade", e))?; + + let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector); + + let erased_stream: Box = Box::new(tls_stream); + let mut upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); + + let server_public_key = ironrdp_tls::extract_tls_server_public_key(&tls_cert) + .ok_or_else(|| ironrdp_connector::general_err!("unable to extract tls server public key"))? + .to_owned(); let connection_result = ironrdp_tokio::connect_finalize( upgraded, - &mut framed, connector, + &mut upgraded_framed, + &mut ReqwestNetworkClient::new(), (&config.destination).into(), server_public_key, - Some(&mut ReqwestNetworkClient::new()), - None, + config.kerberos_config.clone(), ) .await?; - let (ws, leftover_bytes) = framed.into_inner(); - let erased_stream = Box::new(ws) as Box; - let upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); - Ok((connection_result, upgraded_framed)) } -async fn connect_rdcleanpath( +// ── RDCleanPath handshake ───────────────────────────────────────────────────── + +async fn rdcleanpath_handshake( framed: &mut ironrdp_tokio::Framed, - connector: &mut connector::ClientConnector, + connector: &mut ironrdp_connector::ClientConnector, destination: String, proxy_auth_token: String, pcb: Option, @@ -344,40 +585,36 @@ async fn connect_rdcleanpath( where S: ironrdp_tokio::FramedRead + FramedWrite, { - use ironrdp::connector::Sequence as _; + use ironrdp_connector::Sequence as _; use x509_cert::der::Decode as _; #[derive(Clone, Copy, Debug)] struct RDCleanPathHint; - const RDCLEANPATH_HINT: RDCleanPathHint = RDCleanPathHint; - impl ironrdp::pdu::PduHint for RDCleanPathHint { - fn find_size(&self, bytes: &[u8]) -> ironrdp::core::DecodeResult> { + impl ironrdp_pdu::PduHint for RDCleanPathHint { + fn find_size(&self, bytes: &[u8]) -> ironrdp_core::DecodeResult> { match ironrdp_rdcleanpath::RDCleanPathPdu::detect(bytes) { ironrdp_rdcleanpath::DetectionResult::Detected { total_length, .. } => Ok(Some((true, total_length))), ironrdp_rdcleanpath::DetectionResult::NotEnoughBytes => Ok(None), - ironrdp_rdcleanpath::DetectionResult::Failed => Err(ironrdp::core::other_err!( - "RDCleanPathHint", - "detection failed (invalid PDU)" - )), + ironrdp_rdcleanpath::DetectionResult::Failed => { + Err(ironrdp_core::other_err!("RDCleanPathHint", "detection failed")) + } } } } let mut buf = WriteBuf::new(); + info!("Begin RDCleanPath connection procedure"); - info!("Begin connection procedure"); - + // Send X224 + RDCleanPath request. { - // RDCleanPath request - - let connector::ClientConnectorState::ConnectionInitiationSendRequest = connector.state else { - return Err(connector::general_err!("invalid connector state (send request)")); + let ironrdp_connector::ClientConnectorState::ConnectionInitiationSendRequest = connector.state else { + return Err(ironrdp_connector::general_err!( + "invalid connector state (send request)" + )); }; - debug_assert!(connector.next_pdu_hint().is_none()); - let written = connector.step_no_input(&mut buf)?; let x224_pdu_len = written.size().expect("written size"); debug_assert_eq!(x224_pdu_len, buf.filled_len()); @@ -385,38 +622,34 @@ where let rdcleanpath_req = ironrdp_rdcleanpath::RDCleanPathPdu::new_request(x224_pdu, destination, proxy_auth_token, pcb) - .map_err(|e| connector::custom_err!("new RDCleanPath request", e))?; + .map_err(|e| ironrdp_connector::custom_err!("new RDCleanPath request", e))?; debug!(message = ?rdcleanpath_req, "Send RDCleanPath request"); let rdcleanpath_req = rdcleanpath_req .to_der() - .map_err(|e| connector::custom_err!("RDCleanPath request encode", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("RDCleanPath request encode", e))?; framed .write_all(&rdcleanpath_req) .await - .map_err(|e| connector::custom_err!("couldn't write RDCleanPath request", e))?; + .map_err(|e| ironrdp_connector::custom_err!("couldn't write RDCleanPath request", e))?; } + // Read RDCleanPath response. { - // RDCleanPath response - let rdcleanpath_res = framed .read_by_hint(&RDCLEANPATH_HINT) .await - .map_err(|e| connector::custom_err!("read RDCleanPath request", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("read RDCleanPath response", e))?; let rdcleanpath_res = ironrdp_rdcleanpath::RDCleanPathPdu::from_der(&rdcleanpath_res) - .map_err(|e| connector::custom_err!("RDCleanPath response decode", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("RDCleanPath response decode", e))?; debug!(message = ?rdcleanpath_res, "Received RDCleanPath PDU"); let (x224_connection_response, server_cert_chain) = match rdcleanpath_res .into_enum() - .map_err(|e| connector::custom_err!("invalid RDCleanPath PDU", e))? + .map_err(|e| ironrdp_connector::custom_err!("invalid RDCleanPath PDU", e))? { ironrdp_rdcleanpath::RDCleanPath::Request { .. } => { - return Err(connector::general_err!( - "received an unexpected RDCleanPath type (request)", + return Err(ironrdp_connector::general_err!( + "received unexpected RDCleanPath type (request)" )); } ironrdp_rdcleanpath::RDCleanPath::Response { @@ -425,128 +658,179 @@ where server_addr: _, } => (x224_connection_response, server_cert_chain), ironrdp_rdcleanpath::RDCleanPath::GeneralErr(error) => { - return Err(connector::custom_err!("received an RDCleanPath error", error)); + return Err(ironrdp_connector::custom_err!("received RDCleanPath error", error)); } ironrdp_rdcleanpath::RDCleanPath::NegotiationErr { x224_connection_response, } => { - // Try to decode as X.224 Connection Confirm to extract negotiation failure details. if let Ok(x224_confirm) = ironrdp_core::decode::< - ironrdp::pdu::x224::X224, + ironrdp_pdu::x224::X224, >(&x224_connection_response) { - if let ironrdp::pdu::nego::ConnectionConfirm::Failure { code } = x224_confirm.0 { - // Convert to negotiation failure instead of generic RDCleanPath error. - let negotiation_failure = connector::NegotiationFailure::from(code); - return Err(connector::ConnectorError::new( + if let ironrdp_pdu::nego::ConnectionConfirm::Failure { code } = x224_confirm.0 { + let negotiation_failure = ironrdp_connector::NegotiationFailure::from(code); + return Err(ironrdp_connector::ConnectorError::new( "RDP negotiation failed", - connector::ConnectorErrorKind::Negotiation(negotiation_failure), + ironrdp_connector::ConnectorErrorKind::Negotiation(negotiation_failure), )); } } - - // Fallback to generic error if we can't decode the negotiation failure. - return Err(connector::general_err!("received an RDCleanPath negotiation error")); + return Err(ironrdp_connector::general_err!( + "received RDCleanPath negotiation error" + )); } }; - let connector::ClientConnectorState::ConnectionInitiationWaitConfirm { .. } = connector.state else { - return Err(connector::general_err!("invalid connector state (wait confirm)")); + let ironrdp_connector::ClientConnectorState::ConnectionInitiationWaitConfirm { .. } = connector.state else { + return Err(ironrdp_connector::general_err!( + "invalid connector state (wait confirm)" + )); }; - debug_assert!(connector.next_pdu_hint().is_some()); buf.clear(); let written = connector.step(x224_connection_response.as_bytes(), &mut buf)?; - debug_assert!(written.is_nothing()); let server_cert = server_cert_chain .into_iter() .next() - .ok_or_else(|| connector::general_err!("server cert chain missing from rdcleanpath response"))?; + .ok_or_else(|| ironrdp_connector::general_err!("server cert chain missing from rdcleanpath response"))?; let cert = x509_cert::Certificate::from_der(server_cert.as_bytes()) - .map_err(|e| connector::custom_err!("server cert chain missing from rdcleanpath response", e))?; + .map_err(|e| ironrdp_connector::custom_err!("server cert decode", e))?; let server_public_key = cert .tbs_certificate .subject_public_key_info .subject_public_key .as_bytes() - .ok_or_else(|| connector::general_err!("subject public key BIT STRING is not aligned"))? + .ok_or_else(|| ironrdp_connector::general_err!("subject public key BIT STRING is not aligned"))? .to_owned(); let should_upgrade = ironrdp_tokio::skip_connect_begin(connector); - - // At this point, proxy established the TLS session. - let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, connector); Ok((upgraded, server_public_key)) } } +// ── Active session ──────────────────────────────────────────────────────────── + +enum RdpControlFlow { + ReconnectWithNewSize { width: u16, height: u16 }, + TerminatedGracefully(GracefulDisconnectReason), +} + async fn active_session( framed: UpgradedFramed, connection_result: ConnectionResult, - event_loop_proxy: &EventLoopProxy, + output_event_sender: &mpsc::Sender, input_event_receiver: &mut mpsc::UnboundedReceiver, + fake_events_interval: Option, ) -> SessionResult { let (mut reader, mut writer) = split_tokio_framed(framed); - let mut image = DecodedImage::new( - PixelFormat::RgbA32, - connection_result.desktop_size.width, - connection_result.desktop_size.height, - ); + let desktop_size = connection_result.desktop_size; + let mut image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height); + + // We retain the factory to drive the Deactivation-Reactivation Sequence locally. + let activation_factory = connection_result.activation_factory; + + let mut active_stage = ActiveStageBuilder { + static_channels: connection_result.static_channels, + user_channel_id: connection_result.user_channel_id, + io_channel_id: connection_result.io_channel_id, + message_channel_id: connection_result.message_channel_id, + share_id: connection_result.share_id, + compression_type: connection_result.compression_type, + enable_server_pointer: connection_result.enable_server_pointer, + pointer_software_rendering: connection_result.pointer_software_rendering, + } + .build(); + + // Timer interval for driving clipboard lock timeouts. + let mut cleanup_interval = tokio::time::interval(Duration::from_secs(5)); - let mut active_stage = ActiveStage::new(connection_result); + // Anti-idle: track the time of the last real input and the last known mouse position so we can + // synthesize a no-op mouse move when the session has been idle for too long. Default to the + // middle of the screen so a synthetic move before any real input doesn't snap the pointer to a + // corner. + let mut last_input = tokio::time::Instant::now(); + let mut last_mouse_pos = (desktop_size.width / 2, desktop_size.height / 2); + let mut fake_events_interval = + fake_events_interval.map(|interval| tokio::time::interval(core::cmp::max(interval, Duration::from_secs(1)))); let disconnect_reason = 'outer: loop { let outputs = tokio::select! { frame = reader.read_pdu() => { - let (action, payload) = frame.map_err(|e| session::custom_err!("read frame", e))?; + let (action, payload) = frame.map_err(|e| ironrdp_session::custom_err!("read frame", e))?; trace!(?action, frame_length = payload.len(), "Frame received"); - active_stage.process(&mut image, action, &payload)? } input_event = input_event_receiver.recv() => { - let input_event = input_event.ok_or_else(|| session::general_err!("GUI is stopped"))?; + let input_event = input_event.ok_or_else(|| ironrdp_session::general_err!("GUI is stopped"))?; + + last_input = tokio::time::Instant::now(); match input_event { RdpInputEvent::Resize { width, height, scale_factor, physical_size } => { trace!(width, height, "Resize event"); - let (width, height) = MonitorLayoutEntry::adjust_display_size(width.into(), height.into()); + let width = u32::from(width); + let height = u32::from(height); + // TODO: Make adjust_display_size take and return width and height as u16. + // From the function's doc comment, the width and height values must be less than or equal to 8192 pixels. + // Therefore, we can remove unnecessary casts from u16 to u32 and back. + let (width, height) = MonitorLayoutEntry::adjust_display_size(width, height); debug!(width, height, "Adjusted display size"); if let Some(response_frame) = active_stage.encode_resize(width, height, Some(scale_factor), physical_size) { vec![ActiveStageOutput::ResponseFrame(response_frame?)] } else { // TODO(#271): use the "auto-reconnect cookie": https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/15b0d1c9-2891-4adb-a45e-deb4aeeeab7c debug!("Reconnecting with new size"); - return Ok(RdpControlFlow::ReconnectWithNewSize { width: width.try_into().unwrap(), height: height.try_into().unwrap() }) + let width = u16::try_from(width).expect("always in the range"); + let height = u16::try_from(height).expect("always in the range"); + return Ok(RdpControlFlow::ReconnectWithNewSize { width, height }) } - }, + } RdpInputEvent::FastPath(events) => { trace!(?events); + for event in &events { + if let FastPathInputEvent::MouseEvent(mouse) = event { + last_mouse_pos = (mouse.x_position, mouse.y_position); + } + } active_stage.process_fastpath_input(&mut image, &events)? } RdpInputEvent::Close => { active_stage.graceful_shutdown()? } + #[cfg(feature = "clipboard")] RdpInputEvent::Clipboard(event) => { - if let Some(cliprdr) = active_stage.get_svc_processor::() { + if let Some(cliprdr_client) = active_stage.get_svc_processor_mut::() { if let Some(svc_messages) = match event { ClipboardMessage::SendInitiateCopy(formats) => { - Some(cliprdr.initiate_copy(&formats) - .map_err(|e| session::custom_err!("CLIPRDR", e))?) + Some(cliprdr_client.initiate_copy(&formats) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) + } + ClipboardMessage::SendInitiateFileCopy(files) => { + Some(cliprdr_client.initiate_file_copy(files) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } ClipboardMessage::SendFormatData(response) => { - Some(cliprdr.submit_format_data(response) - .map_err(|e| session::custom_err!("CLIPRDR", e))?) + Some(cliprdr_client.submit_format_data(response) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } ClipboardMessage::SendInitiatePaste(format) => { - Some(cliprdr.initiate_paste(format) - .map_err(|e| session::custom_err!("CLIPRDR", e))?) + Some(cliprdr_client.initiate_paste(format) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) + } + ClipboardMessage::SendFileContentsRequest(request) => { + Some(cliprdr_client.request_file_contents(request) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) + } + ClipboardMessage::SendFileContentsResponse(response) => { + Some(cliprdr_client.submit_file_contents(response) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } ClipboardMessage::Error(e) => { error!("Clipboard backend error: {}", e); @@ -554,25 +838,66 @@ async fn active_session( } } { let frame = active_stage.process_svc_processor_messages(svc_messages)?; - // Send the messages to the server vec![ActiveStageOutput::ResponseFrame(frame)] } else { - // No messages to send to the server Vec::new() } - } else { + } else { warn!("Clipboard event received, but Cliprdr is not available"); Vec::new() } } RdpInputEvent::SendDvcMessages { channel_id, messages } => { trace!(channel_id, ?messages, "Send DVC messages"); - let frame = active_stage.encode_dvc_messages(messages)?; vec![ActiveStageOutput::ResponseFrame(frame)] } } } + _ = cleanup_interval.tick() => { + // Drive clipboard lock timeout cleanup. + #[cfg(feature = "clipboard")] + if let Some(cliprdr_client) = active_stage.get_svc_processor_mut::() { + match cliprdr_client.drive_timeouts() { + Ok(svc_messages) => { + let frame = active_stage.process_svc_processor_messages(svc_messages)?; + if !frame.is_empty() { + vec![ActiveStageOutput::ResponseFrame(frame)] + } else { + Vec::new() + } + } + Err(e) => { + warn!(error = %e, "Clipboard timeout cleanup failed"); + Vec::new() + } + } + } else { + Vec::new() + } + #[cfg(not(feature = "clipboard"))] + Vec::new() + } + _ = async { match fake_events_interval.as_mut() { + Some(interval) => interval.tick().await, + None => core::future::pending().await, + }} => { + // Anti-idle: synthesize a no-op mouse move if the session has been idle for at least + // the configured interval, keeping the connection alive without user interaction. + if last_input.elapsed() >= fake_events_interval.as_ref().map_or(Duration::MAX, |i| i.period()) { + last_input = tokio::time::Instant::now(); + let mut events = SmallVec::<[FastPathInputEvent; 2]>::new(); + events.push(FastPathInputEvent::MouseEvent(MousePdu { + flags: PointerFlags::MOVE, + number_of_wheel_rotation_units: 0, + x_position: last_mouse_pos.0, + y_position: last_mouse_pos.1, + })); + active_stage.process_fastpath_input(&mut image, &events)? + } else { + Vec::new() + } + } }; for out in outputs { @@ -580,7 +905,7 @@ async fn active_session( ActiveStageOutput::ResponseFrame(frame) => writer .write_all(&frame) .await - .map_err(|e| session::custom_err!("write response", e))?, + .map_err(|e| ironrdp_session::custom_err!("write response", e))?, ActiveStageOutput::GraphicsUpdate(_region) => { let buffer: Vec = image .data() @@ -592,77 +917,94 @@ async fn active_session( u32::from_be_bytes([0, r, g, b]) }) .collect(); - - event_loop_proxy - .send_event(RdpOutputEvent::Image { + output_event_sender + .send(RdpOutputEvent::Image { buffer, - width: image.width(), - height: image.height(), + width: NonZeroU16::new(image.width()) + .ok_or_else(|| ironrdp_session::general_err!("width is zero"))?, + height: NonZeroU16::new(image.height()) + .ok_or_else(|| ironrdp_session::general_err!("height is zero"))?, }) - .map_err(|e| session::custom_err!("event_loop_proxy", e))?; + .await + .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerDefault => { - event_loop_proxy - .send_event(RdpOutputEvent::PointerDefault) - .map_err(|e| session::custom_err!("event_loop_proxy", e))?; + output_event_sender + .send(RdpOutputEvent::PointerDefault) + .await + .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerHidden => { - event_loop_proxy - .send_event(RdpOutputEvent::PointerHidden) - .map_err(|e| session::custom_err!("event_loop_proxy", e))?; + output_event_sender + .send(RdpOutputEvent::PointerHidden) + .await + .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerPosition { x, y } => { - event_loop_proxy - .send_event(RdpOutputEvent::PointerPosition { x, y }) - .map_err(|e| session::custom_err!("event_loop_proxy", e))?; + output_event_sender + .send(RdpOutputEvent::PointerPosition { x, y }) + .await + .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerBitmap(pointer) => { - event_loop_proxy - .send_event(RdpOutputEvent::PointerBitmap(pointer)) - .map_err(|e| session::custom_err!("event_loop_proxy", e))?; + output_event_sender + .send(RdpOutputEvent::PointerBitmap(pointer)) + .await + .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } - ActiveStageOutput::DeactivateAll(mut connection_activation) => { - // Execute the Deactivation-Reactivation Sequence: + ActiveStageOutput::DeactivateAll => { + // Deactivation-Reactivation Sequence: // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 - debug!("Received Server Deactivate All PDU, executing Deactivation-Reactivation Sequence"); + debug!("Executing Deactivation-Reactivation Sequence"); + let mut connection_activation = activation_factory.create(); let mut buf = WriteBuf::new(); 'activation_seq: loop { - let written = single_sequence_step_read(&mut reader, &mut *connection_activation, &mut buf) + let written = single_sequence_step_read(&mut reader, &mut connection_activation, &mut buf) .await - .map_err(|e| session::custom_err!("read deactivation-reactivation sequence step", e))?; - + .map_err(|e| { + ironrdp_session::custom_err!("read deactivation-reactivation sequence step", e) + })?; if written.size().is_some() { writer.write_all(buf.filled()).await.map_err(|e| { - session::custom_err!("write deactivation-reactivation sequence step", e) + ironrdp_session::custom_err!("write deactivation-reactivation sequence step", e) })?; } - if let ConnectionActivationState::Finalized { - io_channel_id, - user_channel_id, desktop_size, + share_id, enable_server_pointer, pointer_software_rendering, - } = connection_activation.state + } = connection_activation.connection_activation_state() { debug!(?desktop_size, "Deactivation-Reactivation Sequence completed"); - // Update image size with the new desktop size. image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height); - // Update the active stage with the new channel IDs and pointer settings. active_stage.set_fastpath_processor( fast_path::ProcessorBuilder { - io_channel_id, - user_channel_id, + io_channel_id: connection_activation.io_channel_id(), + user_channel_id: connection_activation.user_channel_id(), + share_id, enable_server_pointer, pointer_software_rendering, + bulk_decompressor: None, } .build(), ); + active_stage.set_share_id(share_id); active_stage.set_enable_server_pointer(enable_server_pointer); break 'activation_seq; } } } + ActiveStageOutput::MultitransportRequest(pdu) => { + debug!( + request_id = pdu.request_id, + requested_protocol = ?pdu.requested_protocol, + "Multitransport request received (UDP transport not implemented)" + ); + } + ActiveStageOutput::AutoDetect(request) => { + debug!(?request, "Auto-detect"); + } ActiveStageOutput::Terminate(reason) => break 'outer reason, } } diff --git a/crates/ironrdp-client/src/ws.rs b/crates/ironrdp-client/src/ws.rs index 675553b9b1..a40df235c3 100644 --- a/crates/ironrdp-client/src/ws.rs +++ b/crates/ironrdp-client/src/ws.rs @@ -1,3 +1,7 @@ +use core::pin::Pin; +use core::task::{Context, Poll, ready}; +use std::io; + use futures_util::{Sink, SinkExt as _, Stream, StreamExt as _}; use tokio::io::{AsyncRead, AsyncWrite}; use tokio_tungstenite::tungstenite; @@ -14,10 +18,10 @@ where .filter_map(|item| { let mapped = item .map(|msg| match msg { - tungstenite::Message::Text(s) => Some(transport::WsReadMsg::Payload(tungstenite::Bytes::from(s))), - tungstenite::Message::Binary(data) => Some(transport::WsReadMsg::Payload(data)), + tungstenite::Message::Text(s) => Some(WsReadMsg::Payload(tungstenite::Bytes::from(s))), + tungstenite::Message::Binary(data) => Some(WsReadMsg::Payload(data)), tungstenite::Message::Ping(_) | tungstenite::Message::Pong(_) => None, - tungstenite::Message::Close(_) => Some(transport::WsReadMsg::Close), + tungstenite::Message::Close(_) => Some(WsReadMsg::Close), tungstenite::Message::Frame(_) => unreachable!("raw frames are never returned when reading"), }) .transpose(); @@ -30,5 +34,99 @@ where ))) }); - transport::WsStream::new(compat) + WsStream::new(compat) +} + +/// A WebSocket message as consumed by [`WsStream`] when reading. +enum WsReadMsg { + Payload(tungstenite::Bytes), + Close, +} + +/// Wraps a stream/sink of WebSocket messages and exposes it as [`AsyncRead`] + [`AsyncWrite`]. +/// +/// The wrapped `S` is required to be [`Unpin`] so no pinning projection is needed; the caller of +/// [`websocket_compat`] always provides an `Unpin` stream. +struct WsStream { + inner: S, + read_buf: Option, +} + +impl WsStream { + fn new(inner: S) -> Self { + Self { inner, read_buf: None } + } +} + +impl AsyncRead for WsStream +where + S: Stream> + Unpin, + E: core::error::Error + Send + Sync + 'static, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> Poll> { + let this = &mut *self; + + let mut data = if let Some(data) = this.read_buf.take() { + data + } else { + match ready!(Pin::new(&mut this.inner).poll_next(cx)) { + Some(Ok(WsReadMsg::Payload(data))) => data, + Some(Ok(WsReadMsg::Close)) => return Poll::Ready(Ok(())), + Some(Err(e)) => return Poll::Ready(Err(io::Error::other(e))), + None => return Poll::Ready(Ok(())), + } + }; + + let bytes_to_copy = core::cmp::min(buf.remaining(), data.len()); + + let dest = buf.initialize_unfilled_to(bytes_to_copy); + dest.copy_from_slice(&data.split_to(bytes_to_copy)); + buf.advance(bytes_to_copy); + + if !data.is_empty() { + this.read_buf = Some(data); + } + + Poll::Ready(Ok(())) + } +} + +impl AsyncWrite for WsStream +where + S: Sink, Error = E> + Unpin, + E: core::error::Error + Send + Sync + 'static, +{ + fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll> { + let this = &mut *self; + + // Try flushing preemptively. + let _ = Pin::new(&mut this.inner).poll_flush(cx); + + // Make sure the sink is ready to send. + if let Err(e) = ready!(Pin::new(&mut this.inner).poll_ready(cx)) { + return Poll::Ready(Err(io::Error::other(e))); + } + + // Actually submit the new item. If no error occurred, the message is accepted and queued + // (that is: `to_vec` is called only once). + if let Err(e) = Pin::new(&mut this.inner).start_send(buf.to_vec()) { + return Poll::Ready(Err(io::Error::other(e))); + } + + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let res = ready!(Pin::new(&mut self.inner).poll_flush(cx)); + Poll::Ready(res.map_err(io::Error::other)) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let res = ready!(Pin::new(&mut self.inner).poll_close(cx)); + Poll::Ready(res.map_err(io::Error::other)) + } } diff --git a/crates/ironrdp-cliprdr-format/CHANGELOG.md b/crates/ironrdp-cliprdr-format/CHANGELOG.md index 61cf2d5f64..8af3400cef 100644 --- a/crates/ironrdp-cliprdr-format/CHANGELOG.md +++ b/crates/ironrdp-cliprdr-format/CHANGELOG.md @@ -6,17 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-format-v0.1.4...ironrdp-cliprdr-format-v0.2.0)] - 2026-05-27 + +### Build + +- Update `ironrdp-core` public dependency to 0.2 ([#965](https://github.com/Devolutions/IronRDP/issues/965)) ([630525deae](https://github.com/Devolutions/IronRDP/commit/630525deae92f39bfed53248ab0fec0e71249322)) + + +## [[0.1.4](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-format-v0.1.3...ironrdp-cliprdr-format-v0.1.4)] - 2025-09-04 + +### Build + +- Bump png from 0.17.16 to 0.18.0 (#961) ([21fa028dff](https://github.com/Devolutions/IronRDP/commit/21fa028dffa5f9bb1498b4d48d063ea42929faf5)) + ## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-format-v0.1.2...ironrdp-cliprdr-format-v0.1.3)] - 2025-03-12 ### Build - Update dependencies (#695) ([c21fa44fd6](https://github.com/Devolutions/IronRDP/commit/c21fa44fd6f3c6a6b74788ff68e83133c1314caa)) - ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-format-v0.1.1...ironrdp-cliprdr-format-v0.1.2)] - 2025-01-28 ### Documentation - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - diff --git a/crates/ironrdp-cliprdr-format/Cargo.toml b/crates/ironrdp-cliprdr-format/Cargo.toml index f2bdcbf5d4..c9de09d872 100644 --- a/crates/ironrdp-cliprdr-format/Cargo.toml +++ b/crates/ironrdp-cliprdr-format/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-cliprdr-format" -version = "0.1.3" +version = "0.2.0" readme = "README.md" description = "CLIPRDR format conversion library" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -16,9 +17,8 @@ doctest = false test = false [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -png = "0.17" +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["std"] } # public +png = "0.18" [lints] workspace = true - diff --git a/crates/ironrdp-cliprdr-format/src/bitmap.rs b/crates/ironrdp-cliprdr-format/src/bitmap.rs index 497c6bb27d..8d3d50080f 100644 --- a/crates/ironrdp-cliprdr-format/src/bitmap.rs +++ b/crates/ironrdp-cliprdr-format/src/bitmap.rs @@ -1,6 +1,8 @@ +use std::io::Cursor; + use ironrdp_core::{ - cast_int, ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_int, ensure_fixed_part_size, + invalid_field_err, }; /// Maximum size of PNG image that could be placed on the clipboard. @@ -281,16 +283,14 @@ impl BitmapInfoHeader { fn width(&self) -> u16 { let abs = self.width.abs(); debug_assert!(abs <= 10_000); - // Per the invariant on self.width, this cast is infallible. - u16::try_from(abs).unwrap() + u16::try_from(abs).expect("per the invariant on self.width, this cast is infallible") } // INVARIANT: output (height) <= 10_000 fn height(&self) -> u16 { let abs = self.height.abs(); debug_assert!(abs <= 10_000); - // Per the invariant on self.height, this cast is infallible. - u16::try_from(abs).unwrap() + u16::try_from(abs).expect("per the invariant on self.height, this cast is infallible") } fn is_bottom_up(&self) -> bool { @@ -681,7 +681,6 @@ fn top_down_rgba_to_bottom_up_bgra( let width = u16::try_from(info.width).map_err(|_| BitmapError::WidthTooBig)?; let height = u16::try_from(info.height).map_err(|_| BitmapError::HeightTooBig)?; - #[expect(clippy::arithmetic_side_effects)] // width * 4 <= 10_000 * 4 < u32::MAX let stride = usize::from(width) * 4; let src_rows = src_bitmap.chunks_exact(stride); @@ -735,13 +734,15 @@ fn top_down_rgba_to_bottom_up_bgra( } fn decode_png(mut input: &[u8]) -> Result<(png::OutputInfo, Vec), BitmapError> { - let mut decoder = png::Decoder::new(&mut input); + let mut decoder = png::Decoder::new(Cursor::new(&mut input)); // We need to produce 32-bit DIB, so we should expand the palette to 32-bit RGBA. decoder.set_transformations(png::Transformations::ALPHA | png::Transformations::EXPAND); let mut reader = decoder.read_info()?; - let output_buffer_len = reader.output_buffer_size(); + let Some(output_buffer_len) = reader.output_buffer_size() else { + return Err(BitmapError::BufferTooBig); + }; // Prevent allocation of huge buffers. ensure(output_buffer_len <= MAX_BUFFER_SIZE).ok_or(BitmapError::BufferTooBig)?; diff --git a/crates/ironrdp-cliprdr-native/CHANGELOG.md b/crates/ironrdp-cliprdr-native/CHANGELOG.md index 69f3ee386c..5bf69398cf 100644 --- a/crates/ironrdp-cliprdr-native/CHANGELOG.md +++ b/crates/ironrdp-cliprdr-native/CHANGELOG.md @@ -6,6 +6,52 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.6.0...ironrdp-cliprdr-native-v0.7.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-cliprdr` public dependency to 0.7 + + + +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.5.0...ironrdp-cliprdr-native-v0.6.0)] - 2026-05-27 + +### Features + +- Implement clipboard file transfer support ([#1166](https://github.com/Devolutions/IronRDP/issues/1166)) ([c98a8fb774](https://github.com/Devolutions/IronRDP/commit/c98a8fb7741986e9afef00cb5615250c963a7fa9)) + + Add end-to-end clipboard file transfer (upload and download) across the + CLIPRDR channel per MS-RDPECLIP. + +## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.4.0...ironrdp-cliprdr-native-v0.5.0)] - 2025-12-18 + +### Bug Fixes + +- Prevent window class registration error on multiple sessions ([#1047](https://github.com/Devolutions/IronRDP/issues/1047)) ([a2af587e60](https://github.com/Devolutions/IronRDP/commit/a2af587e60e869f0235703e21772d1fc6a7dadcd)) + + When starting a second clipboard session, `RegisterClassA` would fail + with `ERROR_CLASS_ALREADY_EXISTS` because window classes are global to + the process. Now checks if the class is already registered before + attempting registration, allowing multiple WinClipboard instances to + coexist. + +### Build + +- Bump windows from 0.61.3 to 0.62.1 ([#1010](https://github.com/Devolutions/IronRDP/issues/1010)) ([79e71c4f90](https://github.com/Devolutions/IronRDP/commit/79e71c4f90ea68b14fe45241c1cf3953027b22a2)) + +## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.3.0...ironrdp-cliprdr-native-v0.4.0)] - 2025-08-29 + +### Bug Fixes + +- Map `E_ACCESSDENIED` WinAPI error code to `ClipboardAccessDenied` error (#936) ([b0c145d0d9](https://github.com/Devolutions/IronRDP/commit/b0c145d0d9cf2f347e537c08ce9d6c35223823d5)) + + When the system clipboard updates, we receive an `Updated` event. Then + we try to open it, but we can get `AccessDenied` error because the + clipboard may still be locked for another window (like _Notepad_). To + handle this, we have special logic that attempts to open the clipboard + in the event of such errors. + The problem is that so far, the `ClipboardAccessDenied` error was not mapped. + ## [[0.1.4](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.1.3...ironrdp-cliprdr-native-v0.1.4)] - 2025-03-12 ### Build @@ -20,16 +66,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Previously, the function handled only `WM_ACTIVATE`. - - ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.1.1...ironrdp-cliprdr-native-v0.1.2)] - 2025-01-28 ### Documentation - Use CDN URLs instead of the blob storage URLs for Devolutions logo ([#631](https://github.com/Devolutions/IronRDP/issues/631)) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-native-v0.1.0...ironrdp-cliprdr-native-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-cliprdr-native/Cargo.toml b/crates/ironrdp-cliprdr-native/Cargo.toml index 34bcd28581..38a4294cf2 100644 --- a/crates/ironrdp-cliprdr-native/Cargo.toml +++ b/crates/ironrdp-cliprdr-native/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-cliprdr-native" -version = "0.3.0" +version = "0.7.0" readme = "README.md" description = "Native CLIPRDR static channel backend implementations for IronRDP" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -16,12 +17,12 @@ doctest = false test = false [dependencies] -ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.3" } # public -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.7" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } tracing = { version = "0.1", features = ["log"] } [target.'cfg(windows)'.dependencies] -windows = { version = "0.61", features = [ +windows = { version = "0.62", features = [ "Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_DataExchange", diff --git a/crates/ironrdp-cliprdr-native/src/lib.rs b/crates/ironrdp-cliprdr-native/src/lib.rs index df320550ba..b278ce487f 100644 --- a/crates/ironrdp-cliprdr-native/src/lib.rs +++ b/crates/ironrdp-cliprdr-native/src/lib.rs @@ -13,7 +13,25 @@ #[cfg(windows)] mod windows; #[cfg(windows)] -pub use crate::windows::{WinClipboard, WinCliprdrError, WinCliprdrResult, HWND}; +pub use crate::windows::{HWND, WinClipboard, WinCliprdrError, WinCliprdrResult}; mod stub; +use std::sync::OnceLock; +use std::time::Instant; + pub use crate::stub::{StubClipboard, StubCliprdrBackend}; + +/// Process-wide monotonic clock epoch for `CliprdrBackend::now_ms()` on native platforms. +/// +/// Uses a lazily-initialized `Instant` so that all backends in the same process +/// share the same zero-point, producing comparable timestamps. +fn epoch() -> &'static Instant { + static EPOCH: OnceLock = OnceLock::new(); + EPOCH.get_or_init(Instant::now) +} + +/// Returns monotonic milliseconds since process start, for use by native +/// `CliprdrBackend` implementations. +pub fn native_now_ms() -> u64 { + u64::try_from(epoch().elapsed().as_millis()).unwrap_or(u64::MAX) +} diff --git a/crates/ironrdp-cliprdr-native/src/stub.rs b/crates/ironrdp-cliprdr-native/src/stub.rs index e6baf6e697..bef43706fe 100644 --- a/crates/ironrdp-cliprdr-native/src/stub.rs +++ b/crates/ironrdp-cliprdr-native/src/stub.rs @@ -96,4 +96,12 @@ impl CliprdrBackend for StubCliprdrBackend { fn on_request_format_list(&mut self) { debug!("on_request_format_list"); } + + fn now_ms(&self) -> u64 { + crate::native_now_ms() + } + + fn elapsed_ms(&self, since: u64) -> u64 { + self.now_ms().saturating_sub(since) + } } diff --git a/crates/ironrdp-cliprdr-native/src/windows/clipboard_data_ref.rs b/crates/ironrdp-cliprdr-native/src/windows/clipboard_data_ref.rs index b95d7b3c35..0667214282 100644 --- a/crates/ironrdp-cliprdr-native/src/windows/clipboard_data_ref.rs +++ b/crates/ironrdp-cliprdr-native/src/windows/clipboard_data_ref.rs @@ -27,7 +27,7 @@ impl<'a> ClipboardDataRef<'a> { }; // SAFETY: It is safe to call `GlobalLock` on the valid handle. - let data = unsafe { GlobalLock(handle) } as *const u8; + let data = unsafe { GlobalLock(handle) }.cast::().cast_const(); if data.is_null() { // Can't lock data handle, handle is not valid anymore (e.g. clipboard has changed) diff --git a/crates/ironrdp-cliprdr-native/src/windows/clipboard_impl.rs b/crates/ironrdp-cliprdr-native/src/windows/clipboard_impl.rs index ba1dba7ee5..ef05bf4743 100644 --- a/crates/ironrdp-cliprdr-native/src/windows/clipboard_impl.rs +++ b/crates/ironrdp-cliprdr-native/src/windows/clipboard_impl.rs @@ -1,3 +1,4 @@ +use core::ptr::with_exposed_provenance_mut; use core::time::Duration; use std::collections::HashSet; use std::sync::mpsc; @@ -17,7 +18,7 @@ use crate::windows::clipboard_data_ref::ClipboardDataRef; use crate::windows::os_clipboard::OwnedOsClipboard; use crate::windows::remote_format_registry::RemoteClipboardFormatRegistry; use crate::windows::utils::render_format; -use crate::windows::{BackendEvent, WinCliprdrError, WinCliprdrResult, WM_CLIPRDR_BACKEND_EVENT}; +use crate::windows::{BackendEvent, WM_CLIPRDR_BACKEND_EVENT, WinCliprdrError, WinCliprdrResult}; const RENDER_FORMAT_TIMEOUT_SECS: u64 = 10; const IDT_CLIPBOARD_RETRY: usize = 1; @@ -320,17 +321,19 @@ pub(crate) unsafe extern "system" fn clipboard_subproc( // SAFETY: `data` is a valid pointer, returned by `Box::into_raw`, transferred to OS earlier // via `SetWindowSubclass` call. - let _ = unsafe { Box::from_raw(data as *mut WinClipboardImpl) }; + let _ = unsafe { Box::from_raw(with_exposed_provenance_mut::(data)) }; return LRESULT(0); } // SAFETY: `data` is a valid pointer, returned by `Box::into_raw`, transferred to OS earlier // via `SetWindowSubclass` call. - let ctx = unsafe { &mut *(data as *mut WinClipboardImpl) }; + let ctx = unsafe { &mut *(with_exposed_provenance_mut::(data)) }; match msg { // We need to keep track of window state to distinguish between local and remote copy - WM_ACTIVATE | WM_ACTIVATEAPP => ctx.window_is_active = wparam.0 != WA_INACTIVE as usize, // `as` conversion is fine for constants + WM_ACTIVATE | WM_ACTIVATEAPP => { + ctx.window_is_active = wparam.0 != usize::try_from(WA_INACTIVE).expect("WA_INACTIVE fits into usize") + } // Sent by the OS when OS clipboard content is changed WM_CLIPBOARDUPDATE => { // SAFETY: `GetClipboardOwner` is always safe to call. @@ -347,8 +350,9 @@ pub(crate) unsafe extern "system" fn clipboard_subproc( } // Sent by the OS when delay-rendered data is requested for rendering. WM_RENDERFORMAT => { - #[expect(clippy::cast_possible_truncation)] // should never truncate in practice - ctx.handle_event(BackendEvent::RenderFormat(ClipboardFormatId::new(wparam.0 as u32))); + ctx.handle_event(BackendEvent::RenderFormat(ClipboardFormatId::new( + u32::try_from(wparam.0).expect("should never truncate in practice"), + ))); } // Sent by the OS when all delay-rendered data is requested for rendering. WM_RENDERALLFORMATS => { diff --git a/crates/ironrdp-cliprdr-native/src/windows/cliprdr_backend.rs b/crates/ironrdp-cliprdr-native/src/windows/cliprdr_backend.rs index 52c1bf5395..956dc62839 100644 --- a/crates/ironrdp-cliprdr-native/src/windows/cliprdr_backend.rs +++ b/crates/ironrdp-cliprdr-native/src/windows/cliprdr_backend.rs @@ -5,7 +5,7 @@ use ironrdp_cliprdr::pdu::{ ClipboardFormat, ClipboardGeneralCapabilityFlags, FileContentsRequest, FileContentsResponse, FormatDataRequest, FormatDataResponse, LockDataId, }; -use ironrdp_core::{impl_as_any, IntoOwned as _}; +use ironrdp_core::{IntoOwned as _, impl_as_any}; use windows::Win32::Foundation::{HWND, LPARAM, WPARAM}; use windows::Win32::UI::WindowsAndMessaging::PostMessageW; @@ -91,4 +91,12 @@ impl CliprdrBackend for WinCliprdrBackend { fn on_request_format_list(&mut self) { self.send_event(BackendEvent::RemoteRequestsFormatList); } + + fn now_ms(&self) -> u64 { + crate::native_now_ms() + } + + fn elapsed_ms(&self, since: u64) -> u64 { + self.now_ms().saturating_sub(since) + } } diff --git a/crates/ironrdp-cliprdr-native/src/windows.rs b/crates/ironrdp-cliprdr-native/src/windows/mod.rs similarity index 86% rename from crates/ironrdp-cliprdr-native/src/windows.rs rename to crates/ironrdp-cliprdr-native/src/windows/mod.rs index b91241c570..22f8d6c217 100644 --- a/crates/ironrdp-cliprdr-native/src/windows.rs +++ b/crates/ironrdp-cliprdr-native/src/windows/mod.rs @@ -12,17 +12,18 @@ use ironrdp_cliprdr::pdu::{ ClipboardFormat, ClipboardFormatId, ClipboardGeneralCapabilityFlags, FormatDataRequest, FormatDataResponse, }; use tracing::error; -use windows::core::{s, Error}; pub use windows::Win32::Foundation::HWND; use windows::Win32::Foundation::{E_ACCESSDENIED, FALSE, LPARAM, LRESULT, WPARAM}; use windows::Win32::System::DataExchange::{AddClipboardFormatListener, RemoveClipboardFormatListener}; use windows::Win32::System::LibraryLoader::GetModuleHandleA; use windows::Win32::UI::Shell::{RemoveWindowSubclass, SetWindowSubclass}; use windows::Win32::UI::WindowsAndMessaging::{ - CreateWindowExA, DefWindowProcA, RegisterClassA, CW_USEDEFAULT, WINDOW_EX_STYLE, WM_USER, WNDCLASSA, WS_POPUP, + CW_USEDEFAULT, CreateWindowExA, DefWindowProcA, GetClassInfoA, RegisterClassA, WINDOW_EX_STYLE, WM_USER, WNDCLASSA, + WS_POPUP, }; +use windows::core::{Error, s}; -use self::clipboard_impl::{clipboard_subproc, WinClipboardImpl}; +use self::clipboard_impl::{WinClipboardImpl, clipboard_subproc}; use self::cliprdr_backend::WinCliprdrBackend; const BACKEND_CHANNEL_SIZE: usize = 8; @@ -152,17 +153,25 @@ impl WinClipboard { // SAFETY: low-level WinAPI call let instance = unsafe { GetModuleHandleA(None)? }; let window_class = s!("IronRDPClipboardMonitor"); - let wc = WNDCLASSA { - hInstance: instance.into(), - lpszClassName: window_class, - lpfnWndProc: Some(wndproc), - ..Default::default() - }; - // SAFETY: low-level WinAPI call - let atom = unsafe { RegisterClassA(&wc) }; - if atom == 0 { - return Err(Error::from_win32())?; + let mut existing_wc = WNDCLASSA::default(); + // SAFETY: `instance` is a valid module handle, `window_class` is a valid null-terminated string, + // and `existing_wc` is a valid mutable reference to a WNDCLASSA structure. + let class_exists = unsafe { GetClassInfoA(Some(instance.into()), window_class, &mut existing_wc).is_ok() }; + + if !class_exists { + let wc = WNDCLASSA { + hInstance: instance.into(), + lpszClassName: window_class, + lpfnWndProc: Some(wndproc), + ..Default::default() + }; + + // SAFETY: low-level WinAPI call + let atom = unsafe { RegisterClassA(&wc) }; + if atom == 0 { + return Err(WinCliprdrError::from(Error::from_thread())); + } } // SAFETY: low-level WinAPI call @@ -184,7 +193,7 @@ impl WinClipboard { }; if window.is_invalid() { - return Err(Error::from_win32())?; + return Err(WinCliprdrError::from(Error::from_thread())); } // Init clipboard processing for WinAPI event loop // @@ -200,8 +209,14 @@ impl WinClipboard { // // SAFETY: `window` is a valid window handle, `clipboard_subproc` is in the static memory, // `ctx` is valid and its ownership is transferred to the subclass via `into_raw`. - let winapi_result = - unsafe { SetWindowSubclass(window, Some(clipboard_subproc), 0, Box::into_raw(ctx) as usize) }; + let winapi_result = unsafe { + SetWindowSubclass( + window, + Some(clipboard_subproc), + 0, + Box::into_raw(ctx).expose_provenance(), + ) + }; if winapi_result == FALSE { return Err(WinCliprdrError::WindowSubclass); diff --git a/crates/ironrdp-cliprdr-native/src/windows/os_clipboard.rs b/crates/ironrdp-cliprdr-native/src/windows/os_clipboard.rs index 26d347484f..734aa3a30d 100644 --- a/crates/ironrdp-cliprdr-native/src/windows/os_clipboard.rs +++ b/crates/ironrdp-cliprdr-native/src/windows/os_clipboard.rs @@ -5,8 +5,8 @@ use windows::Win32::System::DataExchange::{ CloseClipboard, EmptyClipboard, EnumClipboardFormats, GetClipboardFormatNameW, OpenClipboard, SetClipboardData, }; -use crate::windows::utils::get_last_winapi_error; use crate::windows::WinCliprdrError; +use crate::windows::utils::get_last_winapi_error; /// Safe wrapper around windows. Clipboard is automatically closed on drop. pub(crate) struct OwnedOsClipboard; diff --git a/crates/ironrdp-cliprdr-native/src/windows/remote_format_registry.rs b/crates/ironrdp-cliprdr-native/src/windows/remote_format_registry.rs index 5a3b87d438..573b804ae8 100644 --- a/crates/ironrdp-cliprdr-native/src/windows/remote_format_registry.rs +++ b/crates/ironrdp-cliprdr-native/src/windows/remote_format_registry.rs @@ -2,8 +2,8 @@ use std::collections::HashMap; use ironrdp_cliprdr::pdu::{ClipboardFormat, ClipboardFormatId}; use tracing::error; -use windows::core::PCWSTR; use windows::Win32::System::DataExchange::RegisterClipboardFormatW; +use windows::core::PCWSTR; use crate::windows::utils::get_last_winapi_error; diff --git a/crates/ironrdp-cliprdr-native/src/windows/utils.rs b/crates/ironrdp-cliprdr-native/src/windows/utils.rs index bd1120c835..1ca22eefc1 100644 --- a/crates/ironrdp-cliprdr-native/src/windows/utils.rs +++ b/crates/ironrdp-cliprdr-native/src/windows/utils.rs @@ -2,7 +2,7 @@ use ironrdp_cliprdr::pdu::ClipboardFormatId; use tracing::error; use windows::Win32::Foundation::{GetLastError, GlobalFree, HANDLE, HGLOBAL, WIN32_ERROR}; use windows::Win32::System::DataExchange::SetClipboardData; -use windows::Win32::System::Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE}; +use windows::Win32::System::Memory::{GMEM_MOVEABLE, GlobalAlloc, GlobalLock, GlobalUnlock}; use crate::windows::WinCliprdrResult; @@ -26,7 +26,7 @@ impl GlobalMemoryBuffer { // - `dst` is valid for writes of `data.len()` bytes, we allocated enough above. // - Both `data` and `dst` are properly aligned: u8 alignment is 1 // - Memory regions are not overlapping, `dst` was allocated by us just above. - unsafe { core::ptr::copy_nonoverlapping(data.as_ptr(), dst as *mut u8, data.len()) }; + unsafe { core::ptr::copy_nonoverlapping(data.as_ptr(), dst.cast::(), data.len()) }; // SAFETY: We called `GlobalLock` on this handle just above. if let Err(error) = unsafe { GlobalUnlock(handle) } { diff --git a/crates/ironrdp-cliprdr/CHANGELOG.md b/crates/ironrdp-cliprdr/CHANGELOG.md index 2934a4cebd..c2c748d099 100644 --- a/crates/ironrdp-cliprdr/CHANGELOG.md +++ b/crates/ironrdp-cliprdr/CHANGELOG.md @@ -6,6 +6,135 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-v0.6.0...ironrdp-cliprdr-v0.7.0)] - 2026-07-10 + +### Features + +- Dispatch initiate_file_copy via ClipboardMessage ([#1388](https://github.com/Devolutions/IronRDP/issues/1388)) ([b6325f9ea6](https://github.com/Devolutions/IronRDP/commit/b6325f9ea6900a84643b4415f9ebc7b1010cf3cd)) + + Extends the CLIPRDR backend-facing API to properly support offering clipboard file lists (so later FileContentsRequests can be serviced) by introducing ClipboardMessage::SendInitiateFileCopy(Vec) and wiring it through the in-tree ClipboardMessage dispatchers. + +### Bug Fixes + +- Release outgoing locks before initiating a file copy ([#1375](https://github.com/Devolutions/IronRDP/issues/1375)) ([5d534f10a6](https://github.com/Devolutions/IronRDP/commit/5d534f10a6f62ac7a860521b4e95c8c47b754612)) + +- Lower verbosity of routine logs in library crates ([c36032f91b](https://github.com/Devolutions/IronRDP/commit/c36032f91b27390a2cd34bfb300cfbe099d847a9)) + + Library crates should not emit info! for routine, repeating operations; + that floods the default logs of the final consumer, which owns the + verbosity decision. Reserve info! for rare connection/session lifecycle + milestones, debug! for significant one-off events, and trace! for the + fine-grained detail only needed when nothing else explains a problem. + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-svc` public dependency to 0.8 + + + +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-v0.5.0...ironrdp-cliprdr-v0.6.0)] - 2026-05-27 + +### Features + +- [**breaking**] Implement clipboard file transfer support ([#1166](https://github.com/Devolutions/IronRDP/issues/1166)) ([c98a8fb774](https://github.com/Devolutions/IronRDP/commit/c98a8fb7741986e9afef00cb5615250c963a7fa9)) + + Add end-to-end clipboard file transfer (upload and download) across the + CLIPRDR channel per MS-RDPECLIP. Automatic clipboard locking: when + `FileGroupDescriptorW` is detected in a FormatList, the processor + automatically sends Lock PDUs and manages the lock lifecycle (expiry, + cleanup, Unlock PDUs) internally. + + New `CliprdrBackend` methods (with default implementations): + `on_remote_file_list()`, `on_file_contents_request()`, + `on_outgoing_locks_cleared()`, `on_outgoing_locks_expired()`, and + `now_ms()` / `elapsed_ms()` for timeout tracking. New `drive_timeouts()` + method for callers to invoke periodically. Comprehensive path + sanitization to protect against path traversal attacks. + + Breaking changes folded in: removed `ClipboardMessage::SendLockClipboard` + and `SendUnlockClipboard` variants (lock/unlock is now managed + internally); renamed `FileContentsFlags::DATA` to `RANGE` (matches + MS-RDPECLIP 2.2.5.3 terminology); changed `FileContentsRequest::index` + from `u32` to `i32` (per spec); made `FileDescriptor` `#[non_exhaustive]` + and added `relative_path: Option` field (use the builder pattern + instead of struct literals). + +- Add clipboard data locking methods ([#1064](https://github.com/Devolutions/IronRDP/issues/1064)) ([58c3df84bb](https://github.com/Devolutions/IronRDP/commit/58c3df84bb9cafc8669315834cead35a71483c34)) + + Per MS-RDPECLIP sections 2.2.4.6 and 2.2.4.7, the local + clipboard owner can lock shared clipboard data before requesting file + contents, ensuring data stability during multi-request transfers. + +- Add request_file_contents method ([#1065](https://github.com/Devolutions/IronRDP/issues/1065)) ([c30fc35a28](https://github.com/Devolutions/IronRDP/commit/c30fc35a28d6218603c1662e98e8b3053bea3aa5)) + + Per MS-RDPECLIP section 2.2.5.3, this adds support + for sending File Contents Request PDUs to retrieve remote file data + during paste operations. + +- Add SendFileContentsResponse message variant ([#1066](https://github.com/Devolutions/IronRDP/issues/1066)) ([25f81337aa](https://github.com/Devolutions/IronRDP/commit/25f81337aa494af9a21f55f12ec27fd946465cbe)) + + Adds `SendFileContentsResponse` to `ClipboardMessage`, allowing + clipboard backends to signal when file data is ready to be sent via + `submit_file_contents()`. + +- Always set FD_PROGRESSUI in FileDescriptor::encode ([#1299](https://github.com/Devolutions/IronRDP/issues/1299)) ([7e0bfd3c55](https://github.com/Devolutions/IronRDP/commit/7e0bfd3c550135a3c9c85cb66a478ce41c8641d9)) + +- Advertise Preferred DropEffect alongside FileGroupDescriptorW ([#1301](https://github.com/Devolutions/IronRDP/issues/1301)) ([5375bbb9dd](https://github.com/Devolutions/IronRDP/commit/5375bbb9ddb8b853973d050fa2efd0ed217ac17b)) + + `initiate_file_copy` now advertises **both** `FileGroupDescriptorW` and + `Preferred DropEffect` (`CFSTR_PREFERREDDROPEFFECT`) in the FormatList, + and `handle_format_data_request` short-circuits a request for the latter + with `DROPEFFECT_COPY` (0x00000001 LE). + +- [**breaking**] Add CliprdrBackend::on_format_list_response(ok) hook ([#1300](https://github.com/Devolutions/IronRDP/issues/1300)) ([a4bc475360](https://github.com/Devolutions/IronRDP/commit/a4bc4753607d87ef0989d9df16a31cd22e7c7fde)) + +### Bug Fixes + +- Replace all from_bits_truncate with from_bits_retain ([#1144](https://github.com/Devolutions/IronRDP/issues/1144)) ([353e30ddfd](https://github.com/Devolutions/IronRDP/commit/353e30ddfdaafc897db10b8663e364ef7775a7fd)) + + from_bits_truncate silently discards unknown bits, which breaks the + encode/decode round-trip property. This matters for fuzzing because a + PDU that decodes and re-encodes should produce identical bytes. + from_bits_retain preserves all bits, including those not yet defined in + our bitflags types, so the round-trip property holds. + +### Build + +- Bump the patch group across 1 directory with 2 updates ([#1222](https://github.com/Devolutions/IronRDP/issues/1222)) ([3fe6d157e0](https://github.com/Devolutions/IronRDP/commit/3fe6d157e0b55bddfdac20af290a6cfa6e550576)) + + +## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-v0.4.0...ironrdp-cliprdr-v0.5.0)] - 2025-12-18 + +### Bug Fixes + +- Fixes the Cliprdr `SvcProcessor` impl to support handling a `TemporaryDirectory` Clipboard PDU ([#1031](https://github.com/Devolutions/IronRDP/issues/1031)) ([f2326ef046](https://github.com/Devolutions/IronRDP/commit/f2326ef046cc81fb0e8985f03382859085882e86)) + +- Allow servers to announce clipboard ownership ([#1053](https://github.com/Devolutions/IronRDP/issues/1053)) ([d587b0c4c1](https://github.com/Devolutions/IronRDP/commit/d587b0c4c114c49d30f52859f43b22f829456a01)) + + Servers can now send Format List PDU via initiate_copy() regardless of + internal state. The existing state machine was designed for clients + where clipboard initialization must complete before announcing + ownership. + + MS-RDPECLIP Section 2.2.3.1 specifies that Format List PDU is sent by + either client or server when the local clipboard is updated. Servers + should be able to announce clipboard changes immediately after channel + negotiation. + + This change enables RDP servers to properly announce clipboard ownership + by bypassing the Initialization/Ready state check when R::is_server() is + true. Client behavior remains unchanged. + +- [**breaking**] Removed the `PackedMetafile::data()` method in favor of making the `PackedMetafile::data` field public. + +## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-v0.3.0...ironrdp-cliprdr-v0.4.0)] - 2025-08-29 + +### Bug Fixes + +- [**breaking**] Remove the `on_format_list_received` callback (#935) ([5b948e2161](https://github.com/Devolutions/IronRDP/commit/5b948e2161b08b13d32bdbb480b26c8fa44d42f7)) + ## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-v0.2.0...ironrdp-cliprdr-v0.3.0)] - 2025-05-27 ### Features @@ -36,7 +165,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-cliprdr-v0.1.0...ironrdp-cliprdr-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-cliprdr/Cargo.toml b/crates/ironrdp-cliprdr/Cargo.toml index d0b131c7bb..fa5ff661fb 100644 --- a/crates/ironrdp-cliprdr/Cargo.toml +++ b/crates/ironrdp-cliprdr/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-cliprdr" -version = "0.3.0" +version = "0.7.0" readme = "README.md" description = "CLIPRDR static channel for clipboard implemented as described in MS-RDPECLIP" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -15,12 +16,18 @@ categories.workspace = true doctest = false test = false +[features] +# Internal (PRIVATE!) features used to aid testing. +# Don't rely on these whatsoever. They may disappear at any time. +__test = ["dep:visibility"] + [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public tracing = { version = "0.1", features = ["log"] } -bitflags = "2.9" +bitflags = "2.11" +visibility = { version = "0.1", optional = true } [lints] workspace = true diff --git a/crates/ironrdp-cliprdr/README.md b/crates/ironrdp-cliprdr/README.md index ca9b97a36c..f744b60469 100644 --- a/crates/ironrdp-cliprdr/README.md +++ b/crates/ironrdp-cliprdr/README.md @@ -1,14 +1,385 @@ # IronRDP CLIPRDR -Implementation of clipboard static virtual channel(`CLIPRDR`) described in `MS-RDPECLIP` +Implementation of clipboard static virtual channel (`CLIPRDR`) described in [MS-RDPECLIP]. This library includes: - Clipboard SVC PDUs parsing - Clipboard SVC processing - Clipboard backend API types for implementing OS-specific clipboard logic +- File transfer support via clipboard redirection For concrete native clipboard backend implementations, see `ironrdp-cliprdr-native` crate. This crate is part of the [IronRDP] project. [IronRDP]: https://github.com/Devolutions/IronRDP +[MS-RDPECLIP]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeclip + +## Features + +- **Text clipboard transfer**: Copy/paste text between local and remote clipboards +- **File transfer**: Copy/paste files using delayed rendering per MS-RDPECLIP spec +- **Format negotiation**: Automatic capability negotiation with the server +- **Delayed rendering**: Efficient clipboard synchronization with minimal bandwidth usage + +## Usage + +### Basic Clipboard Operations + +```rust +use ironrdp_cliprdr::{CliprdrClient, backend::CliprdrBackend}; +use ironrdp_cliprdr::pdu::{ClipboardFormat, ClipboardFormatId}; + +// Initialize clipboard client with your backend implementation +let mut cliprdr = CliprdrClient::new(Box::new(my_backend)); + +// Initiate a text copy operation +let formats = vec![ + ClipboardFormat::new(ClipboardFormatId::new(13)) // CF_UNICODETEXT +]; +let messages = cliprdr.initiate_copy(&formats)?; +// Send messages on the CLIPRDR virtual channel + +// Initiate a paste operation (after receiving FormatList from remote) +let format_id = ClipboardFormatId::new(13); // CF_UNICODETEXT +let messages = cliprdr.initiate_paste(format_id)?; +// Send messages on the CLIPRDR virtual channel +``` + +### File Transfer + +File transfer follows the delayed rendering pattern specified in MS-RDPECLIP section 1.3.1.4: + +1. **Copying files locally** - Advertise file formats without sending data +2. **Pasting files** - Request file list when user initiates paste +3. **Downloading files** - Request individual file contents + +#### Copying Files to Remote (Upload) + +```rust +use ironrdp_cliprdr::pdu::{FileDescriptor, ClipboardFileAttributes}; + +// Create file descriptors for files to copy (FileDescriptor is #[non_exhaustive]) +let files = vec![ + FileDescriptor::new("document.pdf") + .with_attributes(ClipboardFileAttributes::ARCHIVE) + .with_last_write_time(132489216000000000) // FILETIME format + .with_file_size(1024), + FileDescriptor::new("spreadsheet.xlsx") + .with_attributes(ClipboardFileAttributes::ARCHIVE) + .with_last_write_time(132489216000000000) + .with_file_size(2048), +]; + +// Initiate file copy - sends FormatList with FileGroupDescriptorW +let messages = cliprdr.initiate_file_copy(files)?; +// Send messages on the CLIPRDR virtual channel + +// When remote requests the file list (via FormatDataRequest), +// the state machine automatically responds with the stored file list +``` + +#### Pasting Files from Remote (Download) + +```rust +// When FormatList arrives containing FileGroupDescriptorW format, +// the backend's on_remote_copy() is called with available formats. +// The file list format ID is stored automatically. + +// User initiates paste - request the file list +let file_list_format_id = /* format ID from FormatList */; +let messages = cliprdr.initiate_paste(file_list_format_id)?; +// Send messages on the CLIPRDR virtual channel + +// When FormatDataResponse arrives with file list, +// backend's on_remote_file_list() is called automatically: +fn on_remote_file_list( + &mut self, + files: &[FileDescriptor], + clip_data_id: Option, +) { + // Receive file metadata and the automatic lock ID + self.current_lock_id = clip_data_id; + + for (index, file) in files.iter().enumerate() { + println!("File {}: {} ({} bytes)", + index, + file.name, + file.file_size.unwrap_or(0)); + } + + // Example: Request first file's size (using the lock) + let size_request = FileContentsRequest { + stream_id: 1, + index: 0, // First file in the list + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: clip_data_id, + }; + // Call cliprdr.request_file_contents(size_request) + // Response arrives via on_file_contents_response() +} +``` + +#### Requesting File Contents + +```rust +use ironrdp_cliprdr::pdu::{FileContentsRequest, FileContentsFlags}; + +// Request file size first +let size_request = FileContentsRequest { + stream_id: 1, // Unique ID for this transfer + index: 0, // File index (0-based i32, must be non-negative per MS-RDPECLIP 2.2.5.3) + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, // SIZE requests must request 8 bytes per MS-RDPECLIP 2.2.5.3 + data_id: None, +}; +let messages = cliprdr.request_file_contents(size_request)?; + +// Then request file data in chunks +let data_request = FileContentsRequest { + stream_id: 1, + index: 0, + flags: FileContentsFlags::RANGE, + position: 0, // Byte offset + requested_size: 4096, // Chunk size + data_id: None, +}; +let messages = cliprdr.request_file_contents(data_request)?; +``` + +### Clipboard Locking + +Per [MS-RDPECLIP] section 2.2.4, clipboard locking prevents clipboard data from being overwritten during file transfer operations. IronRDP acquires locks automatically when `FileGroupDescriptorW` is detected in a FormatList, similar to FreeRDP's behavior. + +#### Why Locking is Needed + +File transfers can take significant time. Without locking, if the remote clipboard changes during transfer: +- File list metadata becomes stale +- File contents requests may fail with mismatched data +- Download operations fail mid-transfer + +Locking creates a snapshot of the clipboard state that persists even if the remote clipboard content changes. + +#### Automatic Lock Behavior + +When a FormatList containing `FileGroupDescriptorW` is received and `CAN_LOCK_CLIPDATA` was negotiated, the cliprdr processor automatically: + +1. Sends a Lock Clipboard Data PDU with a new `clipDataId` +2. Passes the `clipDataId` to the backend via `on_remote_file_list(files, clip_data_id)` +3. Manages the lock lifecycle (expiry, cleanup, Unlock PDUs) internally + +Backends receive the `clipDataId` and can pass it to `request_file_contents()` via the `data_id` field. No explicit lock/unlock calls are needed. + +#### Driving Timeouts + +Callers must drive [`Cliprdr::drive_timeouts()`] from a periodic timer in their event loop (e.g., every 5 seconds). This method processes: +- **Expired locks**: Sends Unlock PDUs for locks past their inactivity timeout or max lifetime +- **Stale requests**: Cleans up file contents requests older than the transfer timeout +- **Abandoned uploads**: Prunes locked file list snapshots with no recent activity + +```rust +// In your event loop's timer arm (e.g., tokio::time::interval or gloo_timers::IntervalStream) +let messages = cliprdr.drive_timeouts()?; +for msg in messages { + send_on_channel(msg)?; +} +``` + +Locks are cleaned up based on: +- **Inactivity timeout** (60s default): No FileContentsRequest activity after lock expires +- **Maximum lifetime** (2h default): Force cleanup regardless of activity + +#### Customizing Lock Timeouts + +For specialized use cases (slow networks, large files), customize timeout policy: + +```rust +use std::time::Duration; + +let cliprdr = Cliprdr::with_lock_timeouts( + Box::new(my_backend), + Duration::from_secs(120), // Inactivity: 2 minutes + Duration::from_secs(3600), // Max: 1 hour +); +``` + +#### Concurrent Downloads + +Multiple file download operations can run simultaneously using the same lock. The `clipDataId` from `on_remote_file_list()` should be passed to each `request_file_contents()` call. The lock remains active as long as file content requests keep arriving within the inactivity timeout. + +#### Lock Expiration on Clipboard Change + +When a new `FormatList` arrives (indicating clipboard content changed), all active locks transition to **Expired state** and enter a grace period. Expired locks: +- Remain functional and can continue servicing ongoing file transfers +- Are cleaned up after the inactivity timeout (60s default) if no FileContentsRequest activity occurs +- Are force-cleaned after the maximum lifetime (2h default) regardless of activity + +This grace period approach ensures: +- Long-running downloads can complete even if the remote clipboard changes +- Resources are eventually released without blocking active transfers +- Network stalls and reconnections don't cause transfer failures + +##### Grace Period Timeout Values + +The lock cleanup mechanism uses two timeout values to balance transfer reliability with resource management: + +**Inactivity Timeout (default: 60 seconds)** +- Locks are cleaned up after 60 seconds without `FileContentsRequest` activity +- Each `FileContentsRequest` for the lock resets the inactivity timer +- This handles abandoned downloads while allowing active transfers to continue +- Configurable via `Cliprdr::with_lock_timeouts(backend, inactivity_duration, max_lifetime_duration)` + +**Maximum Lifetime (default: 2 hours)** +- Locks are force-cleaned after 2 hours regardless of activity +- This prevents indefinite resource accumulation from slow or stalled transfers +- Measured from when the lock was created (not from when it transitioned to Expired) +- Configurable via `Cliprdr::with_lock_timeouts(backend, inactivity_duration, max_lifetime_duration)` + +**Activity Tracking:** +- Only `FileContentsRequest` calls update the `last_used_at` timestamp +- Lock/Unlock PDUs do not reset the timer +- A lock survives clipboard changes as long as requests keep arriving within 60s + +**Example: Customizing Timeouts for Slow Networks** + +```rust +use std::time::Duration; + +let cliprdr = Cliprdr::with_lock_timeouts( + Box::new(MyBackend), + Duration::from_secs(300), // 5 minute inactivity timeout + Duration::from_secs(14400), // 4 hour maximum lifetime +); +``` + +**When to Adjust Timeouts:** +- **Slow networks**: Increase inactivity timeout to prevent cleanup during normal transfer delays +- **Large files**: Increase both timeouts to accommodate extended download times +- **Resource-constrained systems**: Decrease timeouts to free resources more aggressively +- **Default values work well** for typical network conditions and file sizes + +#### Backend Responsibilities + +Backends receive the lock ID and use it for file content requests: + +```rust +impl CliprdrBackend for MyBackend { + fn on_remote_file_list( + &mut self, + files: &[FileDescriptor], + clip_data_id: Option, + ) { + // Store the lock ID for use in file content requests + self.current_lock_id = clip_data_id; + // Display files to user or start downloads + self.start_file_downloads(files); + } +} +``` + +No explicit lock/unlock calls are needed - the cliprdr processor handles the full lifecycle. + +#### clipDataId in File Contents Requests + +When making `FileContentsRequest` calls, the `data_id` field is automatically populated with the most recent lock ID if not already set: + +```rust +let request = FileContentsRequest { + stream_id: 1, + index: 0, + flags: FileContentsFlags::RANGE, + position: 0, + requested_size: 4096, + data_id: None, // Automatically uses current_lock_id +}; +cliprdr.request_file_contents(request)?; +``` + +Backends can override by setting `data_id` explicitly if managing multiple concurrent locks. + +### Implementing a Clipboard Backend + +```rust +use ironrdp_cliprdr::backend::CliprdrBackend; +use ironrdp_cliprdr::pdu::*; + +struct MyClipboardBackend { + // Your OS-specific clipboard state +} + +impl CliprdrBackend for MyClipboardBackend { + fn temporary_directory(&self) -> &str { + "/tmp/clipboard" + } + + fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags { + ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES + | ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + | ClipboardGeneralCapabilityFlags::FILECLIP_NO_FILE_PATHS + } + + fn on_ready(&mut self) { + // Clipboard channel is ready + } + + fn on_remote_copy(&mut self, available_formats: &[ClipboardFormat]) { + // Remote has new clipboard content with these formats + } + + fn on_remote_file_list( + &mut self, + files: &[FileDescriptor], + clip_data_id: Option, + ) { + // Remote has files available for download. + // clip_data_id is the lock acquired automatically when + // FileGroupDescriptorW was detected. Pass it to request_file_contents(). + self.current_lock_id = clip_data_id; + for (index, file) in files.iter().enumerate() { + println!("File {}: {} ({} bytes)", + index, file.name, file.file_size.unwrap_or(0)); + } + } + + fn on_format_data_request(&mut self, request: FormatDataRequest) { + // Remote is requesting clipboard data + // Call cliprdr.submit_format_data() to respond + } + + fn on_file_contents_request(&mut self, request: FileContentsRequest) { + // Remote is requesting file contents + // Call cliprdr.submit_file_contents() to respond + } + + // ... implement other required methods +} +``` + +## File Name Validation + +Per MS-RDPECLIP section 2.2.5.2.3.1, file names are automatically validated: +- Maximum length: 259 characters (Unicode) +- File names must not be empty +- No absolute paths when `CB_FILECLIP_NO_FILE_PATHS` is set (relative paths like `subfolder/file.txt` are allowed) + +Invalid file descriptors are logged and skipped during `initiate_file_copy()`. + +**Note:** IronRDP always sets `CB_FILECLIP_NO_FILE_PATHS` to use stream-based file transfer. Backends should provide relative filenames (e.g., `document.pdf` or `reports/summary.txt`) rather than absolute paths (e.g., `/home/user/document.pdf` or `C:\Users\user\document.pdf`). + +## Delayed Rendering + +The implementation follows MS-RDPECLIP section 1.3.1.4 "Delayed Rendering": + +1. **Copy phase**: Only format IDs are sent, not actual data +2. **Paste phase**: Data is requested only when user initiates paste +3. **File transfers**: File list is requested on paste, file contents on demand + +This minimizes network bandwidth and ensures clipboard synchronization efficiency. + +## References + +- [MS-RDPECLIP]: Remote Desktop Protocol: Clipboard Virtual Channel Extension +- [MS-RDPBCGR]: Remote Desktop Protocol: Basic Connectivity and Graphics Remoting diff --git a/crates/ironrdp-cliprdr/src/backend.rs b/crates/ironrdp-cliprdr/src/backend.rs index 2caa11b56c..d6f489ed97 100644 --- a/crates/ironrdp-cliprdr/src/backend.rs +++ b/crates/ironrdp-cliprdr/src/backend.rs @@ -4,7 +4,7 @@ use ironrdp_core::AsAny; use crate::pdu::{ ClipboardFormat, ClipboardFormatId, ClipboardGeneralCapabilityFlags, FileContentsRequest, FileContentsResponse, - FormatDataRequest, FormatDataResponse, LockDataId, OwnedFormatDataResponse, + FileDescriptor, FormatDataRequest, FormatDataResponse, LockDataId, OwnedFormatDataResponse, }; pub trait ClipboardError: core::error::Error + Send + Sync + 'static {} @@ -33,6 +33,23 @@ pub enum ClipboardMessage { /// received. SendInitiatePaste(ClipboardFormatId), + /// Sent by clipboard backend when file contents are needed from the remote. + /// + /// Implementation should send file contents request on `CLIPRDR` SVC when received. + SendFileContentsRequest(FileContentsRequest), + + /// Sent by clipboard backend when file contents data is ready to be sent to the remote. + /// + /// Implementation should send file contents response on `CLIPRDR` SVC when received. + SendFileContentsResponse(FileContentsResponse<'static>), + + /// Sent by clipboard backend when a local file list is ready to be offered to the remote. + /// + /// Implementation should initiate a file copy on `CLIPRDR` SVC when this message is + /// received. Unlike [`ClipboardMessage::SendInitiateCopy`], this records the file list so + /// later `FileContentsRequest`s from the remote can be serviced. + SendInitiateFileCopy(Vec), + /// Failure received from the OS clipboard event loop. /// /// Client implementation should log/display this error. @@ -73,17 +90,22 @@ pub trait CliprdrBackend: AsAny + core::fmt::Debug + Send { /// client's clipboard prior to `CLIPRDR` SVC initialization. fn on_request_format_list(&mut self); - /// Called by [crate::Cliprdr] when copy sequence is finished. - /// This method is called after remote returns format list response. + /// Called by [`crate::Cliprdr`] when the remote responds to a `FormatList` we + /// sent (i.e. an outbound advertise of our own clipboard contents). /// - /// Useful for the backend implementations which need to know when remote is ready to paste - /// previously advertised formats from the client. E.g. Web client uses this for - /// Firefox-specific logic to delay sending keyboard key events to prevent pasting the old - /// data from the clipboard. + /// `ok = true` means the remote accepted the list (`CB_RESPONSE_OK`); + /// `ok = false` means it rejected it (`CB_RESPONSE_FAIL`), and + /// [`crate::Cliprdr`] has already cleared + /// `local_file_list` / `local_file_list_format_id` per MS-RDPECLIP 3.1.5.2.4. /// - /// This method has default implementation which does nothing because it is not required for - /// most of the backends. - fn on_format_list_received(&mut self) {} + /// Backends can use this to retry on `Fail` (e.g. ride out a transient + /// rejection caused by the remote window being inactive at the instant we + /// advertised) and, equally important, to **stop** re-advertising once an + /// `Ok` is seen — a later blind re-advertise that gets rejected would wipe + /// already-accepted state and silently break a paste that was about to work. + fn on_format_list_response(&mut self, ok: bool) { + let _ = ok; + } /// Adjusts [crate::Cliprdr] backend capabilities based on capabilities negotiated with a server. /// @@ -137,15 +159,120 @@ pub trait CliprdrBackend: AsAny + core::fmt::Debug + Send { /// If data is not available anymore, then server will send error response instead. fn on_file_contents_response(&mut self, response: FileContentsResponse<'_>); - /// Locks specific data stream in the client clipboard. + /// Processes incoming Lock PDU from the server. + /// + /// Called by [crate::Cliprdr] when server requests to lock **client clipboard data**. + /// This is an incoming lock request - the server wants to prevent the client's clipboard + /// from changing during file upload operations. /// - /// Called by [crate::Cliprdr] when server requests to lock client clipboard. fn on_lock(&mut self, data_id: LockDataId); - /// Unlocks specific data stream in the client clipboard. + /// Processes incoming Unlock PDU from the server. + /// + /// Called by [crate::Cliprdr] when server requests to unlock **client clipboard data**. + /// This is an incoming unlock request - the server is done with the locked clipboard snapshot. /// - /// Called by [crate::Cliprdr] when server requests to unlock client clipboard. fn on_unlock(&mut self, data_id: LockDataId); + + /// [2.2.5.2] Processes remote file list metadata + /// + /// Called by [crate::Cliprdr] when file list metadata is received from the remote + /// in response to a paste request for the FileGroupDescriptorW format (delayed + /// rendering). The file list is not fetched automatically when a FormatList arrives; + /// the backend must call [`crate::Cliprdr::initiate_paste`] to request it. + /// The backend receives file metadata (names, sizes, timestamps) to decide whether + /// to download files. + /// + /// ## Parameters + /// + /// - `files`: File metadata received from the remote + /// - `clip_data_id`: The clipDataId for the lock that was automatically + /// created when the Format List was received. `None` if locking was not + /// negotiated (CAN_LOCK_CLIPDATA capability absent). Use this ID in + /// [`crate::Cliprdr::request_file_contents`] calls to download files against + /// the locked clipboard snapshot. + /// + /// ## Security Considerations + /// + /// **Windows reserved device names**: File names like `CON`, `PRN`, `AUX`, `NUL`, + /// `COM1`-`COM9`, and `LPT1`-`LPT9` are reserved on Windows. Creating a file with + /// one of these names opens the corresponding device instead of a regular file, + /// which can cause hangs or unexpected behavior. Backends writing files to disk + /// on Windows should use [`crate::is_windows_device_name`] to detect and reject + /// these names before creating files. + /// + /// **File size validation**: The `file_size` field in [`FileDescriptor`] is provided + /// by the remote peer and should not be trusted for memory allocation decisions. + /// A malicious remote could advertise an arbitrarily large file size (up to `u64::MAX`) + /// to cause out-of-memory conditions. Backends should: + /// - Validate file sizes against available disk space before downloading + /// - Use streaming writes or chunked downloads rather than pre-allocating buffers + /// - Consider enforcing maximum file size limits appropriate for their use case + /// + /// [2.2.5.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeclip/9c01c966-e09b-438d-9391-ce31f3caddc3 + fn on_remote_file_list(&mut self, files: &[FileDescriptor], clip_data_id: Option) { + let _ = (files, clip_data_id); + } + + /// Called when expired outgoing clipboard locks are cleaned up. + /// + /// This is triggered by [`crate::Cliprdr::drive_timeouts`] after a lock has been + /// in the Expired state and either the inactivity timeout or max lifetime has elapsed. + /// Locks transition to Expired when a new FormatList PDU is received (clipboard change); + /// this callback fires later when the cleanup actually removes them. + /// + /// **Use case**: Backends can use this to clean up any associated lock state, cancel + /// ongoing downloads, or update UI to reflect that the lock is no longer valid. + /// + /// ## Parameters + /// + /// - `clip_data_ids`: List of clipDataIds for locks that were cleaned up. + /// + /// **Note**: Unlock PDUs are automatically sent for each cleared lock. + fn on_outgoing_locks_cleared(&mut self, clip_data_ids: &[LockDataId]) { + let _ = clip_data_ids; + // Default implementation does nothing - backends can override to handle lock cleanup + } + + /// Called when outgoing locks transition from Active to Expired due to a + /// clipboard change (new FormatList received from remote). + /// + /// Expired locks are not yet removed -- they remain in the outgoing locks + /// map to protect in-flight file downloads. The locks will be cleaned up + /// later by [`crate::Cliprdr::drive_timeouts`], which triggers + /// [`CliprdrBackend::on_outgoing_locks_cleared`]. + /// + /// This callback fires once per clipboard change, with only the lock IDs + /// that transitioned from Active to Expired during that event. + fn on_outgoing_locks_expired(&mut self, clip_data_ids: &[LockDataId]) { + let _ = clip_data_ids; + } + + /// Returns the current monotonic time in milliseconds. + /// + /// Used by [`crate::Cliprdr`] for lock inactivity tracking and cleanup scheduling. + /// Implementations should return a monotonically non-decreasing value. + /// + /// The default implementation uses `std::time::Instant` with a process-local + /// epoch. Override this for WASM (use `Performance.now()`) or tests (use a + /// controllable counter for deterministic behavior). + fn now_ms(&self) -> u64 { + use std::sync::OnceLock; + use std::time::Instant; + + static EPOCH: OnceLock = OnceLock::new(); + let epoch = EPOCH.get_or_init(Instant::now); + + u64::try_from(epoch.elapsed().as_millis()).unwrap_or(u64::MAX) + } + + /// Returns the elapsed time in milliseconds since the given timestamp. + /// + /// `since` is a value previously returned by [`now_ms`](Self::now_ms). + /// If `since` is in the future (clock skew), implementations should return 0. + fn elapsed_ms(&self, since: u64) -> u64 { + self.now_ms().saturating_sub(since) + } } /// Required to build backend for the OS clipboard implementation. diff --git a/crates/ironrdp-cliprdr/src/lib.rs b/crates/ironrdp-cliprdr/src/lib.rs index 5205ee5ca3..116ca3af68 100644 --- a/crates/ironrdp-cliprdr/src/lib.rs +++ b/crates/ironrdp-cliprdr/src/lib.rs @@ -1,28 +1,26 @@ #![cfg_attr(doc, doc = include_str!("../README.md"))] #![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] -#![allow(clippy::arithmetic_side_effects)] // FIXME: remove -#![allow(clippy::cast_lossless)] // FIXME: remove -#![allow(clippy::cast_possible_truncation)] // FIXME: remove -#![allow(clippy::cast_possible_wrap)] // FIXME: remove -#![allow(clippy::cast_sign_loss)] // FIXME: remove pub mod backend; pub mod pdu; +use std::collections::HashMap; + use backend::CliprdrBackend; -use ironrdp_core::{decode, AsAny, EncodeResult}; +use ironrdp_core::{AsAny, EncodeResult, IntoOwned as _, decode}; use ironrdp_pdu::gcc::ChannelName; -use ironrdp_pdu::{decode_err, encode_err, PduResult}; +use ironrdp_pdu::{PduResult, decode_err, encode_err}; use ironrdp_svc::{ ChannelFlags, CompressionCondition, SvcClientProcessor, SvcMessage, SvcProcessor, SvcProcessorMessages, SvcServerProcessor, }; use pdu::{ - Capabilities, ClientTemporaryDirectory, ClipboardFormat, ClipboardFormatId, ClipboardGeneralCapabilityFlags, - ClipboardPdu, ClipboardProtocolVersion, FileContentsResponse, FormatDataRequest, FormatListResponse, - OwnedFormatDataResponse, + Capabilities, ClientTemporaryDirectory, ClipboardFormat, ClipboardFormatId, ClipboardFormatName, + ClipboardGeneralCapabilityFlags, ClipboardPdu, ClipboardProtocolVersion, FileContentsFlags, FileContentsRequest, + FileContentsResponse, FileDescriptor, FormatDataRequest, FormatListResponse, LockDataId, OwnedFormatDataResponse, + PackedFileList, }; -use tracing::{error, info}; +use tracing::{debug, error, trace, warn}; #[rustfmt::skip] // do not reorder use crate::pdu::FormatList; @@ -30,40 +28,427 @@ use crate::pdu::FormatList; /// PDUs for sending to the server on the CLIPRDR channel. pub type CliprdrSvcMessages = SvcProcessorMessages>; -#[derive(Debug)] -enum ClipboardError { - UnimplementedPdu { pdu: &'static str }, - FormatListRejected, +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "__test", visibility::make(pub))] +pub(crate) enum CliprdrState { + Initialization, + Ready, +} + +/// [MS-RDPECLIP] 2.2.5.3 / 2.2.5.4 - Tracks state of a file contents transfer +/// +/// Used to validate FileContentsResponse matches the corresponding FileContentsRequest +/// and to support concurrent transfers identified by streamId. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "__test", visibility::make(pub))] +pub(crate) struct FileTransferState { + /// File index (lindex) from the file list. + /// Validated non-negative and in-bounds at parse time (Parse Don't Validate). + /// Wire format is i32 per [MS-RDPECLIP] 2.2.5.3, but stored as usize after validation. + pub file_index: usize, + /// Flags from the request (SIZE or RANGE) + /// Used for SIZE/RANGE response validation + pub flags: FileContentsFlags, + /// When this request was sent (milliseconds from backend clock). + /// Used for stale request cleanup. + pub sent_at_ms: u64, +} + +/// State of a clipboard lock +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "__test", visibility::make(pub))] +pub(crate) enum LockState { + /// Lock is active and protecting clipboard data + Active, + + /// Lock has expired (clipboard changed) but may still be in use + /// Will be cleaned up based on activity and time rules + Expired { + /// When this lock expired (clipboard changed), in milliseconds + /// from the backend clock. + expired_at_ms: u64, + }, +} + +/// [MS-RDPECLIP] 2.2.4 - Outgoing clipboard lock state tracking +/// +/// Tracks state of a clipboard lock with activity-based timeout. +/// Used to manage multiple concurrent file download operations. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "__test", visibility::make(pub))] +pub(crate) struct OutgoingLock { + /// Current state of this lock + pub state: LockState, + + /// When this lock was created (milliseconds from backend clock), + /// used for max_lifetime enforcement. + pub created_at_ms: u64, + + /// Last time this lock was used for a FileContentsRequest + /// (milliseconds from backend clock). + /// Updated to prevent timeout during active transfers. + pub last_used_at_ms: u64, } -impl core::fmt::Display for ClipboardError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - ClipboardError::UnimplementedPdu { pdu } => { - write!(f, "received clipboard PDU `{pdu}` is not implemented") +/// Detects if a filename contains an absolute path. +/// +/// Per [MS-RDPECLIP] 3.1.1.3, when CB_FILECLIP_NO_FILE_PATHS is set, filenames +/// MUST NOT include source paths. This function detects absolute paths across +/// different OS path conventions: +/// +/// - Unix absolute: `/path/to/file` +/// - Windows absolute: `C:\path\to\file` or `C:/path/to/file` +/// - Windows drive-relative: `C:relative` (references specific drive) +/// - UNC paths: `\\server\share\file` +/// - Long UNC paths: `\\?\UNC\server\share\file` +/// - Long path prefix: `\\?\C:\very\long\path` +/// +/// Returns false for relative paths like `file.txt` or `subfolder/file.txt`. +#[cfg_attr(feature = "__test", visibility::make(pub))] +pub(crate) fn is_absolute_path(filename: &str) -> bool { + // Unix absolute path (including root) + if filename.starts_with('/') { + return true; + } + + // Windows long path prefix: \\?\ or \\.\ + // Catches: \\?\C:\path, \\?\UNC\server\share, \\.\device + if filename.starts_with("\\\\?\\") || filename.starts_with("\\\\.\\") { + return true; + } + + // UNC path: \\server\share or //server/share + if filename.starts_with("\\\\") || filename.starts_with("//") { + return true; + } + + // Windows absolute path: C:\ or C:/ + // Also Windows drive-relative: C:relative (no separator after colon) + // Both reveal drive information and should be blocked + if filename.len() >= 2 { + let mut chars = filename.chars(); + if let (Some(first), Some(second)) = (chars.next(), chars.next()) { + if first.is_ascii_alphabetic() && second == ':' { + return true; } - ClipboardError::FormatListRejected => write!(f, "sent format list was rejected"), } } + + false } -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -enum CliprdrState { - Initialization, - Ready, - Failed, +/// Checks whether a filename is a Windows reserved device name. +/// +/// On Windows, creating a file named `CON`, `PRN`, `AUX`, `NUL`, +/// `COM1`-`COM9`, or `LPT1`-`LPT9` opens the corresponding device +/// instead of a regular file. This is true even with an extension +/// (e.g. `CON.txt` opens the console device). +/// +/// The check is case-insensitive and also matches names with extensions +/// (the stem before the first `.` is checked). +/// +/// Backends that write received files to disk on Windows should call +/// this function and reject matching names in their +/// [`CliprdrBackend::on_remote_file_list`](crate::backend::CliprdrBackend::on_remote_file_list) +/// implementation. +/// +/// # Example +/// +/// ``` +/// use ironrdp_cliprdr::is_windows_device_name; +/// +/// assert!(is_windows_device_name("CON")); +/// assert!(is_windows_device_name("con.txt")); +/// assert!(is_windows_device_name("NUL")); +/// assert!(is_windows_device_name("LPT1.doc")); +/// assert!(!is_windows_device_name("document.txt")); +/// assert!(!is_windows_device_name("console.log")); +/// ``` +pub fn is_windows_device_name(filename: &str) -> bool { + // Extract the stem (part before the first dot) for comparison. + // "CON.txt" -> "CON", "NUL" -> "NUL" + let stem = filename.split('.').next().unwrap_or(""); + + const DEVICE_NAMES: &[&str] = &[ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", + "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + ]; + + DEVICE_NAMES.iter().any(|name| stem.eq_ignore_ascii_case(name)) } +/// Result of sanitizing a file path from a remote peer. +#[cfg_attr(feature = "__test", visibility::make(pub))] +pub(crate) struct SanitizedPath { + /// The file basename (e.g., `"file.txt"`). + pub name: String, + /// The relative directory path (e.g., `"temp\\subdir"`), or `None` for + /// root-level files. Uses `\` as the separator to match the Windows wire + /// convention. + pub relative_path: Option, +} + +/// Sanitizes a file path received from a remote peer. +/// +/// A malicious remote could send paths containing traversal sequences +/// (e.g. `../../../etc/cron.d/backdoor`) or absolute paths. This function: +/// +/// 1. Strips absolute path prefixes (drive letters, UNC, `/`-rooted) +/// 2. Removes `.` and `..` traversal components +/// 3. Preserves safe relative directory components +/// 4. Extracts the basename +/// +/// Returns `None` if the path is empty or consists entirely of path +/// separators, traversal components, or null bytes. +/// +/// # Examples +/// +/// - `"temp\\file.txt"` -> `SanitizedPath { name: "file.txt", relative_path: Some("temp") }` +/// - `"folder\\sub\\file.txt"` -> `SanitizedPath { name: "file.txt", relative_path: Some("folder\\sub") }` +/// - `"C:\\Users\\victim\\Desktop\\file.txt"` -> `SanitizedPath { name: "file.txt", relative_path: Some("Users\\victim\\Desktop") }` +/// - `"../../../etc/passwd"` -> `SanitizedPath { name: "passwd", relative_path: Some("etc") }` +/// - `"file.txt"` -> `SanitizedPath { name: "file.txt", relative_path: None }` +/// +/// # Limitations +/// +/// This function only splits on ASCII path separators (`/` U+002F and `\` +/// U+005C). Unicode look-alikes such as fullwidth solidus (U+FF0F), +/// fullwidth reverse solidus (U+FF3C), or division slash (U+2215) are +/// **not** treated as separators. Some operating systems may normalize +/// these characters to their ASCII equivalents when creating files. +/// +/// Windows reserved device names (`CON`, `PRN`, `AUX`, `NUL`, +/// `COM1`-`COM9`, `LPT1`-`LPT9`) are **not** rejected by this function. +/// On Windows, creating a file with one of these names accesses the +/// corresponding device rather than a regular file. Backends that write +/// files to disk on Windows **must** check for and reject these names +/// before creating files. The [`is_windows_device_name`] helper can be +/// used for this check. +#[cfg_attr(feature = "__test", visibility::make(pub))] +pub(crate) fn sanitize_file_path(filename: &str) -> Option { + // Strip trailing null bytes: CLIPRDR file descriptors use null-terminated + // strings padded with nulls, so the filename may contain trailing \0 chars. + let filename = filename.trim_end_matches('\0'); + + // Reject filenames with embedded null bytes. These have no legitimate use + // and could cause truncation when passed to C-based filesystem APIs (the + // OS would treat \0 as a string terminator, silently shortening the name). + if filename.contains('\0') { + return None; + } + + // Fast path: no separators (the common case for flat file lists). + // Avoids Vec allocation and join when the name has no path components. + if !filename.contains('/') && !filename.contains('\\') { + if filename.is_empty() || filename == "." || filename == ".." { + return None; + } + return Some(SanitizedPath { + name: filename.to_owned(), + relative_path: None, + }); + } + + // Split on both Windows and Unix path separators, filter out empty + // components and traversal sequences, keeping only safe components. + let safe_components: Vec<&str> = filename + .split(['/', '\\']) + .filter(|c| !c.is_empty() && *c != "." && *c != "..") + .collect(); + + if safe_components.is_empty() { + return None; + } + + // Strip absolute path prefix: if the first component looks like a drive + // letter (e.g., "C:"), a UNC host, or a Windows long-path prefix, discard + // all leading components that are part of the absolute prefix. + let components = strip_absolute_prefix(&safe_components); + + if components.is_empty() { + return None; + } + + // Last component is the basename; everything before is the relative path. + let (dir_parts, basename) = components.split_at(components.len() - 1); + let name = (*basename.first()?).to_owned(); + + // Reject if the basename is empty or a traversal component (should not + // happen after filtering, but defense in depth) + if name.is_empty() || name == "." || name == ".." { + return None; + } + + let relative_path = if dir_parts.is_empty() { + None + } else { + Some(dir_parts.join("\\")) + }; + + Some(SanitizedPath { name, relative_path }) +} + +/// Strips absolute path prefixes from the component list. +/// +/// Handles: +/// - Windows drive letters: `["C:", "Users", ...]` -> `["Users", ...]` +/// - UNC paths: `["server", "share", ...]` after split -> keep from share onward +/// - Long path prefixes: `["?", "C:", ...]` or `[".", "device", ...]` +/// +/// If the original path was absolute, we strip the prefix and return the +/// remaining relative portion. If it was already relative, returns as-is. +/// +/// Returns a sub-slice of the input to avoid allocation. +fn strip_absolute_prefix<'a>(components: &'a [&str]) -> &'a [&'a str] { + if components.is_empty() { + return &[]; + } + + let first = components[0]; + + // Check for Windows drive letter prefix (e.g., "C:") + if first.len() == 2 && first.as_bytes()[0].is_ascii_alphabetic() && first.as_bytes()[1] == b':' { + // Absolute path like C:\Users\...; drop the drive letter. + return &components[1..]; + } + + // Detect "?" or "." as first component which indicates \\?\ or \\.\ + // long path prefixes. + if first == "?" || (first == "." && components.len() > 1) { + // Long path prefix: \\?\C:\path or \\.\device\path + // Skip the prefix marker and any following drive letter. + let rest = &components[1..]; + if let Some(second) = rest.first() { + if second.len() == 2 && second.as_bytes()[0].is_ascii_alphabetic() && second.as_bytes()[1] == b':' { + return &rest[1..]; + } + } + return rest; + } + + // No absolute prefix detected; return as-is. + components +} + +/// Marker trait distinguishing client and server roles for the CLIPRDR channel. +/// +/// The clipboard channel has symmetric message processing with role-specific +/// differences during initialization: the server transitions to Ready on +/// receiving the first Format List, while the client transitions on receiving +/// Format List Response (per [MS-RDPECLIP] 1.3.2.1). pub trait Role: core::fmt::Debug + Send + 'static { + /// Returns `true` if this role is the server side of the CLIPRDR channel. fn is_server() -> bool; } +/// Maximum number of incoming clipboard locks (remote lock requests) to track. +/// +/// This limit prevents malicious remotes from exhausting memory by sending +/// unlimited Lock PDUs without corresponding Unlock PDUs. +/// +/// Per MS-RDPECLIP 3.1.5.3.2, clipboard changes should auto-unlock, but +/// malicious or buggy implementations may not comply. This limit provides +/// defense-in-depth protection. +const MAX_LOCKED_FILE_LISTS: usize = 100; + +/// Maximum number of outgoing clipboard locks we will create. +/// +/// Unlike incoming locks, outgoing locks are created by the local side +/// (one per file download). This limit prevents unbounded +/// growth of `outgoing_locks` if the application repeatedly creates locks +/// without unlocking them. +const MAX_OUTGOING_LOCKS: usize = 100; + +/// Maximum number of pending (unanswered) file contents requests. +/// +/// Each call to [`Cliprdr::request_file_contents`] inserts an entry that is +/// removed when the corresponding [`FileContentsResponse`] arrives. This limit +/// prevents unbounded growth if responses are never received. +const MAX_PENDING_FILE_REQUESTS: usize = 1000; + /// CLIPRDR static virtual channel endpoint implementation #[derive(Debug)] pub struct Cliprdr { backend: Box, capabilities: Capabilities, state: CliprdrState, + + /// Tracks the format ID of the most recently sent FormatDataRequest. + /// Used to correlate FormatDataResponse with the request that produced it, + /// so we only intercept responses for the file list format and forward all + /// others to the backend. + pending_format_data_request: Option, + + /// Stores the local file list when initiating a file copy operation. + /// Set by initiate_file_copy(), used to respond to FormatDataRequest. + local_file_list: Option, + + /// Format ID used for local FileGroupDescriptorW in the FormatList we sent. + /// Tracked so we can recognize FormatDataRequest for our file list. + local_file_list_format_id: Option, + + /// Format ID used for the local "Preferred DropEffect" entry in the + /// FormatList sent alongside FileGroupDescriptorW from + /// [`Cliprdr::initiate_file_copy`]. Tracked so we can recognize a + /// FormatDataRequest for it and respond inline with `DROPEFFECT_COPY` + /// (0x00000001) — backends don't have to know about the format. + local_drop_effect_format_id: Option, + + /// Stores the remote file list after receiving it via FormatDataResponse. + /// Used for validating FileContentsRequest.lindex bounds. + remote_file_list: Option, + + /// Format ID used by remote for FileGroupDescriptorW in FormatList they sent. + /// Detected by finding format with name "FileGroupDescriptorW". + remote_file_list_format_id: Option, + + /// [MS-RDPECLIP] 2.2.5.3 - Tracks FileContentsRequest PDUs we've sent (client → server downloads) + /// Maps streamId → FileTransferState to validate incoming FileContentsResponse PDUs. + /// Supports concurrent transfers with different streamIds. + sent_file_contents_requests: HashMap, + + /// [MS-RDPECLIP] 2.2.4 - Outgoing clipboard lock tracking + /// Maps clipDataId → OutgoingLock for all active locks we've sent to remote. + /// When we lock clipboard for file download, we generate a clipDataId and store lock state here. + /// This enables multiple concurrent file downloads, each with independent clipDataId. + outgoing_locks: HashMap, + + /// The most recently created lock's clipDataId. + /// Used as the default clipDataId for new FileContentsRequest calls. + /// Set when a lock is created on FormatList, cleared when last lock is removed. + current_lock_id: Option, + + /// Counter for generating unique clipDataId values for lock operations. + /// Incremented each time a new lock is needed. Zero is avoided per convention. + next_clip_data_id: u32, + + /// Timeout for inactive expired locks (no FileContentsRequest) + /// Default: 60 seconds + lock_inactivity_timeout: core::time::Duration, + + /// Maximum lifetime for expired locks regardless of activity + /// Prevents resource leaks from stalled transfers + /// Default: 2 hours + lock_max_lifetime: core::time::Duration, + + /// [MS-RDPECLIP] 3.1.5.3.2 - Locked file list snapshots (incoming from remote) + /// Maps clipDataId -> PackedFileList for servicing FileContentsRequest with clipDataId. + /// When a Lock PDU is received, we snapshot the current local_file_list and store it here. + /// This allows us to serve file requests even after the clipboard changes. + locked_file_lists: HashMap, + + /// Tracks last FileContentsRequest activity per locked file list. + /// Maps clipDataId -> last activity timestamp (ms from backend clock). + /// Initialized when a Lock PDU is received; updated on each incoming + /// FileContentsRequest that references the clipDataId. Used by the + /// cleanup sweep to detect abandoned uploads. + locked_file_list_activity: HashMap, + + /// Timeout for individual file contents requests. + /// Requests older than this are removed and the backend receives a + /// synthetic error response. Default: 60 seconds. + transfer_timeout: core::time::Duration, + _marker: core::marker::PhantomData, } @@ -85,21 +470,61 @@ impl AsAny for Cliprdr { } } -macro_rules! ready_guard { - ($self:ident, $function:ident) => {{ - let _ = Self::$function; // ensure the function actually exists - - if $self.state != CliprdrState::Ready { - error!(?$self.state, concat!("Attempted to initiate ", stringify!($function), " in incorrect state")); - return Ok(Vec::new().into()); - } - }}; - } - impl Cliprdr { const CHANNEL_NAME: ChannelName = ChannelName::from_static(b"cliprdr\0"); + /// Creates new CLIPRDR processor with default timeout policy + /// + /// Defaults: + /// - Inactivity timeout: 60 seconds (cleanup expired locks with no FileContentsRequest activity) + /// - Max lifetime: 2 hours (force cleanup regardless of activity) + /// - Transfer timeout: 60 seconds (individual file contents request timeout) + /// + /// Callers must drive [`Self::drive_timeouts()`] from a periodic timer (e.g., every 5 seconds) + /// to process expired locks and stale transfers. pub fn new(backend: Box) -> Self { + Self::with_all_config( + backend, + core::time::Duration::from_secs(60), // inactivity_timeout + core::time::Duration::from_secs(2 * 3600), // max_lifetime (2 hours) + core::time::Duration::from_secs(60), // transfer_timeout + ) + } + + /// Creates new CLIPRDR processor with custom lock timeout policy + /// + /// ## Parameters + /// - `inactivity_timeout`: Cleanup expired locks with no FileContentsRequest for this duration + /// - `max_lifetime`: Force cleanup after this duration regardless of activity + /// + /// Callers must drive [`Self::drive_timeouts()`] from a periodic timer (e.g., every 5 seconds). + pub fn with_lock_timeouts( + backend: Box, + inactivity_timeout: core::time::Duration, + max_lifetime: core::time::Duration, + ) -> Self { + Self::with_all_config( + backend, + inactivity_timeout, + max_lifetime, + core::time::Duration::from_secs(60), // transfer_timeout + ) + } + + /// Creates new CLIPRDR processor with full configuration control + /// + /// ## Parameters + /// - `inactivity_timeout`: Cleanup expired locks with no FileContentsRequest for this duration + /// - `max_lifetime`: Force cleanup after this duration regardless of activity + /// - `transfer_timeout`: Timeout for individual file contents requests and upload inactivity + /// + /// Callers must drive [`Self::drive_timeouts()`] from a periodic timer (e.g., every 5 seconds). + pub fn with_all_config( + backend: Box, + inactivity_timeout: core::time::Duration, + max_lifetime: core::time::Duration, + transfer_timeout: core::time::Duration, + ) -> Self { // This CLIPRDR implementation supports long format names by default let flags = ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES | backend.client_capabilities(); @@ -107,6 +532,21 @@ impl Cliprdr { backend, state: CliprdrState::Initialization, capabilities: Capabilities::new(ClipboardProtocolVersion::V2, flags), + pending_format_data_request: None, + local_file_list: None, + local_file_list_format_id: None, + local_drop_effect_format_id: None, + remote_file_list: None, + remote_file_list_format_id: None, + sent_file_contents_requests: HashMap::new(), + outgoing_locks: HashMap::new(), + current_lock_id: None, + next_clip_data_id: 1, // Start at 1, avoiding 0 + lock_inactivity_timeout: inactivity_timeout, + lock_max_lifetime: max_lifetime, + locked_file_lists: HashMap::new(), + locked_file_list_activity: HashMap::new(), + transfer_timeout, _marker: core::marker::PhantomData, } } @@ -129,13 +569,17 @@ impl Cliprdr { FormatList::new_unicode(formats, self.are_long_format_names_enabled()) } - fn handle_error_transition(&mut self, err: ClipboardError) -> PduResult> { - // Failure of clipboard is not an critical error, but we should properly report it - // and transition channel to failed state. - self.state = CliprdrState::Failed; - error!("CLIPRDR(clipboard) failed: {err}"); - - Ok(Vec::new()) + /// Returns an error if the clipboard channel is not in Ready state. + fn require_ready(&self, method: &'static str) -> PduResult<()> { + if self.state != CliprdrState::Ready { + return Err(ironrdp_pdu::PduError::new( + method, + ironrdp_pdu::PduErrorKind::Other { + description: "clipboard channel is not in Ready state", + }, + )); + } + Ok(()) } fn handle_server_capabilities(&mut self, server_capabilities: Capabilities) -> PduResult> { @@ -148,8 +592,25 @@ impl Cliprdr { } fn handle_monitor_ready(&mut self) -> PduResult> { - // Request client to sent list of initially available formats and wait for the backend - // response. + // [MS-RDPECLIP] 3.2.5.1 - Initialization Sequence (client side) + // + // The spec requires the client to send its Capabilities PDU after + // receiving Monitor Ready. We achieve this by asking the backend + // for its initial format list here. The backend responds + // asynchronously (e.g. via ClipboardMessage::SendInitiateCopy), + // which eventually calls `initiate_copy()`. In Initialization + // state, `initiate_copy()` bundles Capabilities + Temporary + // Directory + Format List into a single batch. + // + // INVARIANT (ordering): `handle_server_capabilities()` runs + // synchronously during `process()` for the Capabilities PDU, + // which always precedes the Monitor Ready PDU in the server's + // initialization sequence. By the time any backend callback + // triggers `initiate_copy()`, `self.capabilities` has already + // been downgraded against the server's capabilities. This holds + // regardless of whether the backend responds synchronously or + // asynchronously, because `process()` for the Capabilities PDU + // completes before `process()` for Monitor Ready begins. self.backend.on_request_format_list(); Ok(Vec::new()) } @@ -159,18 +620,42 @@ impl Cliprdr { FormatListResponse::Ok => { if !R::is_server() { if self.state == CliprdrState::Initialization { - info!("CLIPRDR(clipboard) virtual channel has been initialized"); + debug!("Clipboard virtual channel initialized"); self.state = CliprdrState::Ready; self.backend.on_ready(); } else { - info!("CLIPRDR(clipboard) Remote has received format list successfully"); + trace!("Remote accepted format list"); } } - - self.backend.on_format_list_received(); + self.backend.on_format_list_response(true); } FormatListResponse::Fail => { - return self.handle_error_transition(ClipboardError::FormatListRejected); + // [MS-RDPECLIP] 3.1.5.2.4 - The remote rejected our FormatList but the + // channel remains operational. Clear local state so we don't serve stale + // data, but stay in Ready state to allow subsequent clipboard operations. + warn!("Remote rejected our format list, clearing local clipboard state"); + + self.local_file_list = None; + self.local_file_list_format_id = None; + self.local_drop_effect_format_id = None; + + if !self.sent_file_contents_requests.is_empty() { + debug!( + count = self.sent_file_contents_requests.len(), + "Clearing pending file contents requests due to FormatListResponse::Fail" + ); + + // Notify backend for each pending request so it can clean up + // (e.g. reject pending download promises in WASM). + let stream_ids: Vec = self.sent_file_contents_requests.keys().copied().collect(); + for stream_id in stream_ids { + self.backend + .on_file_contents_response(FileContentsResponse::new_error(stream_id)); + } + + self.sent_file_contents_requests.clear(); + } + self.backend.on_format_list_response(false); } } @@ -179,28 +664,114 @@ impl Cliprdr { fn handle_format_list(&mut self, format_list: FormatList<'_>) -> PduResult> { if R::is_server() && self.state == CliprdrState::Initialization { - info!("CLIPRDR(clipboard) virtual channel has been initialized"); + debug!("Clipboard virtual channel initialized"); self.state = CliprdrState::Ready; self.backend.on_ready(); } + // Clear any previous remote clipboard state since new content is available + self.remote_file_list = None; + self.remote_file_list_format_id = None; + self.pending_format_data_request = None; + + // [MS-RDPECLIP] 2.2.4.2 - Expire locks when clipboard changes + // Locks enter grace period with activity-based timeout + if !self.outgoing_locks.is_empty() { + let active_locks_count = self + .outgoing_locks + .values() + .filter(|lock| matches!(lock.state, LockState::Active)) + .count(); + if active_locks_count > 0 { + debug!( + count = active_locks_count, + "Expiring active locks due to clipboard change (new FormatList received)" + ); + } + } + // Transition active locks to Expired — but do NOT send Unlock PDUs yet. + // Active downloads from the previous clipboard may still be using these locks. + // The cleanup timer will send Unlock only after inactivity timeout. + self.expire_all_locks(); + let mut messages = Vec::new(); + + // Do NOT clear sent_file_contents_requests here. + // + // Per [MS-RDPECLIP] 2.2.4.1 and 3.1.5.3.2, clipboard locks ensure that + // file stream data is retained by the server even after the clipboard + // changes. The spec says: "The purpose of this PDU is to request that + // the Shared Clipboard Owner retain all File Stream data [...] even when + // the Shared Owner clipboard has changed and the File Stream data is no + // longer available." + // + // expire_all_locks() above correctly transitions locks to Expired with a + // grace period so in-flight downloads can finish. But if we cleared the + // request tracking here, valid FileContentsResponse PDUs from the server + // (serviced from locked data) would be dropped as "unknown streamId", + // silently breaking downloads that should succeed. + // + // Each entry is removed individually when its response arrives (line that + // calls sent_file_contents_requests.remove(&stream_id) in the + // FileContentsResponse handler). + // + // Note: FormatListResponse::Fail also clears sent_file_contents_requests + // because the remote rejected our clipboard and no further responses for + // those requests will arrive. + let formats = format_list.get_formats(self.are_long_format_names_enabled())?; + + // Notify backend of available formats self.backend.on_remote_copy(&formats); - let pdu = ClipboardPdu::FormatListResponse(FormatListResponse::Ok); + // [MS-RDPECLIP] 1.3.1.2 - Detect if FormatList contains FileGroupDescriptorW by name. + // [MS-RDPECLIP] 1.3.2.2.3 - "The Local Clipboard Owner first requests the list of files + // available from the clipboard." The word "first" refers to ordering within the paste + // sequence itself (file list before file contents), NOT immediately after FormatList receipt. + // Per spec section 1.3.1.4 "Delayed Rendering", the file list is requested only when the + // user initiates a paste operation. The format ID is stored and the file list is requested + // only when the paste operation occurs. + let file_list_format = formats.iter().find(|fmt| { + fmt.name + .as_ref() + .map(|n| n.value() == ClipboardFormatName::FILE_LIST.value()) + .unwrap_or(false) + }); + + if let Some(format) = file_list_format { + // Store the format ID for later use when user initiates paste + self.remote_file_list_format_id = Some(format.id); + trace!(format_id = ?format.id, "FileGroupDescriptorW format available in FormatList"); + } + + // [MS-RDPECLIP] 3.1.5.2.2 - Acknowledge the FormatList before any + // further PDUs. The FormatListResponse logically completes the copy + // sequence; sending Lock before it is technically permitted by the + // spec but unusual. FreeRDP sends FormatListResponse first as well. + messages.push(into_cliprdr_message(ClipboardPdu::FormatListResponse( + FormatListResponse::Ok, + ))); + + // [MS-RDPECLIP] 2.2.4.1 / Figure 3 - Automatically lock remote clipboard + // when file data is detected. Sent after FormatListResponse to complete + // the copy sequence first. + if file_list_format.is_some() { + if let Some(lock_messages) = self.send_lock() { + messages.extend(lock_messages); + } + } - Ok(vec![into_cliprdr_message(pdu)]) + Ok(messages) } /// Submits the format data response, returning a [`CliprdrSvcMessages`] to send on the channel. /// /// Should be called by the clipboard implementation when it receives data from the OS clipboard - /// and is ready to sent it to the server. This should happen after + /// and is ready to send it to the server. This should happen after /// [`CliprdrBackend::on_format_data_request`] is called by [`Cliprdr`]. /// /// If data is not available anymore, an error response should be sent instead. pub fn submit_format_data(&self, response: OwnedFormatDataResponse) -> PduResult> { - ready_guard!(self, submit_format_data); + self.require_ready("submit_format_data")?; let pdu = ClipboardPdu::FormatDataResponse(response); @@ -209,13 +780,13 @@ impl Cliprdr { /// Submits the file contents response, returning a [`CliprdrSvcMessages`] to send on the channel. /// - /// Should be called by the clipboard implementation when file data is ready to sent it to the + /// Should be called by the clipboard implementation when file data is ready to send it to the /// server. This should happen after [`CliprdrBackend::on_file_contents_request`] is called /// by [`Cliprdr`]. /// /// If data is not available anymore, an error response should be sent instead. pub fn submit_file_contents(&self, response: FileContentsResponse<'static>) -> PduResult> { - ready_guard!(self, submit_file_contents); + self.require_ready("submit_file_contents")?; let pdu = ClipboardPdu::FileContentsResponse(response); @@ -237,49 +808,786 @@ impl Cliprdr { /// Starts processing of `CLIPRDR` copy command. Should be called by the clipboard /// implementation when user performs OS-specific copy command (e.g. `Ctrl+C` shortcut on /// keyboard) - pub fn initiate_copy(&self, available_formats: &[ClipboardFormat]) -> PduResult> { + /// + /// Note: For file copies, use `initiate_file_copy()` instead. + /// + /// Takes `&mut self` because it manages `local_file_list` state on each copy, + /// not just PDU encoding. + pub fn initiate_copy(&mut self, available_formats: &[ClipboardFormat]) -> PduResult> { + // Per [MS-RDPECLIP] 3.1.1.1, each FormatList completely replaces the previous. + // A text/image copy ends file visibility to the remote, which may interrupt an + // in-progress file download - acceptable since the user explicitly chose new content. + self.local_file_list = None; + self.local_file_list_format_id = None; + self.local_drop_effect_format_id = None; + let mut pdus = Vec::new(); - match (self.state, R::is_server()) { - // When user initiates copy, we should send format list to server. - (CliprdrState::Ready, _) => { - pdus.push(ClipboardPdu::FormatList( - self.build_format_list(available_formats).map_err(|e| encode_err!(e))?, - )); - } - (CliprdrState::Initialization, false) => { - // During initialization state, first copy action is synthetic and should be sent along with - // capabilities and temporary directory PDUs. - pdus.push(ClipboardPdu::Capabilities(self.capabilities.clone())); - pdus.push(ClipboardPdu::TemporaryDirectory( - ClientTemporaryDirectory::new(self.backend.temporary_directory()).map_err(|e| encode_err!(e))?, - )); - pdus.push(ClipboardPdu::FormatList( - self.build_format_list(available_formats).map_err(|e| encode_err!(e))?, - )); - } - _ => { - error!(?self.state, "Attempted to initiate copy in incorrect state"); + if R::is_server() { + pdus.push(ClipboardPdu::FormatList( + self.build_format_list(available_formats).map_err(|e| encode_err!(e))?, + )); + } else { + match self.state { + CliprdrState::Ready => { + trace!("User initiated copy, sending format list"); + pdus.push(ClipboardPdu::FormatList( + self.build_format_list(available_formats).map_err(|e| encode_err!(e))?, + )); + } + CliprdrState::Initialization => { + // During initialization state, first copy action is synthetic and should be sent along with + // capabilities and temporary directory PDUs. + pdus.push(ClipboardPdu::Capabilities(self.capabilities.clone())); + pdus.push(ClipboardPdu::TemporaryDirectory( + ClientTemporaryDirectory::new(self.backend.temporary_directory()) + .map_err(|e| encode_err!(e))?, + )); + pdus.push(ClipboardPdu::FormatList( + self.build_format_list(available_formats).map_err(|e| encode_err!(e))?, + )); + } } } Ok(pdus.into_iter().map(into_cliprdr_message).collect::>().into()) } - /// Starts processing of `CLIPRDR` paste command. Should be called by the clipboard - /// implementation when user performs OS-specific paste command (e.g. `Ctrl+V` shortcut on - /// keyboard) - pub fn initiate_paste(&self, requested_format: ClipboardFormatId) -> PduResult> { - ready_guard!(self, initiate_paste); + /// Takes `&mut self` because it tracks `pending_format_data_request` for response correlation. + pub fn initiate_paste(&mut self, requested_format: ClipboardFormatId) -> PduResult> { + self.require_ready("initiate_paste")?; - // When user initiates paste, we should send format data request to server, and expect to + // When user initiates paste, send format data request to server, and expect to // receive response with contents via `FormatDataResponse` PDU. + // Track the format so we can correlate the response correctly. + self.pending_format_data_request = Some(requested_format); + + if Some(requested_format) == self.remote_file_list_format_id { + trace!(format_id = ?requested_format, "User initiated paste for FileGroupDescriptorW"); + } + let pdu = ClipboardPdu::FormatDataRequest(FormatDataRequest { format: requested_format, }); Ok(vec![into_cliprdr_message(pdu)].into()) } + + /// Generates the next unique clip_data_id for lock operations. + /// + /// Per [MS-RDPECLIP] 3.1.5.3.1, the clipDataId must uniquely identify + /// File Stream data on the clipboard. This method ensures unique IDs by + /// incrementing a counter, skipping 0 and any IDs that collide with + /// still-active outgoing locks (only possible after u32 wraparound). + /// + /// INVARIANT: this loop terminates because `outgoing_locks.len() <= + /// MAX_OUTGOING_LOCKS` (100), enforced by the caller `send_lock`. + /// At most 101 iterations are needed to find + /// an unused non-zero ID. + #[cfg_attr(feature = "__test", visibility::make(pub))] + fn generate_clip_data_id(&mut self) -> u32 { + debug_assert!( + self.outgoing_locks.len() <= MAX_OUTGOING_LOCKS, + "outgoing_locks exceeds MAX_OUTGOING_LOCKS; loop may not terminate" + ); + + // Bounded loop converts a potential infinite hang into a visible panic + // if the caller invariant (outgoing_locks.len() <= MAX_OUTGOING_LOCKS) + // is ever violated in release builds. + for _ in 0..MAX_OUTGOING_LOCKS + 2 { + let id = self.next_clip_data_id; + self.next_clip_data_id = self.next_clip_data_id.wrapping_add(1); + if self.next_clip_data_id == 0 { + self.next_clip_data_id = 1; + } + if id != 0 && !self.outgoing_locks.contains_key(&id) { + return id; + } + } + + unreachable!( + "no free clip_data_id within {MAX_OUTGOING_LOCKS} + 2 iterations; \ + caller failed to enforce MAX_OUTGOING_LOCKS" + ) + } + + /// Sends a Lock PDU when file data is detected in a Format List. + /// + /// Called internally when files are detected in a Format List from the remote. + /// Returns None if locking capability is not negotiated or channel is not in Ready state. + /// Follows the sequence shown in [MS-RDPECLIP] section 1.3.2.3 Figure 3. + fn send_lock(&mut self) -> Option> { + // Must be in Ready state + if self.state != CliprdrState::Ready { + return None; + } + + // Check if locking capability is supported + if !self + .capabilities + .flags() + .contains(ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA) + { + return None; + } + + // Defense-in-depth: limit outgoing locks + if MAX_OUTGOING_LOCKS <= self.outgoing_locks.len() { + warn!( + current_locks = self.outgoing_locks.len(), + max = MAX_OUTGOING_LOCKS, + "Too many outgoing locks, skipping automatic lock" + ); + return None; + } + + // Detach the previous lock but keep it in outgoing_locks. + // It may still be protecting active file downloads. expire_all_locks() + // (called from handle_format_list) will transition it to Expired, and + // cleanup_expired_locks will send the Unlock PDU once the lock becomes + // inactive. We must NOT send Unlock immediately — that could abort + // concurrent downloads from the previous clipboard. + if let Some(prev_lock_id) = self.current_lock_id.take() { + // The lock remains in outgoing_locks; expire_all_locks() handles it + debug!( + clip_data_id = prev_lock_id, + "Detached previous lock (kept for active transfers)" + ); + } + + // Generate unique ID for this lock + let clip_data_id = self.generate_clip_data_id(); + + let now = self.backend.now_ms(); + + // Create a minimal lock entry (no file_list needed for remote clipboard) + // The file list is on the remote side; we're just reserving it + let lock = OutgoingLock { + state: LockState::Active, + created_at_ms: now, + last_used_at_ms: now, + }; + + // Store in outgoing locks map + self.outgoing_locks.insert(clip_data_id, lock); + self.current_lock_id = Some(clip_data_id); + + trace!(clip_data_id, "Sent clipboard lock"); + + let pdu = ClipboardPdu::LockData(LockDataId(clip_data_id)); + Some(vec![into_cliprdr_message(pdu)]) + } + + /// Transitions all active locks to expired state when clipboard changes. + /// + /// Called when FormatList arrives (clipboard content changed). + /// Locks enter a grace period and will be cleaned up based on activity. + /// + /// # Concurrent lock safety + /// + /// Multiple locks may be active simultaneously — each protecting a separate + /// set of file downloads. When the remote clipboard changes, we must NOT + /// immediately send Unlock PDUs because that would abort in-flight downloads + /// from the previous clipboard. Instead, locks transition to `Expired` state + /// and remain in `outgoing_locks`. The two-tier cleanup + /// ([`cleanup_expired_locks`]) sends Unlock PDUs only when a lock has been + /// inactive for `lock_inactivity_timeout` (no `FileContentsRequest` activity) + /// or exceeds `lock_max_lifetime`. + #[cfg_attr(feature = "__test", visibility::make(pub))] + fn expire_all_locks(&mut self) { + if self.outgoing_locks.is_empty() { + return; + } + + let now = self.backend.now_ms(); + let mut newly_expired = Vec::new(); + + // Transition all Active locks to Expired + for (&id, lock) in self.outgoing_locks.iter_mut() { + if matches!(lock.state, LockState::Active) { + lock.state = LockState::Expired { expired_at_ms: now }; + newly_expired.push(LockDataId(id)); + } + } + + // Clear lock IDs when clipboard changes so new requests don't + // attach an expired lock's clipDataId to new clipboard content. + self.current_lock_id = None; + + if !newly_expired.is_empty() { + debug!( + count = newly_expired.len(), + inactivity_timeout_secs = self.lock_inactivity_timeout.as_secs(), + max_lifetime_secs = self.lock_max_lifetime.as_secs(), + "Expiring locks due to clipboard change" + ); + self.backend.on_outgoing_locks_expired(&newly_expired); + } + + // Backend notification deferred until actual cleanup + } + + /// Immediately sends `Unlock` PDUs for — and drops — every outgoing clipboard lock. + /// + /// Outgoing locks are created when we download files from the remote: each asks the + /// Shared Clipboard Owner to retain File Stream data so we can keep pulling it even + /// after the clipboard changes ([MS-RDPECLIP] 2.2.4.1). They are normally released + /// lazily by the inactivity sweep in [`Self::drive_timeouts`], so concurrent downloads + /// that outlive a *remote* clipboard change aren't aborted. + /// + /// When the **local** side takes clipboard ownership itself (initiating a file copy), + /// those locks point at data we are replacing. Leaving them held while we advertise a + /// fresh `FormatList` makes the server track a lock for a download that will never + /// finish; some servers (notably Windows `rdpclip.exe`) react badly to that overlap. + /// Releasing them up front keeps the lock/ownership state consistent. + /// + /// Returns the `Unlock` PDUs to send (these must precede the new `FormatList` on the + /// wire); empty when no locks are held. + fn release_outgoing_locks(&mut self) -> Vec { + if self.outgoing_locks.is_empty() { + return Vec::new(); + } + + let cleared: Vec = self.outgoing_locks.keys().copied().collect(); + self.outgoing_locks.clear(); + self.current_lock_id = None; + + debug!( + count = cleared.len(), + "Releasing outgoing locks before taking clipboard ownership" + ); + + let messages = cleared + .iter() + .map(|id| into_cliprdr_message(ClipboardPdu::UnlockData(LockDataId(*id)))) + .collect(); + + let lock_ids: Vec = cleared.iter().map(|id| LockDataId(*id)).collect(); + self.backend.on_outgoing_locks_cleared(&lock_ids); + + messages + } + + /// Lazily runs periodic cleanup during normal API activity. + /// + /// Cleans up expired locks, stale file contents requests, and inactive + /// locked file list snapshots in a single throttled sweep. + /// + /// Fast paths: + /// Processes expired locks, stale file transfers, and abandoned uploads. + /// + /// This method performs three cleanup sweeps: + /// 1. **Outgoing locks**: Sends Unlock PDUs for locks that have exceeded their inactivity + /// timeout (default 60s) or max lifetime (default 2h) + /// 2. **Stale requests**: Removes file contents requests older than `transfer_timeout` and + /// sends synthetic error responses to the backend + /// 3. **Abandoned uploads**: Cleans up locked file list snapshots with no recent activity + /// + /// Callers must drive this method from a periodic timer in their event loop + /// (e.g., every 5 seconds). The returned PDUs must be sent on the CLIPRDR channel. + /// + /// ## Example + /// ```no_run + /// # use ironrdp_cliprdr::{Cliprdr, CliprdrBackend}; + /// # fn example(cliprdr: &mut Cliprdr) -> Result<(), Box> { + /// // Called from event loop timer arm (e.g., every 5 seconds) + /// let messages = cliprdr.drive_timeouts()?; + /// for msg in messages.into_iter() { + /// // send_on_channel(msg)?; + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn drive_timeouts(&mut self) -> PduResult> { + self.drive_timeouts_impl() + } + + /// Internal implementation of timeout processing. + fn drive_timeouts_impl(&mut self) -> PduResult> { + let now = self.backend.now_ms(); + let inactivity_timeout_ms = u64::try_from(self.lock_inactivity_timeout.as_millis()).unwrap_or(u64::MAX); + let max_lifetime_ms = u64::try_from(self.lock_max_lifetime.as_millis()).unwrap_or(u64::MAX); + let mut messages = Vec::new(); + let mut expired_ids = Vec::new(); + + // Collect locks that should be cleaned up + for (clip_data_id, lock) in &self.outgoing_locks { + if matches!(lock.state, LockState::Expired { .. }) { + let total_lifetime_ms = now.saturating_sub(lock.created_at_ms); + let time_since_activity_ms = now.saturating_sub(lock.last_used_at_ms); + + // Rule 1: Cleanup if inactive for inactivity_timeout (60s) + if inactivity_timeout_ms <= time_since_activity_ms { + debug!( + clip_data_id, + inactive_secs = time_since_activity_ms / 1000, + "Lock inactive, sending Unlock PDU" + ); + expired_ids.push(*clip_data_id); + continue; + } + + // Rule 2: Force cleanup after max_lifetime since creation (2h) + if max_lifetime_ms <= total_lifetime_ms { + warn!( + clip_data_id, + lifetime_secs = total_lifetime_ms / 1000, + "Lock exceeded maximum lifetime, forcing cleanup" + ); + expired_ids.push(*clip_data_id); + continue; + } + + // Lock is expired but still within timeout window + debug!( + clip_data_id, + time_since_activity_secs = time_since_activity_ms / 1000, + total_lifetime_secs = total_lifetime_ms / 1000, + "Expired lock still within timeout window" + ); + } + } + + // Remove and send Unlock for each + for clip_data_id in &expired_ids { + if let Some(_lock) = self.outgoing_locks.remove(clip_data_id) { + debug!(clip_data_id, "Removed expired lock from tracking"); + let pdu = ClipboardPdu::UnlockData(LockDataId(*clip_data_id)); + messages.push(into_cliprdr_message(pdu)); + } + } + + // Log cleanup summary + if !expired_ids.is_empty() { + debug!( + count = expired_ids.len(), + clip_data_ids = ?expired_ids, + "Automatic lock cleanup completed" + ); + } + + // Clear current_lock_id if it was expired + if let Some(current_id) = self.current_lock_id { + if expired_ids.contains(¤t_id) { + self.current_lock_id = None; + } + } + + // Notify backend of timeout-expired locks + if !expired_ids.is_empty() { + let lock_ids: Vec = expired_ids.iter().map(|id| LockDataId(*id)).collect(); + self.backend.on_outgoing_locks_cleared(&lock_ids); + } + + // Cleanup stale file contents requests that have been pending too long. + // Sends a synthetic error response to the backend for each timed-out + // request so callers can clean up rather than waiting forever. + let transfer_timeout_ms = u64::try_from(self.transfer_timeout.as_millis()).unwrap_or(u64::MAX); + let stale_stream_ids: Vec = self + .sent_file_contents_requests + .iter() + .filter(|(_, state)| transfer_timeout_ms <= now.saturating_sub(state.sent_at_ms)) + .map(|(stream_id, _)| *stream_id) + .collect(); + + for stream_id in &stale_stream_ids { + self.sent_file_contents_requests.remove(stream_id); + warn!( + stream_id, + timeout_secs = transfer_timeout_ms / 1000, + "File contents request timed out, sending synthetic error to backend" + ); + self.backend + .on_file_contents_response(FileContentsResponse::new_error(*stream_id)); + } + + if !stale_stream_ids.is_empty() { + debug!( + count = stale_stream_ids.len(), + "Stale file contents request cleanup completed" + ); + } + + // Cleanup locked file list snapshots with no recent FileContentsRequest + // activity. Notifies backend via on_unlock() for each removed entry so + // it can release associated file handles and resources. + let stale_lock_ids: Vec = self + .locked_file_list_activity + .iter() + .filter(|(_, last_activity)| transfer_timeout_ms <= now.saturating_sub(**last_activity)) + .map(|(clip_data_id, _)| *clip_data_id) + .collect(); + + for clip_data_id in &stale_lock_ids { + self.locked_file_lists.remove(clip_data_id); + self.locked_file_list_activity.remove(clip_data_id); + warn!( + clip_data_id, + timeout_secs = transfer_timeout_ms / 1000, + "Locked file list timed out due to upload inactivity, sending unlock to backend" + ); + self.backend.on_unlock(LockDataId(*clip_data_id)); + } + + if !stale_lock_ids.is_empty() { + debug!(count = stale_lock_ids.len(), "Upload inactivity cleanup completed"); + } + + Ok(messages.into()) + } + + /// [2.2.5.3] File Contents Request PDU (CLIPRDR_FILECONTENTS_REQUEST) + /// + /// Requests file contents from the Shared Clipboard Owner. Should be called when + /// the Local Clipboard Owner needs file data after receiving a file list format. + /// The remote will respond via [`CliprdrBackend::on_file_contents_response`]. + /// + /// ## Validation + /// + /// Per [MS-RDPECLIP] 3.1.5.4.5: + /// - The file index (lindex) must be obtained from a prior file list exchange + /// - For SIZE requests: cbRequested must be 8, position must be 0 + /// - For RANGE requests: the specified range must be within file bounds + /// + /// The streamId is tracked to validate the corresponding FileContentsResponse. + /// + /// [2.2.5.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeclip/cbc851d3-4e68-45f4-9292-26872a9209f2 + pub fn request_file_contents(&mut self, mut request: FileContentsRequest) -> PduResult> { + self.require_ready("request_file_contents")?; + + // [MS-RDPECLIP] 2.2.2.1.1.1 - CB_STREAM_FILECLIP_ENABLED must be negotiated + if !self + .capabilities + .flags() + .contains(ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED) + { + return Err(ironrdp_pdu::PduError::new( + "request_file_contents", + ironrdp_pdu::PduErrorKind::Other { + description: "CB_STREAM_FILECLIP_ENABLED not negotiated", + }, + )); + } + + // [MS-RDPECLIP] 2.2.5.3 - Include clipDataId if we have an active lock + // Use the most recently created lock (current_lock_id) by default. + // Caller can override by setting request.data_id explicitly before calling this method. + // This allows the receiver to correlate the request with locked File Stream data + // even if the clipboard has changed since the lock was sent. + if request.data_id.is_none() { + if let Some(clip_data_id) = self.current_lock_id { + request.data_id = Some(clip_data_id); + } else if self + .capabilities + .flags() + .contains(ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA) + { + // Locking was negotiated but no lock is active (e.g. all locks + // expired). The request will proceed without a clipDataId, which + // means the server may serve it from the current (possibly changed) + // clipboard rather than a locked snapshot. + debug!( + stream_id = request.stream_id, + "File contents request proceeding without lock despite CAN_LOCK_CLIPDATA being negotiated" + ); + } + } + + // Update last_used_at to track activity and prevent timeout + if let Some(clip_data_id) = request.data_id { + if let Some(lock) = self.outgoing_locks.get_mut(&clip_data_id) { + lock.last_used_at_ms = self.backend.now_ms(); + trace!( + clip_data_id, + stream_id = request.stream_id, + "Updated lock activity timestamp" + ); + } + } + + // [MS-RDPECLIP] 2.2.5.3 - Validate flags are spec-compliant + if let Err(e) = request.flags.validate() { + return Err(ironrdp_pdu::PduError::new( + "request_file_contents", + ironrdp_pdu::PduErrorKind::Other { description: e }, + )); + } + + // [MS-RDPECLIP] 2.2.5.3 - Validate SIZE request constraints + if request.flags.contains(FileContentsFlags::SIZE) { + if request.requested_size != 8 { + return Err(ironrdp_pdu::PduError::new( + "request_file_contents", + ironrdp_pdu::PduErrorKind::Other { + description: "SIZE request must have requested_size=8", + }, + )); + } + if request.position != 0 { + return Err(ironrdp_pdu::PduError::new( + "request_file_contents", + ironrdp_pdu::PduErrorKind::Other { + description: "SIZE request must have position=0", + }, + )); + } + } + + // [MS-RDPECLIP] 3.1.5.4.5 - Validate file index is from known file list + let validated_file_index = usize::try_from(request.index).map_err(|_| { + ironrdp_pdu::PduError::new( + "request_file_contents", + ironrdp_pdu::PduErrorKind::Other { + description: "file index is negative", + }, + ) + })?; + + if let Some(ref file_list) = self.remote_file_list { + if file_list.files.len() <= validated_file_index { + return Err(ironrdp_pdu::PduError::new( + "request_file_contents", + ironrdp_pdu::PduErrorKind::Other { + description: "file index out of bounds for remote file list", + }, + )); + } + + // [MS-RDPECLIP] 3.1.5.4.5 - Validate RANGE request is within file bounds + if request.flags.contains(FileContentsFlags::RANGE) { + // Validate requested_size > 0 for RANGE requests + if request.requested_size == 0 { + return Err(ironrdp_pdu::PduError::new( + "request_file_contents", + ironrdp_pdu::PduErrorKind::Other { + description: "RANGE request must have requested_size > 0", + }, + )); + } + + if let Some(file_desc) = file_list.files.get(validated_file_index) { + if let Some(file_size) = file_desc.file_size { + let end_position = request.position.saturating_add(u64::from(request.requested_size)); + if file_size < end_position { + return Err(ironrdp_pdu::PduError::new( + "request_file_contents", + ironrdp_pdu::PduErrorKind::Other { + description: "RANGE request exceeds file bounds", + }, + )); + } + } + } + } + + // [MS-RDPECLIP] 2.2.5.3 - Validate huge file position constraints + let supports_huge_files = self + .capabilities + .flags() + .contains(ClipboardGeneralCapabilityFlags::HUGE_FILE_SUPPORT_ENABLED); + + if !supports_huge_files && 0x8000_0000 <= request.position { + // 2^31 + return Err(ironrdp_pdu::PduError::new( + "request_file_contents", + ironrdp_pdu::PduErrorKind::Other { + description: "large file position requires CB_HUGE_FILE_SUPPORT_ENABLED capability", + }, + )); + } + } else { + warn!("FileContentsRequest sent without remote file list"); + // Proceeding anyway - remote may have file list we don't know about + } + + // Reject if too many requests are already pending. + if MAX_PENDING_FILE_REQUESTS <= self.sent_file_contents_requests.len() { + return Err(ironrdp_pdu::PduError::new( + "request_file_contents", + ironrdp_pdu::PduErrorKind::Other { + description: "too many pending file contents requests", + }, + )); + } + + // Track this request so we can validate the response. + if self.sent_file_contents_requests.contains_key(&request.stream_id) { + warn!( + stream_id = request.stream_id, + "Overwriting pending request with same stream_id" + ); + } + self.sent_file_contents_requests.insert( + request.stream_id, + FileTransferState { + file_index: validated_file_index, + flags: request.flags, + sent_at_ms: self.backend.now_ms(), + }, + ); + + debug!( + stream_id = request.stream_id, + index = request.index, + flags = ?request.flags, + "Sending FileContentsRequest" + ); + + let pdu = ClipboardPdu::FileContentsRequest(request); + Ok(vec![into_cliprdr_message(pdu)].into()) + } + + /// [2.2.5.2] CLIPRDR_FILELIST - Initiates file copy operation + /// + /// Starts processing of file copy command with the given file descriptors. + /// Should be called by the clipboard implementation when user performs a file copy + /// operation. This method stores the file list and sends a FormatList PDU containing + /// the FileGroupDescriptorW format. + /// + /// Per [MS-RDPECLIP] 1.3.1.4 "Delayed Rendering", the file list data will only be sent + /// when the remote requests it via FormatDataRequest (after user initiates paste remotely). + /// + /// ## Validation + /// + /// Per [MS-RDPECLIP] 2.2.5.2.3.1, file names are validated against the following constraints: + /// - Maximum length: 259 characters (leaving room for null terminator in 260-character field) + /// - File name must not be empty + /// + /// Invalid file descriptors are logged and skipped; if all descriptors are invalid, + /// an empty file list is sent. + /// + /// [2.2.5.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeclip/9c01c966-e09b-438d-9391-ce31f3caddc3 + pub fn initiate_file_copy(&mut self, files: Vec) -> PduResult> { + self.require_ready("initiate_file_copy")?; + + if !self + .capabilities + .flags() + .contains(ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED) + { + return Err(ironrdp_pdu::PduError::new( + "initiate_file_copy", + ironrdp_pdu::PduErrorKind::Other { + description: "CB_STREAM_FILECLIP_ENABLED not negotiated - server does not support file transfer", + }, + )); + } + + // [MS-RDPECLIP] 2.2.5.2.3.1 - Validate file descriptors per spec requirements + // fileName field is 520 bytes = 260 Unicode characters (including null terminator) + const MAX_FILENAME_LEN: usize = 259; + + let file_clip_no_file_paths = self + .capabilities + .flags() + .contains(ClipboardGeneralCapabilityFlags::FILECLIP_NO_FILE_PATHS); + + let original_count = files.len(); + let validated_files: Vec = files + .into_iter() + .filter(|file| { + // Compute wire name length without allocating: the wire name is + // "relative_path\name" (or just "name" when there is no path). + let wire_len = match &file.relative_path { + Some(path) if !path.is_empty() => { + path.chars().count() + 1 /* backslash */ + file.name.chars().count() + } + _ => file.name.chars().count(), + }; + + // The wire name's absolute-path prefix comes from the first + // component, so check the relative_path (if present) or the + // name directly - no allocation needed. + let wire_is_absolute = match &file.relative_path { + Some(path) if !path.is_empty() => is_absolute_path(path), + _ => is_absolute_path(&file.name), + }; + + if file.name.is_empty() { + warn!(name = %file.name, "Skipping file with empty name"); + false + } else if MAX_FILENAME_LEN < wire_len { + warn!( + name = %file.name, + path = ?file.relative_path, + wire_length = wire_len, + max_length = MAX_FILENAME_LEN, + "Skipping file with wire name exceeding maximum length" + ); + false + } else if file_clip_no_file_paths && wire_is_absolute { + warn!( + name = %file.name, + path = ?file.relative_path, + "Skipping file with absolute path (CB_FILECLIP_NO_FILE_PATHS is set)" + ); + false + } else { + true + } + }) + .collect(); + + if validated_files.len() < original_count { + debug!( + total = original_count, + valid = validated_files.len(), + "File list validation completed with warnings" + ); + } + + // Store the validated file list so we can send it when requested + self.local_file_list = Some(PackedFileList { files: validated_files }); + + // Build a format list with FileGroupDescriptorW format. + // Per MS-RDPECLIP 1.3.1.2, the format name "FileGroupDescriptorW" is constant across all + // implementations, but the format ID is arbitrary and OS-specific. + // + // Format ID ranges: + // - 0x0000-0x00FF: Standard Windows clipboard formats (CF_TEXT, CF_UNICODETEXT, etc.) + // - 0xC000-0xFFFF: Private/application-specific formats (registered via RegisterClipboardFormat) + // + // We use 0xC0FE in the private range. This ID is only used locally to identify our file list + // in the format list we send. The remote endpoint will map this to their own format ID based + // on the format name "FileGroupDescriptorW". When the remote requests this format via + // FormatDataRequest, they will use our ID (0xC0FE), which we use to recognize the request + // in handle_format_data_request. + const FILE_LIST_FORMAT_ID: u32 = 0xC0FE; + // Distinct private-range ID for the companion "Preferred DropEffect" + // entry. The value doesn't matter on the wire (the remote keys off + // the format *name*); it just has to be locally unique so we can + // tell which FormatDataRequest is which. + const DROP_EFFECT_FORMAT_ID: u32 = 0xC0FD; + let format_id = ClipboardFormatId::new(FILE_LIST_FORMAT_ID); + let drop_effect_id = ClipboardFormatId::new(DROP_EFFECT_FORMAT_ID); + // Advertise both FileGroupDescriptorW AND Preferred DropEffect. + // Windows Explorer pairs these locally and uses the latter to engage + // its shell file-copy machinery (with the native progress dialog) + // on paste — without it, Explorer falls back to a plain synchronous + // IStream read with no progress UI. + let formats = vec![ + ClipboardFormat::new(format_id).with_name(ClipboardFormatName::FILE_LIST), + ClipboardFormat::new(drop_effect_id).with_name(ClipboardFormatName::PREFERRED_DROP_EFFECT), + ]; + + // Track the format IDs we're using for the file list and drop effect + // so handle_format_data_request can recognize and answer them inline. + self.local_file_list_format_id = Some(format_id); + self.local_drop_effect_format_id = Some(drop_effect_id); + + let format_list = self.build_format_list(&formats).map_err(|e| encode_err!(e))?; + let pdu = ClipboardPdu::FormatList(format_list); + + // Release any outgoing download locks BEFORE advertising our file list. By + // initiating a file copy we take clipboard ownership, so locks placed for + // downloads from the previous owner are now stale; sending a new FormatList while + // they're still held desyncs the server's lock state. + // The Unlock PDUs must precede the FormatList on the wire. + let mut messages = self.release_outgoing_locks(); + messages.push(into_cliprdr_message(pdu)); + + Ok(messages.into()) + } } impl SvcProcessor for Cliprdr { @@ -302,46 +1610,326 @@ impl SvcProcessor for Cliprdr { fn process(&mut self, payload: &[u8]) -> PduResult> { let pdu = decode::>(payload).map_err(|e| decode_err!(e))?; - if self.state == CliprdrState::Failed { - error!("Attempted to process clipboard static virtual channel in failed state"); - return Ok(Vec::new()); - } - match pdu { ClipboardPdu::Capabilities(caps) => self.handle_server_capabilities(caps), ClipboardPdu::FormatList(format_list) => self.handle_format_list(format_list), ClipboardPdu::FormatListResponse(response) => self.handle_format_list_response(response), ClipboardPdu::MonitorReady => self.handle_monitor_ready(), ClipboardPdu::LockData(id) => { + // [MS-RDPECLIP] 3.1.5.3.2 - Processing a Lock Clipboard Data PDU + // Store a snapshot of the current file list so we can service + // FileContentsRequest PDUs with this clipDataId even after clipboard changes. + + // Defense-in-depth: Limit number of locked file lists to prevent memory exhaustion + // from malicious remotes sending unlimited Lock PDUs + if MAX_LOCKED_FILE_LISTS <= self.locked_file_lists.len() { + warn!( + clip_data_id = id.0, + current_locks = self.locked_file_lists.len(), + "Too many locked file lists, rejecting new lock request" + ); + return Ok(Vec::new()); + } + + if let Some(ref file_list) = self.local_file_list { + debug!(clip_data_id = id.0, "Locking clipboard with file list snapshot"); + self.locked_file_lists.insert(id.0, file_list.clone()); + self.locked_file_list_activity.insert(id.0, self.backend.now_ms()); + } else { + // This is expected when the local side has no file list (e.g., browser + // client with no pending upload). Per [MS-RDPECLIP] 3.1.5.3.2, the + // storage action is conditional on File Stream data being present. + debug!( + clip_data_id = id.0, + "Received Lock PDU but no local file list available" + ); + } self.backend.on_lock(id); Ok(Vec::new()) } ClipboardPdu::UnlockData(id) => { + // [MS-RDPECLIP] 3.1.5.3.4 - Processing an Unlock Clipboard Data PDU + // Release the file list snapshot associated with this clipDataId. + if self.locked_file_lists.remove(&id.0).is_some() { + self.locked_file_list_activity.remove(&id.0); + debug!( + clip_data_id = id.0, + "Unlocking clipboard and releasing file list snapshot" + ); + } else { + // Per [MS-RDPECLIP] 3.1.5.3.4, an Unlock for a nonexistent Lock + // "MUST be ignored." This is normal when the Lock had no file data. + debug!(clip_data_id = id.0, "Received Unlock PDU but no locked file list found"); + } self.backend.on_unlock(id); Ok(Vec::new()) } ClipboardPdu::FormatDataRequest(request) => { + // Short-circuit: if the remote is asking for our Preferred + // DropEffect, answer inline with DROPEFFECT_COPY (0x00000001, + // 4-byte little-endian). This is what we always mean by an + // outbound file copy (`initiate_file_copy` is named for + // exactly this), so answering inline keeps backends from + // having to know about the format. + if Some(request.format) == self.local_drop_effect_format_id { + const DROPEFFECT_COPY: u32 = 0x0000_0001; + let response = OwnedFormatDataResponse::new_data(DROPEFFECT_COPY.to_le_bytes().to_vec()); + let pdu = ClipboardPdu::FormatDataResponse(response); + return Ok(vec![into_cliprdr_message(pdu)]); + } + // Check if this is a request for our stored file list by comparing format IDs + if Some(request.format) == self.local_file_list_format_id { + if let Some(ref file_list) = self.local_file_list { + // Respond with the stored file list + debug!( + format_id = ?request.format, + file_count = file_list.files.len(), + "Responding to FileGroupDescriptorW request with stored file list" + ); + let response = OwnedFormatDataResponse::new_file_list(file_list).map_err(|e| encode_err!(e))?; + let pdu = ClipboardPdu::FormatDataResponse(response); + return Ok(vec![into_cliprdr_message(pdu)]); + } else { + // Format ID matches but we don't have a file list - this shouldn't happen + warn!("Received FormatDataRequest for file list format but no file list stored"); + } + } + + // Forward to backend for other format requests self.backend.on_format_data_request(request); - // NOTE: An actual data should be sent later via `submit_format_data` method, + // NOTE: Actual data should be sent later via `submit_format_data` method, // therefore we do not send anything immediately. Ok(Vec::new()) } ClipboardPdu::FormatDataResponse(response) => { - self.backend.on_format_data_response(response); - Ok(Vec::new()) + // Correlate this response with the most recently sent FormatDataRequest. + // Only intercept as a file list if the request was for the file list format; + // forward all other responses (text, images, etc.) to the backend. + let requested_format = self.pending_format_data_request.take(); + let is_file_list_response = + requested_format.is_some() && requested_format == self.remote_file_list_format_id; + + if is_file_list_response { + if response.is_error() { + warn!(?requested_format, "FileGroupDescriptorW request failed"); + self.backend.on_format_data_response(response); + Ok(Vec::new()) + } else { + // Parse the file list and store it in our abstract data model + match response.to_file_list() { + Ok(mut file_list) => { + // Sanitize file paths to prevent path traversal attacks while + // preserving safe relative directory structure. + // A malicious remote could send names like "../../../etc/passwd". + for file in &mut file_list.files { + if let Some(sanitized) = sanitize_file_path(&file.name) { + // Compare components directly to detect changes + // without allocating a combined wire name string. + let changed = file.name != sanitized.name + || match (&file.relative_path, &sanitized.relative_path) { + (None, None) => false, + (Some(a), Some(b)) => a != b, + _ => true, + }; + if changed { + warn!( + original = %file.name, + sanitized_name = %sanitized.name, + sanitized_path = ?sanitized.relative_path, + "Sanitized potentially dangerous file path from remote" + ); + } + file.name = sanitized.name; + file.relative_path = sanitized.relative_path; + } else { + warn!( + original = %file.name, + "Rejecting file with invalid name from remote" + ); + file.name = String::from("unnamed_file"); + file.relative_path = None; + } + } + + debug!( + file_count = file_list.files.len(), + "Received FileGroupDescriptorW from remote" + ); + // Notify backend with file metadata and the current lock ID + // (if locking was negotiated). The lock is already held at this point. + self.backend.on_remote_file_list(&file_list.files, self.current_lock_id); + + // Store the remote file list for FileContentsRequest validation. + self.remote_file_list = Some(file_list); + + Ok(Vec::new()) + } + Err(err) => { + error!(?err, "Failed to parse FileGroupDescriptorW from FormatDataResponse"); + // Notify backend of the failure so it can handle the error + self.backend.on_format_data_response(response); + Ok(Vec::new()) + } + } + } + } else { + // Forward other format data responses to backend + self.backend.on_format_data_response(response); + Ok(Vec::new()) + } } ClipboardPdu::FileContentsRequest(request) => { + // [MS-RDPECLIP] 2.2.2.1.1.1 - CB_STREAM_FILECLIP_ENABLED must be negotiated + if !self + .capabilities + .flags() + .contains(ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED) + { + warn!( + stream_id = request.stream_id, + "Received FileContentsRequest but CB_STREAM_FILECLIP_ENABLED not negotiated" + ); + let error_response = FileContentsResponse::new_error(request.stream_id); + let pdu = ClipboardPdu::FileContentsResponse(error_response.into_owned()); + return Ok(vec![into_cliprdr_message(pdu)]); + } + + // [MS-RDPECLIP] 3.1.5.4.6 - Processing a File Contents Request PDU + // Per MS-RDPECLIP 3.1.5.4.6: "If the clipDataId field is present, then the locked + // File Stream data associated with the ID MUST be used to service the request." + + // Determine which file list to use based on clipDataId presence + let file_list_to_use = if let Some(clip_data_id) = request.data_id { + // Use locked file list snapshot if clipDataId is present + match self.locked_file_lists.get(&clip_data_id) { + Some(locked_list) => { + // Track activity to prevent inactivity timeout + self.locked_file_list_activity + .insert(clip_data_id, self.backend.now_ms()); + debug!( + stream_id = request.stream_id, + clip_data_id, "Using locked file list snapshot for FileContentsRequest" + ); + Some(locked_list) + } + None => { + // Lock snapshot was cleaned up (inactivity timeout or Unlock PDU), + // but the file list itself is still valid. Fall back to local_file_list + // to support repaste scenarios where the server retries after a delay. + debug!( + stream_id = request.stream_id, + clip_data_id, "Locked file list snapshot expired, falling back to local file list" + ); + self.local_file_list.as_ref() + } + } + } else { + // Use current local file list if no clipDataId + self.local_file_list.as_ref() + }; + + // Validate the file index against the chosen file list bounds. + if let Some(file_list) = file_list_to_use { + // INVARIANT: request.index >= 0 (validated during decode), so usize + // conversion is safe. Use usize comparison to avoid u32 truncation + // on the file list length. + let file_index = usize::try_from(request.index).unwrap_or(usize::MAX); + if file_list.files.len() <= file_index { + warn!( + stream_id = request.stream_id, + index = request.index, + file_count = file_list.files.len(), + clip_data_id = ?request.data_id, + "Received FileContentsRequest with index out of bounds" + ); + // [MS-RDPECLIP] 3.1.5.4.7 - Send error response if request cannot be satisfied + let error_response = FileContentsResponse::new_error(request.stream_id); + let pdu = ClipboardPdu::FileContentsResponse(error_response.into_owned()); + return Ok(vec![into_cliprdr_message(pdu)]); + } + } else { + warn!( + stream_id = request.stream_id, + "Received FileContentsRequest but no file list available" + ); + // Send error response - we have no files to serve + let error_response = FileContentsResponse::new_error(request.stream_id); + let pdu = ClipboardPdu::FileContentsResponse(error_response.into_owned()); + return Ok(vec![into_cliprdr_message(pdu)]); + } + + debug!( + stream_id = request.stream_id, + index = request.index, + flags = ?request.flags, + "Processing FileContentsRequest" + ); + + // Forward to backend - it will call submit_file_contents() with response self.backend.on_file_contents_request(request); Ok(Vec::new()) } ClipboardPdu::FileContentsResponse(response) => { - self.backend.on_file_contents_response(response); + // [MS-RDPECLIP] 3.1.5.4.8 - Processing a File Contents Response PDU + let stream_id = response.stream_id(); + + // Validate this response matches a request we sent + if let Some(transfer_state) = self.sent_file_contents_requests.remove(&stream_id) { + debug!( + stream_id, + file_index = transfer_state.file_index, + is_error = response.is_error(), + data_len = response.data().len(), + "Received FileContentsResponse" + ); + + if response.is_error() { + warn!(stream_id, "FileContentsResponse indicates failure (CB_RESPONSE_FAIL)"); + + // [MS-RDPECLIP] 2.2.5.4 - FAIL responses MUST have zero-length data. + // Sanitize non-conforming error responses to prevent backends from + // misinterpreting stale data bytes as valid content. + if !response.data().is_empty() { + warn!( + stream_id, + data_len = response.data().len(), + "Sanitizing error response: clearing non-empty data per MS-RDPECLIP 2.2.5.4" + ); + self.backend + .on_file_contents_response(FileContentsResponse::new_error(stream_id)); + } else { + self.backend.on_file_contents_response(response); + } + } else if transfer_state.flags.contains(FileContentsFlags::SIZE) && response.data().len() != 8 { + // [MS-RDPECLIP] 2.2.5.4 - SIZE responses MUST be exactly 8 bytes + // (a 64-bit unsigned integer). A malformed SIZE response would cause + // backends to either fail in data_as_size() or misinterpret the bytes. + // Convert to an error response so backends handle it uniformly. + warn!( + stream_id, + data_len = response.data().len(), + "Converting malformed SIZE response to error: expected 8 bytes per MS-RDPECLIP 2.2.5.4" + ); + self.backend + .on_file_contents_response(FileContentsResponse::new_error(stream_id)); + } else { + // Forward valid response to backend + self.backend.on_file_contents_response(response); + } + } else { + warn!( + stream_id, + "Received FileContentsResponse for unknown streamId (no matching request sent); dropping" + ); + } + + Ok(Vec::new()) + } + ClipboardPdu::TemporaryDirectory(_) => { + // do nothing Ok(Vec::new()) } - _ => self.handle_error_transition(ClipboardError::UnimplementedPdu { - pdu: pdu.message_name(), - }), } } @@ -352,10 +1940,11 @@ impl SvcProcessor for Cliprdr { fn into_cliprdr_message(pdu: ClipboardPdu<'static>) -> SvcMessage { // Adding [`CHANNEL_FLAG_SHOW_PROTOCOL`] is a must for clipboard svc messages, because they - // contain chunked data. This is the requirement from `MS_RDPBCGR` specification. + // contain chunked data. This is the requirement from `MS-RDPBCGR` specification. SvcMessage::from(pdu).with_flags(ChannelFlags::SHOW_PROTOCOL) } +/// Client-side role marker for the CLIPRDR channel. #[derive(Debug)] pub struct Client {} @@ -365,6 +1954,7 @@ impl Role for Client { } } +/// Server-side role marker for the CLIPRDR channel. #[derive(Debug)] pub struct Server {} @@ -373,3 +1963,84 @@ impl Role for Server { true } } + +/// Test-only accessors for `Cliprdr` internal state. +/// +/// These methods are gated behind the `__test` feature and exist solely +/// so that tests in `ironrdp-testsuite-core` can set up / inspect internal +/// fields without making them part of the public API. +#[cfg(feature = "__test")] +#[doc(hidden)] +impl Cliprdr { + pub fn __test_state(&self) -> &CliprdrState { + &self.state + } + + pub fn __test_state_mut(&mut self) -> &mut CliprdrState { + &mut self.state + } + + pub fn __test_capabilities(&self) -> &Capabilities { + &self.capabilities + } + + pub fn __test_capabilities_mut(&mut self) -> &mut Capabilities { + &mut self.capabilities + } + + pub fn __test_outgoing_locks(&self) -> &HashMap { + &self.outgoing_locks + } + + pub fn __test_outgoing_locks_mut(&mut self) -> &mut HashMap { + &mut self.outgoing_locks + } + + pub fn __test_current_lock_id(&self) -> Option { + self.current_lock_id + } + + pub fn __test_sent_file_contents_requests(&self) -> &HashMap { + &self.sent_file_contents_requests + } + + pub fn __test_sent_file_contents_requests_mut(&mut self) -> &mut HashMap { + &mut self.sent_file_contents_requests + } + + pub fn __test_locked_file_lists(&self) -> &HashMap { + &self.locked_file_lists + } + + pub fn __test_locked_file_lists_mut(&mut self) -> &mut HashMap { + &mut self.locked_file_lists + } + + pub fn __test_locked_file_list_activity(&self) -> &HashMap { + &self.locked_file_list_activity + } + + pub fn __test_local_file_list(&self) -> &Option { + &self.local_file_list + } + + pub fn __test_local_file_list_mut(&mut self) -> &mut Option { + &mut self.local_file_list + } + + pub fn __test_local_file_list_format_id(&self) -> Option { + self.local_file_list_format_id + } + + pub fn __test_local_file_list_format_id_mut(&mut self) -> &mut Option { + &mut self.local_file_list_format_id + } + + pub fn __test_remote_file_list_mut(&mut self) -> &mut Option { + &mut self.remote_file_list + } + + pub fn __test_remote_file_list_format_id(&self) -> Option { + self.remote_file_list_format_id + } +} diff --git a/crates/ironrdp-cliprdr/src/pdu/capabilities.rs b/crates/ironrdp-cliprdr/src/pdu/capabilities.rs index 377586ea81..bab0f52f77 100644 --- a/crates/ironrdp-cliprdr/src/pdu/capabilities.rs +++ b/crates/ironrdp-cliprdr/src/pdu/capabilities.rs @@ -1,7 +1,7 @@ use bitflags::bitflags; use ironrdp_core::{ - cast_int, cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeError, DecodeResult, - Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_int, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, }; use ironrdp_pdu::{impl_pdu_pod, read_padding, write_padding}; @@ -46,7 +46,7 @@ impl Capabilities { pub fn downgrade(&mut self, server_caps: &Self) { let client_flags = self.flags(); - let server_flags = self.flags(); + let server_flags = server_caps.flags(); let flags = client_flags & server_flags; let version = self.version().downgrade(server_caps.version()); @@ -213,20 +213,28 @@ impl<'de> Decode<'de> for GeneralCapabilitySet { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let version: ClipboardProtocolVersion = src.read_u32().try_into()?; - let general_flags = ClipboardGeneralCapabilityFlags::from_bits_truncate(src.read_u32()); + let version: ClipboardProtocolVersion = src.read_u32().into(); + let general_flags = ClipboardGeneralCapabilityFlags::from_bits_retain(src.read_u32()); Ok(Self { version, general_flags }) } } /// Specifies the `Remote Desktop Protocol: Clipboard Virtual Channel Extension` version number. -/// This field is for informational purposes and MUST NOT be used to make protocol capability -/// decisions. The actual features supported are specified via [`ClipboardGeneralCapabilityFlags`] +/// +/// Per [MS-RDPECLIP] 2.2.2.1.1.1, this field is for informational purposes and MUST NOT be +/// used to make protocol capability decisions. The actual features supported are specified +/// via [`ClipboardGeneralCapabilityFlags`]. +/// +/// Unknown version values are preserved as [`Unknown`](Self::Unknown) to avoid rejecting +/// Capabilities PDUs from future protocol revisions. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ClipboardProtocolVersion { V1, V2, + /// A version value not recognized by this implementation. + /// Preserved for round-trip encoding fidelity. + Unknown(u32), } impl ClipboardProtocolVersion { @@ -247,18 +255,17 @@ impl From for u32 { match version { ClipboardProtocolVersion::V1 => ClipboardProtocolVersion::VERSION_VALUE_V1, ClipboardProtocolVersion::V2 => ClipboardProtocolVersion::VERSION_VALUE_V2, + ClipboardProtocolVersion::Unknown(value) => value, } } } -impl TryFrom for ClipboardProtocolVersion { - type Error = DecodeError; - - fn try_from(value: u32) -> Result { +impl From for ClipboardProtocolVersion { + fn from(value: u32) -> Self { match value { - Self::VERSION_VALUE_V1 => Ok(Self::V1), - Self::VERSION_VALUE_V2 => Ok(Self::V2), - _ => Err(invalid_field_err!("version", "Invalid clipboard capabilities version")), + Self::VERSION_VALUE_V1 => Self::V1, + Self::VERSION_VALUE_V2 => Self::V2, + other => Self::Unknown(other), } } } @@ -289,5 +296,7 @@ bitflags! { /// using the File Contents Request PDU and File Contents /// Response PDU. const HUGE_FILE_SUPPORT_ENABLED = 0x0000_0020; + + const _ = !0; } } diff --git a/crates/ironrdp-cliprdr/src/pdu/client_temporary_directory.rs b/crates/ironrdp-cliprdr/src/pdu/client_temporary_directory.rs index 53e778aec2..b3168d6856 100644 --- a/crates/ironrdp-cliprdr/src/pdu/client_temporary_directory.rs +++ b/crates/ironrdp-cliprdr/src/pdu/client_temporary_directory.rs @@ -1,11 +1,11 @@ use std::borrow::Cow; use ironrdp_core::{ - cast_int, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, IntoOwned, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, IntoOwned, ReadCursor, WriteCursor, cast_int, ensure_size, + invalid_field_err, }; use ironrdp_pdu::impl_pdu_borrowing; -use ironrdp_pdu::utils::{read_string_from_cursor, write_string_to_cursor, CharacterSet}; +use ironrdp_pdu::utils::{CharacterSet, read_string_from_cursor, write_string_to_cursor}; use crate::pdu::PartialHeader; diff --git a/crates/ironrdp-cliprdr/src/pdu/file_contents.rs b/crates/ironrdp-cliprdr/src/pdu/file_contents.rs index daebe69000..ebad901ec5 100644 --- a/crates/ironrdp-cliprdr/src/pdu/file_contents.rs +++ b/crates/ironrdp-cliprdr/src/pdu/file_contents.rs @@ -2,8 +2,8 @@ use std::borrow::Cow; use bitflags::bitflags; use ironrdp_core::{ - cast_int, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, IntoOwned, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, IntoOwned, ReadCursor, WriteCursor, cast_int, ensure_size, + invalid_field_err, }; use ironrdp_pdu::impl_pdu_borrowing; use ironrdp_pdu::utils::{combine_u64, split_u64}; @@ -19,11 +19,31 @@ bitflags! { /// 0x00000008 and both the nPositionLow and nPositionHigh fields MUST be /// set to 0x00000000. const SIZE = 0x0000_0001; - /// A request for the data present in the file identified by the lindex field. The data + /// A request for a byte range from the file identified by the lindex field. The data /// to be retrieved is extracted starting from the offset given by the nPositionLow /// and nPositionHigh fields. The maximum number of bytes to extract is specified /// by the cbRequested field. - const DATA = 0x0000_0002; + const RANGE = 0x0000_0002; + + const _ = !0; + } +} + +impl FileContentsFlags { + /// [MS-RDPECLIP] 2.2.5.3 - Validates that flags are spec-compliant + /// + /// Per spec requirements: + /// - Exactly one of SIZE or RANGE must be set + /// - SIZE and RANGE flags MUST NOT be set simultaneously + pub fn validate(self) -> Result<(), &'static str> { + let size_set = self.contains(FileContentsFlags::SIZE); + let range_set = self.contains(FileContentsFlags::RANGE); + + match (size_set, range_set) { + (true, true) => Err("SIZE and RANGE flags are mutually exclusive per MS-RDPECLIP 2.2.5.3"), + (false, false) => Err("exactly one of SIZE or RANGE must be set"), + _ => Ok(()), + } } } @@ -88,20 +108,40 @@ impl<'a> FileContentsResponse<'a> { self.stream_id } + pub fn is_error(&self) -> bool { + self.is_error + } + pub fn data(&self) -> &[u8] { &self.data } - /// Read data as u64 size value + /// [MS-RDPECLIP] 2.2.5.4 - Read data as u64 size value + /// + /// Per spec, SIZE responses MUST contain exactly 8 bytes (64-bit unsigned integer). + /// + /// # Errors + /// + /// Returns an error if the data length is not exactly 8 bytes. + /// + /// # Panics + /// + /// Should not panic - the try_into conversion is guaranteed to succeed after length validation. pub fn data_as_size(&self) -> DecodeResult { if self.data.len() != 8 { return Err(invalid_field_err!( "requestedFileContentsData", - "Invalid data size for u64 size" + "SIZE response must be exactly 8 bytes per MS-RDPECLIP 2.2.5.4" )); } - Ok(u64::from_le_bytes(self.data.as_ref().try_into().unwrap())) + // Per length check above, this conversion is infallible. + let chunk: [u8; 8] = self + .data + .as_ref() + .try_into() + .map_err(|_| invalid_field_err!("requestedFileContentsData", "SIZE response data is not 8 bytes"))?; + Ok(u64::from_le_bytes(chunk)) } } @@ -142,7 +182,7 @@ impl<'de> Decode<'de> for FileContentsResponse<'de> { ensure_size!(in: src, size: header.data_length()); if header.data_length() < Self::FIXED_PART_SIZE { - return Err(invalid_field_err!("requestedFileContentsData", "Invalid data size")); + return Err(invalid_field_err!("requestedFileContentsData", "invalid data size")); }; let data_size = header.data_length() - Self::FIXED_PART_SIZE; @@ -162,7 +202,9 @@ impl<'de> Decode<'de> for FileContentsResponse<'de> { #[derive(Debug, Clone, PartialEq, Eq)] pub struct FileContentsRequest { pub stream_id: u32, - pub index: u32, + /// Per [MS-RDPECLIP] 2.2.5.3, lindex is a signed 32-bit integer. + /// Negative values are invalid and rejected during decode. + pub index: i32, pub flags: FileContentsFlags, pub position: u64, pub requested_size: u32, @@ -171,7 +213,7 @@ pub struct FileContentsRequest { impl FileContentsRequest { const NAME: &'static str = "CLIPRDR_FILECONTENTS_REQUEST"; - const FIXED_PART_SIZE: usize = 4 /* streamId */ + 4 /* idx */ + 4 /* flags */ + 8 /* position */ + 4 /* reqSize */; + const FIXED_PART_SIZE: usize = 4 /* streamId */ + 4 /* lindex */ + 4 /* dwFlags */ + 8 /* nPositionLow + nPositionHigh */ + 4 /* cbRequested */; fn inner_size(&self) -> usize { let data_id_size = match self.data_id { @@ -184,6 +226,12 @@ impl FileContentsRequest { } impl Encode for FileContentsRequest { + /// Encodes the request into the wire format. + /// + /// Note: this does not enforce the spec constraints from [MS-RDPECLIP] 2.2.5.3 + /// (e.g., that SIZE requests have `cbRequested = 8` and `position = 0`). + /// Callers that build these PDUs are responsible for setting fields correctly; + /// use [`FileContentsFlags::validate`] to check flag consistency. fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { let header = PartialHeader::new(cast_int!("dataLen", self.inner_size())?); header.encode(dst)?; @@ -191,7 +239,7 @@ impl Encode for FileContentsRequest { ensure_size!(in: dst, size: self.inner_size()); dst.write_u32(self.stream_id); - dst.write_u32(self.index); + dst.write_i32(self.index); dst.write_u32(self.flags.bits()); let (position_lo, position_hi) = split_u64(self.position); @@ -229,14 +277,41 @@ impl<'de> Decode<'de> for FileContentsRequest { ensure_size!(in: src, size: expected_size); let stream_id = src.read_u32(); - let index = src.read_u32(); - let flags = FileContentsFlags::from_bits_truncate(src.read_u32()); + let index = src.read_i32(); + let flags = FileContentsFlags::from_bits_retain(src.read_u32()); let position_lo = src.read_u32(); let position_hi = src.read_u32(); let position = combine_u64(position_lo, position_hi); let requested_size = src.read_u32(); let data_id = if read_data_id { Some(src.read_u32()) } else { None }; + // [MS-RDPECLIP] 2.2.5.3 - Validate lindex is non-negative + if index < 0 { + return Err(invalid_field_err!( + "lindex", + "file index must be non-negative per MS-RDPECLIP 2.2.5.3" + )); + } + + // [MS-RDPECLIP] 2.2.5.3 - Validate flags are spec-compliant + flags.validate().map_err(|e| invalid_field_err!("dwFlags", e))?; + + // [MS-RDPECLIP] 2.2.5.3 - Validate SIZE request constraints + if flags.contains(FileContentsFlags::SIZE) { + if requested_size != 8 { + return Err(invalid_field_err!( + "cbRequested", + "SIZE request must have cbRequested=8 per MS-RDPECLIP 2.2.5.3" + )); + } + if position != 0 { + return Err(invalid_field_err!( + "position", + "SIZE request must have position=0 per MS-RDPECLIP 2.2.5.3" + )); + } + } + Ok(Self { stream_id, index, diff --git a/crates/ironrdp-cliprdr/src/pdu/format_data/file_list.rs b/crates/ironrdp-cliprdr/src/pdu/format_data/file_list.rs index b0940e83df..132ce6e2e8 100644 --- a/crates/ironrdp-cliprdr/src/pdu/format_data/file_list.rs +++ b/crates/ironrdp-cliprdr/src/pdu/format_data/file_list.rs @@ -1,22 +1,34 @@ +use std::borrow::Cow; + use bitflags::bitflags; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, }; -use ironrdp_pdu::utils::{combine_u64, decode_string, encode_string, split_u64, CharacterSet}; +use ironrdp_pdu::utils::{CharacterSet, combine_u64, decode_string, encode_string, split_u64}; use ironrdp_pdu::{impl_pdu_pod, write_padding}; +/// Maximum file name field size in bytes (260 UTF-16 code units * 2 bytes per code unit). const NAME_LENGTH: usize = 520; +/// Defense-in-depth limit on the number of file descriptors in a single +/// [`PackedFileList`]. This prevents memory exhaustion from crafted payloads +/// while remaining well above any realistic file transfer count. +pub const MAX_FILE_COUNT: usize = 100_000; + bitflags! { /// Represents `flags` field of `CLIPRDR_FILEDESCRIPTOR` structure. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ClipboardFileFlags: u32 { /// The fileAttributes field contains valid data. const ATTRIBUTES = 0x0000_0004; - /// The fileSizeHigh and fileSizeLow fields contain valid data. - const FILE_SIZE = 0x0000_0040; /// The lastWriteTime field contains valid data. const LAST_WRITE_TIME = 0x0000_0020; + /// The fileSizeHigh and fileSizeLow fields contain valid data. + const FILE_SIZE = 0x0000_0040; + /// A progress indicator should be shown when copying the file. + const SHOW_PROGRESS_UI = 0x0000_4000; + + const _ = !0; } } @@ -41,19 +53,43 @@ bitflags! { /// A file that does not have other attributes set. This attribute is valid only /// when used alone. const NORMAL = 0x0000_0080; + + const _ = !0; } } /// [2.2.5.2.3.1] File Descriptor (CLIPRDR_FILEDESCRIPTOR) /// +/// The `name` field holds the file basename (e.g., `"file.txt"`). +/// The `relative_path` field holds the directory portion of the path +/// (e.g., `"temp\\subdir"`), using `\` as the separator to match +/// the Windows convention on the wire. `None` means the file is at +/// the root level of the copied collection. +/// +/// Per [MS-RDPECLIP] 3.1.1.2, file lists use relative paths to describe +/// directory structure (e.g., `temp\file1.txt`). The sanitization layer +/// in [`crate::Cliprdr`] populates both fields from the raw wire name. +/// +/// # Encoding constraints +/// +/// The wire `cFileName` field is 520 bytes (260 UTF-16 code units). +/// [`Encode::encode`] will return an error if the reconstructed wire name +/// (`relative_path` + `\` + `name`) exceeds this limit. Callers that +/// construct descriptors directly (rather than via decode) must ensure +/// the combined name fits. +/// /// [2.2.5.2.3.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeclip/a765d784-2b39-4b88-9faa-88f8666f9c35 #[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] pub struct FileDescriptor { pub attributes: Option, pub last_write_time: Option, pub file_size: Option, // TODO: Define a new type for "bounded" strings (this one should never be bigger than 260 characters, including the null-terminator) pub name: String, + /// Relative directory path for this file within the copied collection. + /// Uses `\` as the separator. `None` for root-level files. + pub relative_path: Option, } impl_pdu_pod!(FileDescriptor); @@ -61,13 +97,52 @@ impl_pdu_pod!(FileDescriptor); impl FileDescriptor { const NAME: &'static str = "CLIPRDR_FILEDESCRIPTOR"; - const FIXED_PART_SIZE: usize = 4 // flags - + 32 // reserved - + 4 // attributes - + 16 // reserved - + 8 // last write time - + 8 // size - + NAME_LENGTH; // name + /// Creates a new file descriptor with the given name and all optional fields set to `None`. + pub fn new(name: impl Into) -> Self { + Self { + attributes: None, + last_write_time: None, + file_size: None, + name: name.into(), + relative_path: None, + } + } + + /// Sets the file attributes. + #[must_use] + pub fn with_attributes(mut self, attributes: ClipboardFileAttributes) -> Self { + self.attributes = Some(attributes); + self + } + + /// Sets the last write time (Windows FILETIME). + #[must_use] + pub fn with_last_write_time(mut self, time: u64) -> Self { + self.last_write_time = Some(time); + self + } + + /// Sets the file size in bytes. + #[must_use] + pub fn with_file_size(mut self, size: u64) -> Self { + self.file_size = Some(size); + self + } + + /// Sets the relative directory path within the copied collection. + #[must_use] + pub fn with_relative_path(mut self, path: impl Into) -> Self { + self.relative_path = Some(path.into()); + self + } + + const FIXED_PART_SIZE: usize = 4 /* dwFlags */ + + 32 /* reserved1 */ + + 4 /* dwFileAttributes */ + + 16 /* reserved2 */ + + 8 /* ftLastWriteTime */ + + 8 /* nFileSizeHigh + nFileSizeLow */ + + NAME_LENGTH /* cFileName */; const SIZE: usize = Self::FIXED_PART_SIZE; } @@ -76,7 +151,14 @@ impl Encode for FileDescriptor { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - let mut flags = ClipboardFileFlags::empty(); + // Always advertise FD_PROGRESSUI (SHOW_PROGRESS_UI = 0x4000) so the + // remote knows it MAY show a progress indicator for this file. The + // flag is benign if the remote doesn't honor it; for clipboard file + // paste into Windows Explorer it is the actual trigger that makes + // the native "Copying… items" progress dialog appear (otherwise the + // paste falls back to a synchronous IStream read with no progress + // UI, only a busy cursor — same UX as pasting an Outlook attachment). + let mut flags = ClipboardFileFlags::SHOW_PROGRESS_UI; if self.attributes.is_some() { flags |= ClipboardFileFlags::ATTRIBUTES; } @@ -97,7 +179,25 @@ impl Encode for FileDescriptor { dst.write_u32(size_hi); dst.write_u32(size_lo); - let written = encode_string(dst.remaining_mut(), &self.name, CharacterSet::Unicode, true)?; + // Reconstruct the wire fileName from relative_path and name. + // Per MS-RDPECLIP 3.1.1.2, file lists use relative paths like "temp\file1.txt". + let wire_name: Cow<'_, str> = match &self.relative_path { + Some(path) if !path.is_empty() => Cow::Owned(format!("{path}\\{}", self.name)), + _ => Cow::Borrowed(&self.name), + }; + + // Validate length before writing to prevent buffer corruption when + // encoding multiple descriptors into a shared buffer. + // UTF-16 encoding: each code unit is 2 bytes, plus 2 bytes for null terminator. + let encoded_len = wire_name.encode_utf16().count() * 2 + 2; + if NAME_LENGTH < encoded_len { + return Err(ironrdp_core::invalid_field_err!( + "cFileName", + "encoded wire name exceeds NAME_LENGTH (520 bytes)" + )); + } + + let written = encode_string(dst.remaining_mut(), &wire_name, CharacterSet::Unicode, true)?; dst.advance(written); // Pad with zeroes, overriding any previously written data @@ -119,10 +219,10 @@ impl<'de> Decode<'de> for FileDescriptor { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let flags = ClipboardFileFlags::from_bits_truncate(src.read_u32()); + let flags = ClipboardFileFlags::from_bits_retain(src.read_u32()); src.read_array::<32>(); let attributes = if flags.contains(ClipboardFileFlags::ATTRIBUTES) { - Some(ClipboardFileAttributes::from_bits_truncate(src.read_u32())) + Some(ClipboardFileAttributes::from_bits_retain(src.read_u32())) } else { let _ = src.read_u32(); None @@ -143,7 +243,11 @@ impl<'de> Decode<'de> for FileDescriptor { None }; - let name = decode_string(src.remaining(), CharacterSet::Unicode, true)?; + // Bound the scan to exactly the 520-byte cFileName field so that + // malformed data (missing null terminator) cannot read into + // subsequent file descriptors. + let name_field = &src.remaining()[..NAME_LENGTH]; + let name = decode_string(name_field, CharacterSet::Unicode, true)?; src.advance(NAME_LENGTH); Ok(Self { @@ -151,13 +255,15 @@ impl<'de> Decode<'de> for FileDescriptor { last_write_time, file_size, name, + // Populated later by the sanitization layer in Cliprdr::process() + relative_path: None, }) } } -/// Represents `CLIPRDR_FILELIST` +/// [2.2.5.2.3] Packed File List (CLIPRDR_FILELIST) /// -/// NOTE: `Decode` implementation will read all remaining data in cursor as file list. +/// [2.2.5.2.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeclip/a1db10b8-4a2a-4ce4-8e5f-6ce5bec5c979 #[derive(Debug, Clone, PartialEq, Eq)] pub struct PackedFileList { pub files: Vec, @@ -167,7 +273,7 @@ impl_pdu_pod!(PackedFileList); impl PackedFileList { const NAME: &'static str = "CLIPRDR_FILELIST"; - const FIXED_PART_SIZE: usize = 4; // file count + const FIXED_PART_SIZE: usize = 4 /* cItems */; } impl Encode for PackedFileList { @@ -195,9 +301,20 @@ impl Encode for PackedFileList { impl<'de> Decode<'de> for PackedFileList { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let file_count = cast_length!(Self::NAME, "cItems", src.read_u32())?; + let file_count: usize = cast_length!(Self::NAME, "cItems", src.read_u32())?; + + if MAX_FILE_COUNT < file_count { + return Err(ironrdp_core::invalid_field_err!( + "cItems", + "file count exceeds maximum of 100000" + )); + } - let mut files = Vec::with_capacity(file_count); + // Cap pre-allocation against remaining bytes to prevent OOM from + // a malicious file_count. The actual decode loop will fail gracefully + // if the cursor runs out of data. + let max_possible = src.len() / FileDescriptor::SIZE; + let mut files = Vec::with_capacity(file_count.min(max_possible)); for _ in 0..file_count { files.push(FileDescriptor::decode(src)?); } diff --git a/crates/ironrdp-cliprdr/src/pdu/format_data/metafile.rs b/crates/ironrdp-cliprdr/src/pdu/format_data/metafile.rs index 4569687bf7..c605bfacdc 100644 --- a/crates/ironrdp-cliprdr/src/pdu/format_data/metafile.rs +++ b/crates/ironrdp-cliprdr/src/pdu/format_data/metafile.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, ensure_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, }; bitflags! { @@ -30,6 +30,8 @@ bitflags! { const ISOTROPIC = 0x0000_0007; /// Logical units are mapped to arbitrary units with arbitrarily scaled axes. const ANISOTROPIC = 0x0000_0008; + + const _ = !0; } } @@ -42,7 +44,7 @@ pub struct PackedMetafile<'a> { pub x_ext: u32, pub y_ext: u32, /// The variable sized contents of the metafile as specified in [MS-WMF] section 2 - data: Cow<'a, [u8]>, + pub data: Cow<'a, [u8]>, } impl PackedMetafile<'_> { @@ -62,10 +64,6 @@ impl PackedMetafile<'_> { data: data.into(), } } - - pub fn data(&self) -> &[u8] { - &self.data - } } impl Encode for PackedMetafile<'_> { @@ -93,7 +91,7 @@ impl<'de> Decode<'de> for PackedMetafile<'de> { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let mapping_mode = PackedMetafileMappingMode::from_bits_truncate(src.read_u32()); + let mapping_mode = PackedMetafileMappingMode::from_bits_retain(src.read_u32()); let x_ext = src.read_u32(); let y_ext = src.read_u32(); diff --git a/crates/ironrdp-cliprdr/src/pdu/format_data/mod.rs b/crates/ironrdp-cliprdr/src/pdu/format_data/mod.rs index e2acc1f46a..cc957ac9ee 100644 --- a/crates/ironrdp-cliprdr/src/pdu/format_data/mod.rs +++ b/crates/ironrdp-cliprdr/src/pdu/format_data/mod.rs @@ -10,11 +10,11 @@ pub use self::palette::*; use std::borrow::Cow; use ironrdp_core::{ - cast_int, ensure_fixed_part_size, ensure_size, Decode, DecodeResult, Encode, EncodeResult, IntoOwned, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, IntoOwned, ReadCursor, WriteCursor, cast_int, ensure_fixed_part_size, + ensure_size, }; use ironrdp_pdu::impl_pdu_borrowing; -use ironrdp_pdu::utils::{read_string_from_cursor, to_utf16_bytes, CharacterSet}; +use ironrdp_pdu::utils::{CharacterSet, read_string_from_cursor, to_utf16_bytes}; use super::ClipboardFormatId; use crate::pdu::{ClipboardPduFlags, PartialHeader}; @@ -201,6 +201,10 @@ impl<'de> Decode<'de> for FormatDataResponse<'de> { let is_error = header.message_flags.contains(ClipboardPduFlags::RESPONSE_FAIL); + // No explicit upper bound on data_length is needed here: the data is + // borrowed from the existing PDU buffer (Cow::Borrowed), so no new + // allocation occurs. The SVC transport layer already bounds the incoming + // buffer size, and ensure_size! rejects payloads shorter than declared. ensure_size!(in: src, size: header.data_length()); let data = src.read_slice(header.data_length()); diff --git a/crates/ironrdp-cliprdr/src/pdu/format_list.rs b/crates/ironrdp-cliprdr/src/pdu/format_list.rs index 1060b7f457..02fd462931 100644 --- a/crates/ironrdp-cliprdr/src/pdu/format_list.rs +++ b/crates/ironrdp-cliprdr/src/pdu/format_list.rs @@ -1,11 +1,11 @@ use std::borrow::Cow; use ironrdp_core::{ - cast_int, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, IntoOwned, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, IntoOwned, ReadCursor, WriteCursor, cast_int, ensure_size, + invalid_field_err, }; -use ironrdp_pdu::utils::{read_string_from_cursor, to_utf16_bytes, write_string_to_cursor, CharacterSet}; -use ironrdp_pdu::{decode_err, impl_pdu_borrowing, impl_pdu_pod, PduResult}; +use ironrdp_pdu::utils::{CharacterSet, read_string_from_cursor, to_utf16_bytes, write_string_to_cursor}; +use ironrdp_pdu::{PduResult, decode_err, impl_pdu_borrowing, impl_pdu_pod}; use crate::pdu::{ClipboardPduFlags, PartialHeader}; @@ -153,6 +153,14 @@ impl ClipboardFormatName { /// Special format defined by Windows to store HTML fragment in clipboard. pub const HTML: Self = Self::new_static("HTML Format"); + /// `CFSTR_PREFERREDDROPEFFECT`: 4-byte little-endian `DROPEFFECT` value + /// (1 = DROPEFFECT_COPY, 2 = DROPEFFECT_MOVE). Conventionally placed on + /// the clipboard alongside [`Self::FILE_LIST`] to label the operation + /// as a copy. When present, Windows Explorer engages its shell + /// file-copy machinery on paste (with the native "Copying… items" + /// progress dialog) instead of doing a plain synchronous IStream read. + pub const PREFERRED_DROP_EFFECT: Self = Self::new_static("Preferred DropEffect"); + pub fn new(name: impl Into>) -> Self { Self(name.into()) } @@ -449,7 +457,7 @@ impl<'de> Decode<'de> for FormatListResponse { match header.message_flags { ClipboardPduFlags::RESPONSE_OK => Ok(FormatListResponse::Ok), ClipboardPduFlags::RESPONSE_FAIL => Ok(FormatListResponse::Fail), - _ => Err(invalid_field_err!("msgFlags", "Invalid format list message flags")), + _ => Err(invalid_field_err!("msgFlags", "invalid format list message flags")), } } } diff --git a/crates/ironrdp-cliprdr/src/pdu/lock.rs b/crates/ironrdp-cliprdr/src/pdu/lock.rs index 58f6b3795b..44d0db5dc6 100644 --- a/crates/ironrdp-cliprdr/src/pdu/lock.rs +++ b/crates/ironrdp-cliprdr/src/pdu/lock.rs @@ -1,5 +1,5 @@ use ironrdp_core::{ - cast_int, ensure_fixed_part_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_int, ensure_fixed_part_size, }; use ironrdp_pdu::impl_pdu_pod; diff --git a/crates/ironrdp-cliprdr/src/pdu/mod.rs b/crates/ironrdp-cliprdr/src/pdu/mod.rs index 3fbcdc1f5c..a45bd942e2 100644 --- a/crates/ironrdp-cliprdr/src/pdu/mod.rs +++ b/crates/ironrdp-cliprdr/src/pdu/mod.rs @@ -18,7 +18,7 @@ pub use self::lock::*; #[rustfmt::skip] use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, invalid_field_err, }; use ironrdp_svc::SvcEncode; @@ -69,7 +69,7 @@ impl<'de> Decode<'de> for PartialHeader { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let message_flags = ClipboardPduFlags::from_bits_truncate(src.read_u16()); + let message_flags = ClipboardPduFlags::from_bits_retain(src.read_u16()); let data_length = src.read_u32(); Ok(Self { @@ -243,7 +243,7 @@ impl<'de> Decode<'de> for ClipboardPdu<'de> { MSG_TYPE_FILE_CONTENTS_RESPONSE => ClipboardPdu::FileContentsResponse(FileContentsResponse::decode(src)?), MSG_TYPE_LOCK_CLIPDATA => ClipboardPdu::LockData(LockDataId::decode(src)?), MSG_TYPE_UNLOCK_CLIPDATA => ClipboardPdu::UnlockData(LockDataId::decode(src)?), - _ => return Err(invalid_field_err!("msgType", "Unknown clipboard PDU type")), + _ => return Err(invalid_field_err!("msgType", "unknown clipboard PDU type")), }; Ok(pdu) @@ -266,5 +266,7 @@ bitflags! { /// Used by the Short Format Name variant of the Format List Response PDU to indicate /// that the format names are in ASCII 8 const ASCII_NAMES = 0x0004; + + const _ = !0; } } diff --git a/crates/ironrdp-connector/CHANGELOG.md b/crates/ironrdp-connector/CHANGELOG.md index 7481c6b25f..65a88959ba 100644 --- a/crates/ironrdp-connector/CHANGELOG.md +++ b/crates/ironrdp-connector/CHANGELOG.md @@ -6,6 +6,177 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.9.0...ironrdp-connector-v0.10.0)] - 2026-07-10 + +### Security + +- [**breaking**] Send NetworkAutoDetect over the MCS message channel ([#1348](https://github.com/Devolutions/IronRDP/issues/1348)) ([8a1fd0118e](https://github.com/Devolutions/IronRDP/commit/8a1fd0118e0bac214c9050b6ca6b36a040046dd3)) + + Corrects Network Auto-Detect framing and routing to match MS-RDPBCGR by + moving it off the I/O channel slow-path Share Data PDUs and onto the MCS + message channel with the required Basic Security Header + (SEC_AUTODETECT_REQ / SEC_AUTODETECT_RSP). This aligns IronRDP with + mstsc/xfreerdp behavior and enables both connect-time and continuous + auto-detection to actually function. + +### Bug Fixes + +- Stay in CapabilitiesExchange when activation handles DeactivateAll ([#1371](https://github.com/Devolutions/IronRDP/issues/1371)) ([a4fde9fc50](https://github.com/Devolutions/IronRDP/commit/a4fde9fc50f41d1534f32e619bbe0bbbddc64f25)) + +- Propagate caller location through error constructor helpers ([#1392](https://github.com/Devolutions/IronRDP/issues/1392)) ([d6990d81a1](https://github.com/Devolutions/IronRDP/commit/d6990d81a17e8349e52768ad8a82f673b1e1462d)) + + The error constructor helpers in several crates wrap the #[track_caller] + ironrdp_error::Error::new, but were not themselves marked + #[track_caller]. As a result, the captured location pointed at the + helper body instead of the real call site, giving misleading "@ + file:line" info in error reports. + +- Reduce dependency on ironrdp-connector ([#1419](https://github.com/Devolutions/IronRDP/issues/1419)) ([5c22f86a71](https://github.com/Devolutions/IronRDP/commit/5c22f86a7150bc10c26a3be39bfaebf84c67d781)) + + Removes the leftover legacy modules and moves actually useful utilities to ironrdp-pdu crate. + +- [**breaking**] Rework the connection activation API ([#1435](https://github.com/Devolutions/IronRDP/issues/1435)) ([c6a0286dcb](https://github.com/Devolutions/IronRDP/commit/c6a0286dcb49d9ac54c65c4f9325b41e05d541b8)) + + Introduces a ConnectionActivationFactory (exposed on ConnectionResult) + that builds a fresh ConnectionActivationSequence per + Deactivation-Reactivation, replacing ConnectionActivationSequence::reset_clone, + and turns Deactivate-All handling into a bare signal so consumers own the + activation sequence. + +### Build + +- Align sspi and picky dependencies ([#1385](https://github.com/Devolutions/IronRDP/issues/1385)) ([0a461b5d36](https://github.com/Devolutions/IronRDP/commit/0a461b5d366677fd2f0f664a4f0074e4ab697c42)) + + + +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.8.0...ironrdp-connector-v0.9.0)] - 2026-05-27 + +### Features + +- Add alternate_shell and work_dir configuration support ([#1095](https://github.com/Devolutions/IronRDP/issues/1095)) ([a33d27fe67](https://github.com/Devolutions/IronRDP/commit/a33d27fe6771a5a155161ef40a04de88803dd84c)) + + Add support for configuring `alternate_shell` and `work_dir` fields in + ClientInfoPdu, which are used by: + - CyberArk PSM (Privileged Session Manager) for session tokens + - Remote application scenarios (RemoteApp) + - Custom shell configurations + +- Dispatch multitransport PDUs on IO channel ([#1096](https://github.com/Devolutions/IronRDP/issues/1096)) ([7853e3cc6f](https://github.com/Devolutions/IronRDP/commit/7853e3cc6f26acaf3da000c6177ca3cef6ef85fd)) + + `decode_io_channel()` assumes all IO channel PDUs begin with + a `ShareControlHeader`. Multitransport Request PDUs use a + `BasicSecurityHeader` with `SEC_TRANSPORT_REQ` instead ([MS-RDPBCGR] + 2.2.15.1). + + This adds a peek-based dispatch: check the first `u16` + for`TRANSPORT_REQ`, decode as `MultitransportRequestPdu` if set, + otherwise fall through to the existing `decode_share_control()` path + unchanged. + + The new variant is propagated through `ProcessorOutput` and + 'ActiveStageOutput` so applications can handle multitransport requests. + Client and web consumers log the request (no UDP transport yet). + +- Add bulk compression and wire negotiation ([ebf5da5f33](https://github.com/Devolutions/IronRDP/commit/ebf5da5f3380a3355f6c95814d669f8190425ded)) + + Add support for bulk compression negotiation and payload decoding, + including connector plumbing, CLI configuration flags, and integration + updates across tests/examples/FFI/web. + +- Advertise multitransport channel in GCC blocks ([#1092](https://github.com/Devolutions/IronRDP/issues/1092)) ([4f5fdd3628](https://github.com/Devolutions/IronRDP/commit/4f5fdd3628f4d0d2c2a4116e4e45269d802740f1)) + + Add multitransport_flags config option to populate the + MultiTransportChannelData GCC block during connection negotiation. + When None (the default), behavior is unchanged. + +### Bug Fixes + +- Propagate negotiated share_id to all outgoing ShareDataPdu ([#1147](https://github.com/Devolutions/IronRDP/issues/1147)) ([2b24e9664d](https://github.com/Devolutions/IronRDP/commit/2b24e9664dd05620ff63a24d092377477fdde863)) + +- Advertise all colour depths per FreeRDP pattern ([#1231](https://github.com/Devolutions/IronRDP/issues/1231)) ([2fa7c648cb](https://github.com/Devolutions/IronRDP/commit/2fa7c648cb4a2fc9c75d967ac878f817900dc1b8)) + + Replace the per-depth supportedColorDepths bitmask with an unconditional + BPP32 | BPP24 | BPP16 | BPP15, following FreeRDP's approach of treating + the field as a capability set rather than a preferred-depth indicator + (libfreerdp/core/settings.c). + + The preferred depth is expressed via the two dedicated fields: + - highColorDepth: now derived from the configured depth (15 → + Rgb555Bpp16 / 0x0F, 16 → Rgb565Bpp16 / 0x10, else Bpp24 / 0x18), + matching FreeRDP's ColorDepthToHighColor() + - WANT_32_BPP_SESSION earlyCapabilityFlag: unchanged, set only for 32bpp + + Previously, a client configured for 24bpp advertised BPP24 only. Modern + Windows hosts (Server 2012+) dropped 24bpp RDP support and reset the + connection instead of negotiating down, leaving no usable depth. With + all four bits always advertised the server can freely negotiate to the + highest depth it supports. + +- Surface actual PDU type when an unexpected Share Control PDU arrives ([#1236](https://github.com/Devolutions/IronRDP/issues/1236)) ([78effb3f91](https://github.com/Devolutions/IronRDP/commit/78effb3f9144a482395be738b2c9fd4d909b7b89)) + +- Handle ServerDeactivateAll during CapabilitiesExchange ([#1254](https://github.com/Devolutions/IronRDP/issues/1254)) ([9cb5439b4a](https://github.com/Devolutions/IronRDP/commit/9cb5439b4a78c4a7facc854464894c7893f6a926)) + + Some RDP servers (notably GNOME Remote Desktop / grd) send a + ServerDeactivateAll PDU before ServerDemandActive during the initial + Capabilities Exchange phase. This is valid per MS-RDPBCGR §1.3.1.3 + (Deactivation-Reactivation Sequence). + + Previously this caused a hard error: + "unexpected Share Control Pdu (expected ServerDemandActive)" + + Now the connector skips the DeactivateAll and waits for the next PDU. + +### Performance + +- Reduce connection latency when Kerberos is disabled ([#1107](https://github.com/Devolutions/IronRDP/issues/1107)) ([b1b0289e00](https://github.com/Devolutions/IronRDP/commit/b1b0289e0067228dbc973d3edb0e27136f7ca52a)) + +### Build + +- Upgrade to sspi 0.21 and picky rc.23 ([#1296](https://github.com/Devolutions/IronRDP/issues/1296)) ([d5b3fa7db8](https://github.com/Devolutions/IronRDP/commit/d5b3fa7db8a4ce74ac9a9aaff3064faf6cb6c920)) + + +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.7.1...ironrdp-connector-v0.8.0)] - 2025-12-18 + +### Build + +- Bump picky and sspi ([#1028](https://github.com/Devolutions/IronRDP/issues/1028)) ([5bd319126d](https://github.com/Devolutions/IronRDP/commit/5bd319126d32fbd8e505508e27ab2b1a18a83d04)) + + This fixes build issues with some dependencies. + +## [[0.7.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.7.0...ironrdp-connector-v0.7.1)] - 2025-09-04 + +### Features + +- Add API to retrieve registered SVC processors (#938) ([17833fe009](https://github.com/Devolutions/IronRDP/commit/17833fe009279823c4076d3e2e0c7d063fd24a43)) + +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.6.0...ironrdp-connector-v0.7.0)] - 2025-08-29 + +### Features + +- Add QOI image codec ([613fd51f26](https://github.com/Devolutions/IronRDP/commit/613fd51f26315d8212662c46f8e625c541e4bb59)) + + The Quite OK Image format ([1]) losslessly compresses images to a similar size + of PNG, while offering 20x-50x faster encoding and 3x-4x faster decoding. + +- Add QOIZ image codec ([87df67fdc7](https://github.com/Devolutions/IronRDP/commit/87df67fdc76ff4f39d4b83521e34bf3b5e2e73bb)) + + Add a new QOIZ codec for SetSurface command. The PDU data contains the same + data as the QOI codec, with zstd compression. + +- Add an option to specify a timezone (#917) ([6fab9f8228](https://github.com/Devolutions/IronRDP/commit/6fab9f8228578b3c78db131b3c2e0526352116a9)) + +### Bug Fixes + +- [**breaking**] Rename option no_server_pointer into enable_server_pointer ([218fed03c7](https://github.com/Devolutions/IronRDP/commit/218fed03c7993af0f958453e3944c58bcf9f43cb)) + +- [**breaking**] Rename option no_audio_playback into enable_audio_playback ([5d8a487001](https://github.com/Devolutions/IronRDP/commit/5d8a487001c1280cbaf9f581f2a9a2f47d187bf0)) + +### Build + +- Bump rand to 0.9 ([de0877188c](https://github.com/Devolutions/IronRDP/commit/de0877188cbb3692c3ce0d9a72f6e96d515cde1f)) + +- Bump picky from 7.0.0-rc.16 to 7.0.0-rc.17 (#941) ([fe31cf2c57](https://github.com/Devolutions/IronRDP/commit/fe31cf2c574e0b06177a931db4cac95ea9cfbe7e)) + ## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.5.1...ironrdp-connector-v0.6.0)] - 2025-07-08 ### Build @@ -41,7 +212,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [**breaking**] Add supported codecs in BitmapConfig ([f03ee393a3](https://github.com/Devolutions/IronRDP/commit/f03ee393a36906114b5bcba0e88ebc6869a99785)) - ## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.3.2...ironrdp-connector-v0.4.0)] - 2025-03-12 ### Build @@ -56,7 +226,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update dependencies - ## [[0.3.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.3.0...ironrdp-connector-v0.3.1)] - 2025-01-30 ### Bug Fixes @@ -64,7 +233,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Decrease log verbosity for license exchange ([#655](https://github.com/Devolutions/IronRDP/issues/655)) ([c8597733fe](https://github.com/Devolutions/IronRDP/commit/c8597733fe9998318764064c3682506bf82026d2)) - ## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.2.2...ironrdp-connector-v0.3.0)] - 2025-01-28 ### Features @@ -85,7 +253,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump picky from 7.0.0-rc.11 to 7.0.0-rc.12 ([#639](https://github.com/Devolutions/IronRDP/issues/639)) ([a16a131e43](https://github.com/Devolutions/IronRDP/commit/a16a131e4301e0dfafe8f3b73e1a75a3a06cfdc7)) - ## [[0.2.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-connector-v0.2.1...ironrdp-connector-v0.2.2)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-connector/Cargo.toml b/crates/ironrdp-connector/Cargo.toml index dd9bd2feb6..5860900708 100644 --- a/crates/ironrdp-connector/Cargo.toml +++ b/crates/ironrdp-connector/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-connector" -version = "0.6.0" +version = "0.10.0" readme = "README.md" description = "State machines to drive an RDP connection sequence" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -17,23 +18,21 @@ test = false [features] default = [] -arbitrary = ["dep:arbitrary"] qoi = ["ironrdp-pdu/qoi"] qoiz = ["ironrdp-pdu/qoiz"] [dependencies] -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-error = { path = "../ironrdp-error", version = "0.1" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5", features = ["std"] } # public -arbitrary = { version = "1", features = ["derive"], optional = true } # public -sspi = "0.16" # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["std"] } # public +sspi = { version = "0.21", features = ["scard"] } url = "2.5" # public rand = { version = "0.9", features = ["std"] } # TODO: dependency injection? tracing = { version = "0.1", features = ["log"] } picky-asn1-der = "0.5" -picky-asn1-x509 = "0.14" -picky = "7.0.0-rc.17" +picky-asn1-x509 = "0.15" +picky = "=7.0.0-rc.25" # FIXME: We are pinning with = because the candidate version number counts as the minor number by Cargo, and will be automatically bumped in the Cargo.lock. [lints] workspace = true diff --git a/crates/ironrdp-connector/src/channel_connection.rs b/crates/ironrdp-connector/src/channel_connection.rs index eb2908a495..199946aed4 100644 --- a/crates/ironrdp-connector/src/channel_connection.rs +++ b/crates/ironrdp-connector/src/channel_connection.rs @@ -3,16 +3,15 @@ use std::collections::HashSet; use ironrdp_core::WriteBuf; use ironrdp_pdu::x224::X224; -use ironrdp_pdu::{mcs, PduHint}; +use ironrdp_pdu::{PduHint, mcs}; use tracing::{debug, warn}; use crate::{ - general_err, reason_err, ConnectorError, ConnectorErrorExt as _, ConnectorResult, Sequence, State, Written, + ConnectorError, ConnectorErrorExt as _, ConnectorResult, Sequence, State, Written, general_err, reason_err, }; #[derive(Default, Debug)] #[non_exhaustive] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ChannelConnectionState { #[default] Consumed, @@ -56,7 +55,6 @@ impl State for ChannelConnectionState { } #[derive(Debug)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ChannelConnectionSequence { pub state: ChannelConnectionState, pub channel_ids: Option>, @@ -101,7 +99,7 @@ impl Sequence for ChannelConnectionSequence { ChannelConnectionState::Consumed => { return Err(general_err!( "channel connection sequence state is consumed (this is a bug)", - )) + )); } ChannelConnectionState::SendErectDomainRequest => { diff --git a/crates/ironrdp-connector/src/connection.rs b/crates/ironrdp-connector/src/connection.rs index 6852ce7921..3c0dc582b6 100644 --- a/crates/ironrdp-connector/src/connection.rs +++ b/crates/ironrdp-connector/src/connection.rs @@ -3,35 +3,46 @@ use core::net::SocketAddr; use std::borrow::Cow; use std::sync::Arc; -use ironrdp_core::{decode, encode_vec, Encode, WriteBuf}; +use ironrdp_core::{Encode, WriteBuf, decode, encode_vec}; use ironrdp_pdu::x224::X224; -use ironrdp_pdu::{gcc, mcs, nego, rdp, PduHint}; +use ironrdp_pdu::{PduHint, gcc, mcs, nego, rdp}; use ironrdp_svc::{StaticChannelSet, StaticVirtualChannel, SvcClientProcessor}; use tracing::{debug, error, info, warn}; use crate::channel_connection::{ChannelConnectionSequence, ChannelConnectionState}; -use crate::connection_activation::{ConnectionActivationSequence, ConnectionActivationState}; +use crate::connection_activation::{ + ConnectionActivationFactory, ConnectionActivationSequence, ConnectionActivationState, +}; use crate::license_exchange::{LicenseExchangeSequence, NoopLicenseCache}; use crate::{ - encode_x224_packet, general_err, reason_err, Config, ConnectorError, ConnectorErrorExt as _, ConnectorErrorKind, - ConnectorResult, DesktopSize, NegotiationFailure, Sequence, State, Written, + Config, ConnectorError, ConnectorErrorExt as _, ConnectorErrorKind, ConnectorResult, DesktopSize, + NegotiationFailure, Sequence, State, Written, encode_x224_packet, general_err, reason_err, }; #[derive(Debug)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ConnectionResult { pub io_channel_id: u16, pub user_channel_id: u16, + /// MCS channel ID of the message channel, when one was negotiated. + pub message_channel_id: Option, + pub share_id: u32, pub static_channels: StaticChannelSet, pub desktop_size: DesktopSize, pub enable_server_pointer: bool, pub pointer_software_rendering: bool, - pub connection_activation: ConnectionActivationSequence, + /// Factory for producing connection activation sequences. + /// + /// Used to drive the [Deactivation-Reactivation Sequence] when a Server Deactivate All PDU is + /// received: produce a fresh sequence, drive it to completion, then drop it. + /// + /// [Deactivation-Reactivation Sequence]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 + pub activation_factory: ConnectionActivationFactory, + /// The bulk compression type that was negotiated, if any. + pub compression_type: Option, } #[derive(Default, Debug)] #[non_exhaustive] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ClientConnectorState { #[default] Consumed, @@ -119,13 +130,14 @@ impl State for ClientConnectorState { } #[derive(Debug)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientConnector { pub config: Config, pub state: ClientConnectorState, /// The client address to be used in the Client Info PDU. pub client_addr: SocketAddr, pub static_channels: StaticChannelSet, + /// MCS message channel ID assigned by the server, once negotiated. + pub message_channel_id: Option, } impl ClientConnector { @@ -135,6 +147,7 @@ impl ClientConnector { state: ClientConnectorState::ConnectionInitiationSendRequest, client_addr, static_channels: StaticChannelSet::new(), + message_channel_id: None, } } @@ -154,10 +167,31 @@ impl ClientConnector { self.static_channels.insert(channel); } + pub fn get_static_channel_processor(&mut self) -> Option<&T> + where + T: SvcClientProcessor + 'static, + { + self.static_channels + .get_by_type::() + .and_then(|channel| channel.channel_processor_downcast_ref()) + } + + pub fn get_static_channel_processor_mut(&mut self) -> Option<&mut T> + where + T: SvcClientProcessor + 'static, + { + self.static_channels + .get_by_type_mut::() + .and_then(|channel| channel.channel_processor_downcast_mut()) + } + pub fn should_perform_security_upgrade(&self) -> bool { matches!(self.state, ClientConnectorState::EnhancedSecurityUpgrade { .. }) } + /// # Panics + /// + /// Panics if state is not [ClientConnectorState::EnhancedSecurityUpgrade]. pub fn mark_security_upgrade_as_done(&mut self) { assert!(self.should_perform_security_upgrade()); self.step(&[], &mut WriteBuf::new()).expect("transition to next state"); @@ -168,6 +202,9 @@ impl ClientConnector { matches!(self.state, ClientConnectorState::Credssp { .. }) } + /// # Panics + /// + /// Panics if state is not [ClientConnectorState::Credssp]. pub fn mark_credssp_as_done(&mut self) { assert!(self.should_perform_credssp()); let res = self.step(&[], &mut WriteBuf::new()).expect("transition to next state"); @@ -176,6 +213,31 @@ impl ClientConnector { } } +fn advance_licensing_exchange( + mut license_exchange: LicenseExchangeSequence, + io_channel_id: u16, + user_channel_id: u16, + input: &[u8], + output: &mut WriteBuf, +) -> ConnectorResult<(Written, ClientConnectorState)> { + let written = license_exchange.step(input, output)?; + + let next_state = if license_exchange.state.is_terminal() { + ClientConnectorState::MultitransportBootstrapping { + io_channel_id, + user_channel_id, + } + } else { + ClientConnectorState::LicensingExchange { + io_channel_id, + user_channel_id, + license_exchange, + } + }; + + Ok((written, next_state)) +} + impl Sequence for ClientConnector { fn next_pdu_hint(&self) -> Option<&dyn PduHint> { match &self.state { @@ -188,7 +250,20 @@ impl Sequence for ClientConnector { ClientConnectorState::BasicSettingsExchangeWaitResponse { .. } => Some(&ironrdp_pdu::X224_HINT), ClientConnectorState::ChannelConnection { channel_connection, .. } => channel_connection.next_pdu_hint(), ClientConnectorState::SecureSettingsExchange { .. } => None, - ClientConnectorState::ConnectTimeAutoDetection { .. } => None, + ClientConnectorState::ConnectTimeAutoDetection { .. } => { + // Wait for input only when a message channel was negotiated, so + // we can receive connect-time auto-detect requests there. With a + // message channel the server always sends a PDU next in this phase + // (a connect-time Auto-Detect Request on the message channel, or + // the first licensing PDU on the I/O channel), so waiting here + // cannot stall. Without one, this state reads nothing and + // transitions straight to licensing. + if self.message_channel_id.is_some() { + Some(&ironrdp_pdu::X224_HINT) + } else { + None + } + } ClientConnectorState::LicensingExchange { license_exchange, .. } => license_exchange.next_pdu_hint(), ClientConnectorState::MultitransportBootstrapping { .. } => None, ClientConnectorState::CapabilitiesExchange { @@ -209,7 +284,7 @@ impl Sequence for ClientConnector { let (written, next_state) = match mem::take(&mut self.state) { // Invalid state ClientConnectorState::Consumed => { - return Err(general_err!("connector sequence state is consumed (this is a bug)",)) + return Err(general_err!("connector sequence state is consumed (this is a bug)",)); } //== Connection Initiation ==// @@ -327,7 +402,8 @@ impl Sequence for ClientConnector { let client_gcc_blocks = create_gcc_blocks(&self.config, selected_protocol, self.static_channels.values())?; - let connect_initial = mcs::ConnectInitial::with_gcc_blocks(client_gcc_blocks); + let connect_initial = + mcs::ConnectInitial::with_gcc_blocks(client_gcc_blocks).map_err(ConnectorError::decode)?; debug!(message = ?connect_initial, "Send"); @@ -347,9 +423,9 @@ impl Sequence for ClientConnector { debug!(message = ?connect_response, "Received"); - let client_gcc_blocks = &connect_initial.conference_create_request.gcc_blocks; + let client_gcc_blocks = connect_initial.conference_create_request.gcc_blocks(); - let server_gcc_blocks = connect_response.conference_create_response.gcc_blocks; + let server_gcc_blocks = connect_response.conference_create_response.into_gcc_blocks(); if client_gcc_blocks.security == gcc::ClientSecurityData::no_security() && server_gcc_blocks.security != gcc::ServerSecurityData::no_security() @@ -357,9 +433,10 @@ impl Sequence for ClientConnector { return Err(general_err!("can't satisfy server security settings")); } - if server_gcc_blocks.message_channel.is_some() { - warn!("Unexpected ServerMessageChannelData GCC block (not supported)"); - } + self.message_channel_id = server_gcc_blocks + .message_channel + .as_ref() + .map(|data| data.mcs_message_channel_id); if server_gcc_blocks.multi_transport_channel.is_some() { warn!("Unexpected MultiTransportChannelData GCC block (not supported)"); @@ -393,7 +470,9 @@ impl Sequence for ClientConnector { channel_connection: if skip_channel_join { ChannelConnectionSequence::skip_channel_join() } else { - ChannelConnectionSequence::new(io_channel_id, static_channel_ids) + let mut join_channel_ids = static_channel_ids; + join_channel_ids.extend(self.message_channel_id); + ChannelConnectionSequence::new(io_channel_id, join_channel_ids) }, }, ) @@ -460,12 +539,59 @@ impl Sequence for ClientConnector { ClientConnectorState::ConnectTimeAutoDetection { io_channel_id, user_channel_id, - } => ( - Written::Nothing, - ClientConnectorState::LicensingExchange { - io_channel_id, - user_channel_id, - license_exchange: LicenseExchangeSequence::new( + } => { + // The server may run Optional Connect-Time Auto-Detection on the + // message channel before licensing ([MS-RDPBCGR] 1.3.8). When a + // message channel was negotiated we wait for a PDU here and demux + // by MCS channel: a PDU on the message channel is never a licensing + // PDU, so it must not be handed to the licensing sequence. An + // auto-detect request is answered and we keep listening; any other + // message-channel PDU is not ours to act on in this phase and is + // ignored. The first PDU that is not on the message channel (the + // licensing PDU on the I/O channel) ends the phase. Without a + // message channel nothing is read and we go straight to licensing, + // as before. + // Decode the inbound PDU once and demux on the MCS channel. + let message_channel_pdu = self.message_channel_id.and_then(|message_channel_id| { + let mcs = decode::>>(input).ok()?; + match mcs.0 { + mcs::McsMessage::SendDataIndication(data) if data.channel_id == message_channel_id => { + Some((message_channel_id, data)) + } + _ => None, + } + }); + + if let Some((message_channel_id, data)) = message_channel_pdu { + if let Ok(autodetect) = decode::(&data.user_data) { + let written = respond_to_connect_time_autodetect( + autodetect.request, + message_channel_id, + user_channel_id, + output, + )?; + ( + written, + ClientConnectorState::ConnectTimeAutoDetection { + io_channel_id, + user_channel_id, + }, + ) + } else { + // A message-channel PDU we do not handle in this phase (per the + // canonical sequence multitransport bootstrap is Phase 8 and + // heartbeat is post-connection, both after licensing). Ignore it + // and keep listening rather than decoding it as a licensing PDU. + ( + Written::Nothing, + ClientConnectorState::ConnectTimeAutoDetection { + io_channel_id, + user_channel_id, + }, + ) + } + } else { + let license_exchange = LicenseExchangeSequence::new( io_channel_id, self.config.credentials.username().unwrap_or("").to_owned(), self.config.domain.clone(), @@ -474,9 +600,27 @@ impl Sequence for ClientConnector { .license_cache .clone() .unwrap_or_else(|| Arc::new(NoopLicenseCache)), - ), - }, - ), + ); + // If a PDU was read (message channel present) it is the first + // licensing PDU; advance the licensing sequence with it now, + // through the same helper the LicensingExchange state uses, so + // the terminal-state transition lives in one place. Otherwise + // nothing was read and the licensing sequence runs from its + // first step when the next PDU arrives. + if self.message_channel_id.is_some() { + advance_licensing_exchange(license_exchange, io_channel_id, user_channel_id, input, output)? + } else { + ( + Written::Nothing, + ClientConnectorState::LicensingExchange { + io_channel_id, + user_channel_id, + license_exchange, + }, + ) + } + } + } //== Licensing ==// // Server is sending information regarding licensing. @@ -484,26 +628,11 @@ impl Sequence for ClientConnector { ClientConnectorState::LicensingExchange { io_channel_id, user_channel_id, - mut license_exchange, + license_exchange, } => { debug!("Licensing Exchange"); - let written = license_exchange.step(input, output)?; - - let next_state = if license_exchange.state.is_terminal() { - ClientConnectorState::MultitransportBootstrapping { - io_channel_id, - user_channel_id, - } - } else { - ClientConnectorState::LicensingExchange { - io_channel_id, - user_channel_id, - license_exchange, - } - }; - - (written, next_state) + advance_licensing_exchange(license_exchange, io_channel_id, user_channel_id, input, output)? } //== Optional Multitransport Bootstrapping ==// @@ -528,11 +657,19 @@ impl Sequence for ClientConnector { mut connection_activation, } => { let written = connection_activation.step(input, output)?; - match connection_activation.state { + match connection_activation.connection_activation_state() { ConnectionActivationState::ConnectionFinalization { .. } => ( written, ClientConnectorState::ConnectionFinalization { connection_activation }, ), + // The inner sequence stays in CapabilitiesExchange when it receives a + // Server Deactivate All PDU before the Server Demand Active PDU (sent + // by e.g. Windows Server and gnome-remote-desktop); mirror it here and + // wait for the next input. + ConnectionActivationState::CapabilitiesExchange => ( + written, + ClientConnectorState::CapabilitiesExchange { connection_activation }, + ), _ => return Err(general_err!("invalid state (this is a bug)")), } } @@ -545,25 +682,31 @@ impl Sequence for ClientConnector { } => { let written = connection_activation.step(input, output)?; - let next_state = if !connection_activation.state.is_terminal() { + let next_state = if !connection_activation.connection_activation_state().is_terminal() { ClientConnectorState::ConnectionFinalization { connection_activation } } else { - match connection_activation.state { + match connection_activation.connection_activation_state() { ConnectionActivationState::Finalized { - io_channel_id, - user_channel_id, desktop_size, + share_id, enable_server_pointer, pointer_software_rendering, } => ClientConnectorState::Connected { result: ConnectionResult { - io_channel_id, - user_channel_id, + io_channel_id: connection_activation.io_channel_id(), + user_channel_id: connection_activation.user_channel_id(), + message_channel_id: self.message_channel_id, + share_id, static_channels: mem::take(&mut self.static_channels), desktop_size, enable_server_pointer, pointer_software_rendering, - connection_activation, + activation_factory: ConnectionActivationFactory::new( + self.config.clone(), + connection_activation.io_channel_id(), + connection_activation.user_channel_id(), + ), + compression_type: self.config.compression_type, }, }, _ => return Err(general_err!("invalid state (this is a bug)")), @@ -603,6 +746,32 @@ pub fn encode_send_data_request( Ok(written) } +fn respond_to_connect_time_autodetect( + request: rdp::autodetect::AutoDetectRequest, + message_channel_id: u16, + user_channel_id: u16, + output: &mut WriteBuf, +) -> ConnectorResult { + use ironrdp_pdu::rdp::autodetect::{AutoDetectRequest, AutoDetectResponse, AutoDetectRspPdu}; + + match request { + AutoDetectRequest::RttRequest { sequence_number, .. } => { + let response = AutoDetectRspPdu::new(AutoDetectResponse::RttResponse { sequence_number }); + let written = encode_send_data_request(user_channel_id, message_channel_id, &response, output)?; + Written::from_size(written) + } + // Only RTT is answered at connect time. A connect-time Bandwidth Measure + // Stop ([MS-RDPBCGR] 2.2.14.1.4) is defined to warrant a Bandwidth Measure + // Results reply, and the Network Characteristics Result is informational. + // We deliberately send neither: connect-time auto-detect is informational + // and the server proceeds to licensing whether or not it receives them, so + // skipping them does not stall the sequence. Full connect-time bandwidth + // measurement (replying to Bandwidth Measure Stop with Bandwidth Measure + // Results) is left for a follow-up. + _ => Ok(Written::Nothing), + } +} + #[expect(single_use_lifetimes)] // anonymous lifetimes in `impl Trait` are unstable fn create_gcc_blocks<'a>( config: &Config, @@ -617,19 +786,28 @@ fn create_gcc_blocks<'a>( let max_color_depth = config.bitmap.as_ref().map(|bitmap| bitmap.color_depth).unwrap_or(32); - let supported_color_depths = match max_color_depth { - 15 => SupportedColorDepths::BPP15, - 16 => SupportedColorDepths::BPP16, - 24 => SupportedColorDepths::BPP24, - 32 => SupportedColorDepths::BPP32 | SupportedColorDepths::BPP16, + // Derive the preferred depth indicator. 32bpp has no highColorDepth value; it is + // expressed via WANT_32_BPP_SESSION in earlyCapabilityFlags instead. + let high_color_depth = match max_color_depth { + 15 => HighColorDepth::Rgb555Bpp16, + 16 => HighColorDepth::Rgb565Bpp16, + 24 | 32 => HighColorDepth::Bpp24, _ => { return Err(reason_err!( "create gcc blocks", "unsupported color depth: {max_color_depth}" - )) + )); } }; + // Advertise all colour depth capabilities unconditionally. The preferred depth is + // expressed via highColorDepth and WANT_32_BPP_SESSION, not by restricting this + // bitmask. This lets servers negotiate down without resetting the connection. + let supported_color_depths = SupportedColorDepths::BPP32 + | SupportedColorDepths::BPP24 + | SupportedColorDepths::BPP16 + | SupportedColorDepths::BPP15; + let channels = static_channels .map(ironrdp_svc::make_channel_definition) .collect::>(); @@ -652,12 +830,13 @@ fn create_gcc_blocks<'a>( post_beta2_color_depth: Some(ColorDepth::Bpp8), // ignored because we set high_color_depth client_product_id: Some(1), serial_number: Some(0), - high_color_depth: Some(HighColorDepth::Bpp24), + high_color_depth: Some(high_color_depth), supported_color_depths: Some(supported_color_depths), early_capability_flags: { let mut early_capability_flags = ClientEarlyCapabilityFlags::VALID_CONNECTION_TYPE | ClientEarlyCapabilityFlags::SUPPORT_ERR_INFO_PDU | ClientEarlyCapabilityFlags::STRONG_ASYMMETRIC_KEYS + | ClientEarlyCapabilityFlags::SUPPORT_NET_CHAR_AUTODETECT | ClientEarlyCapabilityFlags::SUPPORT_SKIP_CHANNELJOIN; // TODO(#136): support for ClientEarlyCapabilityFlags::SUPPORT_STATUS_INFO_PDU @@ -674,9 +853,9 @@ fn create_gcc_blocks<'a>( desktop_physical_width: Some(0), // 0 per FreeRDP desktop_physical_height: Some(0), // 0 per FreeRDP desktop_orientation: if config.desktop_size.width > config.desktop_size.height { - Some(MonitorOrientation::Landscape as u16) + Some(MonitorOrientation::Landscape.as_u16()) } else { - Some(MonitorOrientation::Portrait as u16) + Some(MonitorOrientation::Portrait.as_u16()) }, desktop_scale_factor: Some(config.desktop_scale_factor), device_scale_factor: if config.desktop_scale_factor >= 100 && config.desktop_scale_factor <= 500 { @@ -698,21 +877,24 @@ fn create_gcc_blocks<'a>( // TODO(#139): support for Some(ClientClusterData { flags: RedirectionFlags::REDIRECTION_SUPPORTED, redirection_version: RedirectionVersion::V4, redirected_session_id: 0, }), cluster: None, monitor: None, - // TODO(#140): support for Client Message Channel Data (https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/f50e791c-de03-4b25-b17e-e914c9020bc3) - message_channel: None, - // TODO(#140): support for Some(MultiTransportChannelData { flags: MultiTransportFlags::empty(), }) - multi_transport_channel: None, + // Request the MCS message channel, which carries network auto-detect + // ([MS-RDPBCGR] 2.2.14) and the multitransport / heartbeat PDUs. The + // server assigns its ID in Server Message Channel Data. + message_channel: Some(gcc::ClientMessageChannelData), + multi_transport_channel: config + .multitransport_flags + .map(|flags| gcc::MultiTransportChannelData { flags }), monitor_extended: None, }) } fn create_client_info_pdu(config: &Config, client_addr: &SocketAddr) -> rdp::ClientInfoPdu { + use ironrdp_pdu::rdp::ClientInfoPdu; use ironrdp_pdu::rdp::client_info::{ AddressFamily, ClientInfo, ClientInfoFlags, CompressionType, Credentials, ExtendedClientInfo, ExtendedClientOptionalInfo, }; use ironrdp_pdu::rdp::headers::{BasicSecurityHeader, BasicSecurityHeaderFlags}; - use ironrdp_pdu::rdp::ClientInfoPdu; let security_header = BasicSecurityHeader { flags: BasicSecurityHeaderFlags::INFO_PKT, @@ -741,6 +923,15 @@ fn create_client_info_pdu(config: &Config, client_addr: &SocketAddr) -> rdp::Cli flags |= ClientInfoFlags::NO_AUDIO_PLAYBACK; } + // Advertise bulk compression support if configured + let compression_type = if let Some(ct) = config.compression_type { + flags |= ClientInfoFlags::COMPRESSION; + info!(compression_type = ?ct, "Advertising bulk compression in Client Info PDU"); + ct + } else { + CompressionType::K8 // ignored if ClientInfoFlags::COMPRESSION is not set + }; + let client_info = ClientInfo { credentials: Credentials { username: config.credentials.username().unwrap_or("").to_owned(), @@ -749,9 +940,9 @@ fn create_client_info_pdu(config: &Config, client_addr: &SocketAddr) -> rdp::Cli }, code_page: 0, // ignored if the keyboardLayout field of the Client Core Data is set to zero flags, - compression_type: CompressionType::K8, // ignored if ClientInfoFlags::COMPRESSION is not set - alternate_shell: String::new(), - work_dir: String::new(), + compression_type, + alternate_shell: config.alternate_shell.clone(), + work_dir: config.work_dir.clone(), extra_info: ExtendedClientInfo { address_family: match client_addr { SocketAddr::V4(_) => AddressFamily::INET, diff --git a/crates/ironrdp-connector/src/connection_activation.rs b/crates/ironrdp-connector/src/connection_activation.rs index d70b5ddf76..b1a0ef2d91 100644 --- a/crates/ironrdp-connector/src/connection_activation.rs +++ b/crates/ironrdp-connector/src/connection_activation.rs @@ -1,11 +1,12 @@ use core::mem; +use ironrdp_pdu::rdp; use ironrdp_pdu::rdp::capability_sets::CapabilitySet; -use ironrdp_pdu::rdp::{self}; use tracing::{debug, warn}; use crate::{ - general_err, legacy, Config, ConnectionFinalizationSequence, ConnectorResult, DesktopSize, Sequence, State, Written, + Config, ConnectionFinalizationSequence, ConnectorError, ConnectorErrorExt as _, ConnectorResult, DesktopSize, + Sequence, State, Written, general_err, reason_err, }; /// Represents the Capability Exchange and Connection Finalization phases @@ -22,52 +23,80 @@ use crate::{ /// [Server Deactivate All PDU]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/8a29971a-df3c-48da-add2-8ed9a05edc89 #[derive(Debug, Clone)] pub struct ConnectionActivationSequence { - pub state: ConnectionActivationState, + state: ConnectionActivationState, config: Config, + // The MCS channel IDs are invariant for the whole life of the sequence: they are negotiated + // once and never change, even across a Deactivation-Reactivation Sequence. They are stored + // here (rather than duplicated into every state variant). + io_channel_id: u16, + user_channel_id: u16, } impl ConnectionActivationSequence { pub fn new(config: Config, io_channel_id: u16, user_channel_id: u16) -> Self { + // TODO/FIXME: Investigate whether we really need to carry around the whole `Config` struct. + // RATIONALE(@CBenoit): Not very convenient when building in isolation. + // I doubt this type really needs every field there. Self { - state: ConnectionActivationState::CapabilitiesExchange { - io_channel_id, - user_channel_id, - }, + state: ConnectionActivationState::CapabilitiesExchange, config, + io_channel_id, + user_channel_id, } } - #[must_use] - pub fn reset_clone(&self) -> Self { - self.clone().reset() + pub fn io_channel_id(&self) -> u16 { + self.io_channel_id } - fn reset(mut self) -> Self { - match &self.state { - ConnectionActivationState::CapabilitiesExchange { - io_channel_id, - user_channel_id, - } - | ConnectionActivationState::ConnectionFinalization { - io_channel_id, - user_channel_id, - .. - } - | ConnectionActivationState::Finalized { - io_channel_id, - user_channel_id, - .. - } => { - self.state = ConnectionActivationState::CapabilitiesExchange { - io_channel_id: *io_channel_id, - user_channel_id: *user_channel_id, - }; + pub fn user_channel_id(&self) -> u16 { + self.user_channel_id + } - self - } - ConnectionActivationState::Consumed => self, + /// Returns the current state as a distinct type, rather than `&dyn State` provided by [`Self::state`]. + pub fn connection_activation_state(&self) -> ConnectionActivationState { + self.state + } +} + +/// Factory producing fresh [`ConnectionActivationSequence`] instances. +/// +/// The [`Config`] and MCS channel IDs required to build a connection activation sequence are +/// invariant for the whole lifetime of the connection: they are negotiated once and never change, +/// even across a [Deactivation-Reactivation Sequence]. This factory captures them so that a fresh, +/// correctly-initialized sequence can be produced each time one is needed, driven until it is +/// finalized, then dropped. +/// +/// [Deactivation-Reactivation Sequence]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 +#[derive(Debug, Clone)] +pub struct ConnectionActivationFactory { + config: Config, + io_channel_id: u16, + user_channel_id: u16, +} + +impl ConnectionActivationFactory { + pub fn new(config: Config, io_channel_id: u16, user_channel_id: u16) -> Self { + Self { + config, + io_channel_id, + user_channel_id, } } + + pub fn io_channel_id(&self) -> u16 { + self.io_channel_id + } + + pub fn user_channel_id(&self) -> u16 { + self.user_channel_id + } + + /// Produces a fresh [`ConnectionActivationSequence`] in the initial `CapabilitiesExchange` state. + #[must_use] + pub fn create(&self) -> ConnectionActivationSequence { + ConnectionActivationSequence::new(self.config.clone(), self.io_channel_id, self.user_channel_id) + } } impl Sequence for ConnectionActivationSequence { @@ -75,7 +104,7 @@ impl Sequence for ConnectionActivationSequence { match &self.state { ConnectionActivationState::Consumed => None, ConnectionActivationState::Finalized { .. } => None, - ConnectionActivationState::CapabilitiesExchange { .. } => Some(&ironrdp_pdu::X224_HINT), + ConnectionActivationState::CapabilitiesExchange => Some(&ironrdp_pdu::X224_HINT), ConnectionActivationState::ConnectionFinalization { connection_finalization, .. @@ -94,31 +123,50 @@ impl Sequence for ConnectionActivationSequence { "connector sequence state is finalized or consumed (this is a bug)" )); } - ConnectionActivationState::CapabilitiesExchange { - io_channel_id, - user_channel_id, - } => { + ConnectionActivationState::CapabilitiesExchange => { debug!("Capabilities Exchange"); - let send_data_indication_ctx = legacy::decode_send_data_indication(input)?; - let share_control_ctx = legacy::decode_share_control(send_data_indication_ctx)?; + let send_data_indication_ctx = + ironrdp_pdu::mcs::decode_send_data_indication(input).map_err(ConnectorError::decode)?; + let share_control_ctx = + rdp::headers::decode_share_control(send_data_indication_ctx).map_err(ConnectorError::decode)?; debug!(message = ?share_control_ctx.pdu, "Received"); - if share_control_ctx.channel_id != io_channel_id { + if share_control_ctx.channel_id != self.io_channel_id { warn!( - io_channel_id, + io_channel_id = self.io_channel_id, share_control_ctx.channel_id, "Unexpected channel ID for received Share Control Pdu" ); } + // Some servers (e.g. GNOME Remote Desktop) send a ServerDeactivateAll PDU + // before ServerDemandActive as part of a Deactivation-Reactivation Sequence + // (MS-RDPBCGR §1.3.1.3). Skip it and stay in the same state to wait for + // the actual DemandActive PDU. + // + // The decoded PDU is intentionally discarded: the DeactivateAll body carries + // no payload we need during initial activation. + if matches!( + share_control_ctx.pdu, + rdp::headers::ShareControlPdu::ServerDeactivateAll(_) + ) { + debug!( + "Skipping Server Deactivate All PDU received during Capabilities Exchange, awaiting Server Demand Active" + ); + self.state = ConnectionActivationState::CapabilitiesExchange; + return Ok(Written::Nothing); + } + let capability_sets = if let rdp::headers::ShareControlPdu::ServerDemandActive(server_demand_active) = share_control_ctx.pdu { server_demand_active.pdu.capability_sets } else { - return Err(general_err!( - "unexpected Share Control Pdu (expected ServerDemandActive)", + return Err(reason_err!( + "ConnectionActivation::CapabilitiesExchange", + "unexpected Share Control PDU during capabilities exchange: got {} (expected Server Demand Active PDU)", + share_control_ctx.pdu.as_short_name(), )); }; @@ -154,34 +202,39 @@ impl Sequence for ConnectionActivationSequence { height: self.config.desktop_size.height, }); + let share_id = share_control_ctx.share_id; + let client_confirm_active = rdp::headers::ShareControlPdu::ClientConfirmActive( create_client_confirm_active(&self.config, capability_sets, desktop_size), ); debug!(message = ?client_confirm_active, "Send"); - let written = legacy::encode_share_control( - user_channel_id, - io_channel_id, - share_control_ctx.share_id, + let written = rdp::headers::encode_share_control( + self.user_channel_id, + self.io_channel_id, + share_id, client_confirm_active, output, - )?; + ) + .map_err(ConnectorError::encode)?; ( Written::from_size(written)?, ConnectionActivationState::ConnectionFinalization { - io_channel_id, - user_channel_id, desktop_size, - connection_finalization: ConnectionFinalizationSequence::new(io_channel_id, user_channel_id), + share_id, + connection_finalization: ConnectionFinalizationSequence::new( + self.io_channel_id, + self.user_channel_id, + share_id, + ), }, ) } ConnectionActivationState::ConnectionFinalization { - io_channel_id, - user_channel_id, desktop_size, + share_id, mut connection_finalization, } => { debug!("Connection Finalization"); @@ -190,16 +243,14 @@ impl Sequence for ConnectionActivationSequence { let next_state = if !connection_finalization.state.is_terminal() { ConnectionActivationState::ConnectionFinalization { - io_channel_id, - user_channel_id, desktop_size, + share_id, connection_finalization, } } else { ConnectionActivationState::Finalized { - io_channel_id, - user_channel_id, desktop_size, + share_id, enable_server_pointer: self.config.enable_server_pointer, pointer_software_rendering: self.config.pointer_software_rendering, } @@ -215,24 +266,19 @@ impl Sequence for ConnectionActivationSequence { } } -#[derive(Default, Debug, Clone)] +#[derive(Default, Debug, Copy, Clone)] pub enum ConnectionActivationState { #[default] Consumed, - CapabilitiesExchange { - io_channel_id: u16, - user_channel_id: u16, - }, + CapabilitiesExchange, ConnectionFinalization { - io_channel_id: u16, - user_channel_id: u16, desktop_size: DesktopSize, + share_id: u32, connection_finalization: ConnectionFinalizationSequence, }, Finalized { - io_channel_id: u16, - user_channel_id: u16, desktop_size: DesktopSize, + share_id: u32, enable_server_pointer: bool, pointer_software_rendering: bool, }, @@ -242,7 +288,7 @@ impl State for ConnectionActivationState { fn name(&self) -> &'static str { match self { ConnectionActivationState::Consumed => "Consumed", - ConnectionActivationState::CapabilitiesExchange { .. } => "CapabilitiesExchange", + ConnectionActivationState::CapabilitiesExchange => "CapabilitiesExchange", ConnectionActivationState::ConnectionFinalization { .. } => "ConnectionFinalization", ConnectionActivationState::Finalized { .. } => "Finalized", } @@ -265,12 +311,11 @@ fn create_client_confirm_active( desktop_size: DesktopSize, ) -> rdp::capability_sets::ClientConfirmActive { use ironrdp_pdu::rdp::capability_sets::{ - client_codecs_capabilities, Bitmap, BitmapCache, BitmapDrawingFlags, Brush, CacheDefinition, CacheEntry, - ClientConfirmActive, CmdFlags, DemandActive, FrameAcknowledge, General, GeneralExtraFlags, GlyphCache, - GlyphSupportLevel, Input, InputFlags, LargePointer, LargePointerSupportFlags, MultifragmentUpdate, - OffscreenBitmapCache, Order, OrderFlags, OrderSupportExFlags, Pointer, Sound, SoundFlags, SupportLevel, - SurfaceCommands, VirtualChannel, VirtualChannelFlags, BITMAP_CACHE_ENTRIES_NUM, GLYPH_CACHE_NUM, - SERVER_CHANNEL_ID, + BITMAP_CACHE_ENTRIES_NUM, Bitmap, BitmapCache, BitmapDrawingFlags, Brush, CacheDefinition, CacheEntry, + ClientConfirmActive, CmdFlags, DemandActive, FrameAcknowledge, GLYPH_CACHE_NUM, General, GeneralExtraFlags, + GlyphCache, GlyphSupportLevel, Input, InputFlags, LargePointer, LargePointerSupportFlags, MultifragmentUpdate, + OffscreenBitmapCache, Order, OrderFlags, OrderSupportExFlags, Pointer, SERVER_CHANNEL_ID, Sound, SoundFlags, + SupportLevel, SurfaceCommands, VirtualChannel, VirtualChannelFlags, client_codecs_capabilities, }; server_capability_sets.retain(|capability_set| matches!(capability_set, CapabilitySet::MultiFragmentUpdate(_))); @@ -365,13 +410,10 @@ fn create_client_confirm_active( CapabilitySet::SurfaceCommands(SurfaceCommands { flags: CmdFlags::SET_SURFACE_BITS | CmdFlags::STREAM_SURFACE_BITS | CmdFlags::FRAME_MARKER, }), - CapabilitySet::BitmapCodecs( - config - .bitmap - .as_ref() - .map(|b| b.codecs.clone()) - .unwrap_or_else(|| client_codecs_capabilities(&[]).unwrap()), - ), + CapabilitySet::BitmapCodecs(match config.bitmap.as_ref().map(|b| b.codecs.clone()) { + Some(codecs) => codecs, + None => client_codecs_capabilities(&[]).expect("can't panic for &[]"), + }), CapabilitySet::FrameAcknowledge(FrameAcknowledge { // FIXME(#447): Revert this to 2 per FreeRDP. // This is a temporary hack to fix a resize bug, see: diff --git a/crates/ironrdp-connector/src/connection_finalization.rs b/crates/ironrdp-connector/src/connection_finalization.rs index 6267822ca9..1ad0e3357c 100644 --- a/crates/ironrdp-connector/src/connection_finalization.rs +++ b/crates/ironrdp-connector/src/connection_finalization.rs @@ -1,17 +1,18 @@ use core::mem; use ironrdp_core::WriteBuf; +use ironrdp_pdu::PduHint; use ironrdp_pdu::rdp::capability_sets::SERVER_CHANNEL_ID; use ironrdp_pdu::rdp::headers::ShareDataPdu; use ironrdp_pdu::rdp::{finalization_messages, server_error_info}; -use ironrdp_pdu::PduHint; use tracing::{debug, warn}; -use crate::{general_err, legacy, reason_err, ConnectorResult, Sequence, State, Written}; +use crate::{ + ConnectorError, ConnectorErrorExt as _, ConnectorResult, Sequence, State, Written, general_err, reason_err, +}; -#[derive(Default, Debug, Clone)] +#[derive(Default, Debug, Copy, Clone)] #[non_exhaustive] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ConnectionFinalizationState { #[default] Consumed, @@ -48,20 +49,21 @@ impl State for ConnectionFinalizationState { } } -#[derive(Debug, Clone)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Copy, Clone)] pub struct ConnectionFinalizationSequence { pub state: ConnectionFinalizationState, pub io_channel_id: u16, pub user_channel_id: u16, + pub share_id: u32, } impl ConnectionFinalizationSequence { - pub fn new(io_channel_id: u16, user_channel_id: u16) -> Self { + pub fn new(io_channel_id: u16, user_channel_id: u16, share_id: u32) -> Self { Self { state: ConnectionFinalizationState::SendSynchronize, io_channel_id, user_channel_id, + share_id, } } } @@ -88,7 +90,7 @@ impl Sequence for ConnectionFinalizationSequence { ConnectionFinalizationState::Consumed => { return Err(general_err!( "connection finalization sequence state is consumed (this is a bug)", - )) + )); } ConnectionFinalizationState::SendSynchronize => { @@ -98,7 +100,14 @@ impl Sequence for ConnectionFinalizationSequence { debug!(?message, "Send"); - let written = legacy::encode_share_data(self.user_channel_id, self.io_channel_id, 0, message, output)?; + let written = ironrdp_pdu::rdp::headers::encode_share_data( + self.user_channel_id, + self.io_channel_id, + self.share_id, + message, + output, + ) + .map_err(ConnectorError::encode)?; ( Written::from_size(written)?, @@ -115,7 +124,14 @@ impl Sequence for ConnectionFinalizationSequence { debug!(?message, "Send"); - let written = legacy::encode_share_data(self.user_channel_id, self.io_channel_id, 0, message, output)?; + let written = ironrdp_pdu::rdp::headers::encode_share_data( + self.user_channel_id, + self.io_channel_id, + self.share_id, + message, + output, + ) + .map_err(ConnectorError::encode)?; ( Written::from_size(written)?, @@ -132,7 +148,14 @@ impl Sequence for ConnectionFinalizationSequence { debug!(?message, "Send"); - let written = legacy::encode_share_data(self.user_channel_id, self.io_channel_id, 0, message, output)?; + let written = ironrdp_pdu::rdp::headers::encode_share_data( + self.user_channel_id, + self.io_channel_id, + self.share_id, + message, + output, + ) + .map_err(ConnectorError::encode)?; (Written::from_size(written)?, ConnectionFinalizationState::SendFontList) } @@ -142,7 +165,14 @@ impl Sequence for ConnectionFinalizationSequence { debug!(?message, "Send"); - let written = legacy::encode_share_data(self.user_channel_id, self.io_channel_id, 0, message, output)?; + let written = ironrdp_pdu::rdp::headers::encode_share_data( + self.user_channel_id, + self.io_channel_id, + self.share_id, + message, + output, + ) + .map_err(ConnectorError::encode)?; ( Written::from_size(written)?, @@ -151,8 +181,8 @@ impl Sequence for ConnectionFinalizationSequence { } ConnectionFinalizationState::WaitForResponse => { - let ctx = legacy::decode_send_data_indication(input)?; - let ctx = legacy::decode_share_data(ctx)?; + let ctx = ironrdp_pdu::mcs::decode_send_data_indication(input).map_err(ConnectorError::decode)?; + let ctx = ironrdp_pdu::rdp::headers::decode_share_data(ctx).map_err(ConnectorError::decode)?; debug!(message = ?ctx.pdu, "Received"); @@ -161,42 +191,46 @@ impl Sequence for ConnectionFinalizationSequence { debug!("Server Synchronize"); ConnectionFinalizationState::WaitForResponse } - ShareDataPdu::Control(control_pdu) => { - match control_pdu.action { - finalization_messages::ControlAction::Cooperate => { - if control_pdu.grant_id == 0 && control_pdu.control_id == 0 { - debug!("Server Control (Cooperate)"); - } else { - warn!( - control_pdu.grant_id, - control_pdu.control_id, - user_channel_id = self.user_channel_id, - "Server Control (Cooperate) has non-zero grant_id or control_id", - ); - } - ConnectionFinalizationState::WaitForResponse - } - finalization_messages::ControlAction::GrantedControl => { - debug!( + ShareDataPdu::Control(control_pdu) => match control_pdu.action { + finalization_messages::ControlAction::Cooperate => { + if control_pdu.grant_id == 0 && control_pdu.control_id == 0 { + debug!("Server Control (Cooperate)"); + } else { + warn!( control_pdu.grant_id, control_pdu.control_id, user_channel_id = self.user_channel_id, - SERVER_CHANNEL_ID + "Server Control (Cooperate) has non-zero grant_id or control_id", ); + } + ConnectionFinalizationState::WaitForResponse + } + finalization_messages::ControlAction::GrantedControl => { + debug!( + control_pdu.grant_id, + control_pdu.control_id, + user_channel_id = self.user_channel_id, + SERVER_CHANNEL_ID + ); + + if control_pdu.grant_id != self.user_channel_id { + warn!( + "Server Control (Granted Control) had invalid grant_id, expected {}, but got {}", + self.user_channel_id, control_pdu.grant_id + ); + } - if control_pdu.grant_id != self.user_channel_id { - warn!("Server Control (Granted Control) had invalid grant_id, expected {}, but got {}", self.user_channel_id, control_pdu.grant_id); - } - - if control_pdu.control_id != u32::from(SERVER_CHANNEL_ID) { - warn!("Server Control (Granted Control) had invalid control_id, expected {}, but got {}", SERVER_CHANNEL_ID, control_pdu.control_id); - } - - ConnectionFinalizationState::WaitForResponse + if control_pdu.control_id != u32::from(SERVER_CHANNEL_ID) { + warn!( + "Server Control (Granted Control) had invalid control_id, expected {}, but got {}", + SERVER_CHANNEL_ID, control_pdu.control_id + ); } - _ => return Err(general_err!("unexpected control action")), + + ConnectionFinalizationState::WaitForResponse } - } + _ => return Err(general_err!("unexpected control action")), + }, ShareDataPdu::ServerSetErrorInfo(server_error_info::ServerSetErrorInfoPdu(error_info)) => { match error_info { server_error_info::ErrorInfo::ProtocolIndependentCode( diff --git a/crates/ironrdp-connector/src/credssp.rs b/crates/ironrdp-connector/src/credssp.rs index 9750b6081f..413023260d 100644 --- a/crates/ironrdp-connector/src/credssp.rs +++ b/crates/ironrdp-connector/src/credssp.rs @@ -1,25 +1,24 @@ -use ironrdp_core::{other_err, WriteBuf}; -use ironrdp_pdu::{nego, PduHint}; +use ironrdp_core::{WriteBuf, other_err}; +use ironrdp_pdu::{PduHint, nego}; use picky::key::PrivateKey; -use picky_asn1_x509::{oids, Certificate, ExtensionView, GeneralName}; +use picky_asn1_x509::{Certificate, ExtensionView, GeneralName, oids}; use sspi::credssp::{self, ClientState, CredSspClient}; use sspi::generator::{Generator, NetworkRequest}; -use sspi::negotiate::ProtocolConfig; -use sspi::Username; +use sspi::{Secret, Username}; use tracing::debug; use crate::{ - custom_err, general_err, ConnectorError, ConnectorErrorKind, ConnectorResult, Credentials, ServerName, Written, + ConnectorError, ConnectorErrorKind, ConnectorResult, Credentials, ServerName, Written, custom_err, general_err, }; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct KerberosConfig { pub kdc_proxy_url: Option, - pub hostname: Option, + pub hostname: String, } impl KerberosConfig { - pub fn new(kdc_proxy_url: Option, hostname: Option) -> ConnectorResult { + pub fn new(kdc_proxy_url: Option, hostname: String) -> ConnectorResult { let kdc_proxy_url = kdc_proxy_url .map(|url| url::Url::parse(&url)) .transpose() @@ -123,11 +122,13 @@ impl CredsspSequence { certificate: cert, reader_name: config.reader_name.clone(), card_name: None, - container_name: config.container_name.clone(), + container_name: Some(config.container_name.clone()), csp_name: config.csp_name.clone(), pin: pin.as_bytes().to_vec().into(), - private_key_file_index: None, private_key: Some(key.into()), + scard_type: sspi::SmartCardType::Emulated { + scard_pin: Secret::new(pin.as_bytes().to_vec()), + }, }; sspi::Credentials::SmartCard(Box::new(identity)) } @@ -141,23 +142,24 @@ impl CredsspSequence { let service_principal_name = format!("TERMSRV/{}", &server_name); - let credssp_config: Box; - if let Some(ref krb_config) = kerberos_config { - credssp_config = Box::new(Into::::into(krb_config.clone())); - } else { - credssp_config = Box::::default(); - } - debug!(?credssp_config); + let client_mode = match kerberos_config { + Some(ref krb_config) => { + let credssp_config = Box::new(Into::::into(krb_config.clone())); + debug!(?credssp_config); + credssp::ClientMode::Negotiate(sspi::NegotiateConfig { + protocol_config: credssp_config, + package_list: None, + client_computer_name: server_name, + }) + } + None => credssp::ClientMode::Ntlm(sspi::ntlm::NtlmConfig::default()), + }; let client = CredSspClient::new( server_public_key, credentials, credssp::CredSspMode::WithCredentials, - credssp::ClientMode::Negotiate(sspi::NegotiateConfig { - protocol_config: credssp_config, - package_list: None, - client_computer_name: server_name, - }), + client_mode, service_principal_name, ) .map_err(|e| ConnectorError::new("CredSSP", ConnectorErrorKind::Credssp(e)))?; @@ -256,7 +258,7 @@ fn extract_user_principal_name(cert: &Certificate) -> Option { GeneralName::OtherName(name) if name.type_id.0 == oids::user_principal_name() => Some(name.value), _ => None, }) - .and_then(|asn1| picky_asn1_der::from_bytes(&asn1.0 .0).ok()) + .and_then(|asn1| picky_asn1_der::from_bytes(&asn1.0.0).ok()) } fn write_credssp_request(ts_request: credssp::TsRequest, output: &mut WriteBuf) -> ConnectorResult { diff --git a/crates/ironrdp-connector/src/legacy.rs b/crates/ironrdp-connector/src/legacy.rs deleted file mode 100644 index e71fa97e11..0000000000 --- a/crates/ironrdp-connector/src/legacy.rs +++ /dev/null @@ -1,192 +0,0 @@ -use std::borrow::Cow; - -use ironrdp_core::{decode, encode_vec, Decode, Encode, WriteBuf}; -use ironrdp_pdu::rdp; -use ironrdp_pdu::rdp::headers::ServerDeactivateAll; -use ironrdp_pdu::x224::X224; - -use crate::{general_err, reason_err, ConnectorError, ConnectorErrorExt as _, ConnectorResult}; - -pub fn encode_send_data_request( - initiator_id: u16, - channel_id: u16, - user_msg: &T, - buf: &mut WriteBuf, -) -> ConnectorResult -where - T: Encode, -{ - let user_data = encode_vec(user_msg).map_err(ConnectorError::encode)?; - - let pdu = ironrdp_pdu::mcs::SendDataRequest { - initiator_id, - channel_id, - user_data: Cow::Owned(user_data), - }; - - let written = ironrdp_core::encode_buf(&X224(pdu), buf).map_err(ConnectorError::encode)?; - - Ok(written) -} - -#[derive(Debug, Clone, Copy)] -pub struct SendDataIndicationCtx<'a> { - pub initiator_id: u16, - pub channel_id: u16, - pub user_data: &'a [u8], -} - -impl<'a> SendDataIndicationCtx<'a> { - pub fn decode_user_data<'de, T>(&self) -> ConnectorResult - where - T: Decode<'de>, - 'a: 'de, - { - let msg = decode::(self.user_data).map_err(ConnectorError::decode)?; - Ok(msg) - } -} - -pub fn decode_send_data_indication(src: &[u8]) -> ConnectorResult> { - use ironrdp_pdu::mcs::McsMessage; - - let mcs_msg = decode::>>(src).map_err(ConnectorError::decode)?; - - match mcs_msg.0 { - McsMessage::SendDataIndication(msg) => { - let Cow::Borrowed(user_data) = msg.user_data else { - unreachable!() - }; - - Ok(SendDataIndicationCtx { - initiator_id: msg.initiator_id, - channel_id: msg.channel_id, - user_data, - }) - } - McsMessage::DisconnectProviderUltimatum(msg) => Err(reason_err!( - "decode_send_data_indication", - "received disconnect provider ultimatum: {:?}", - msg.reason - )), - _ => Err(reason_err!( - "decode_send_data_indication", - "unexpected MCS message: {}", - ironrdp_core::name(&mcs_msg) - )), - } -} - -pub fn encode_share_control( - initiator_id: u16, - channel_id: u16, - share_id: u32, - pdu: rdp::headers::ShareControlPdu, - buf: &mut WriteBuf, -) -> ConnectorResult { - let pdu_source = initiator_id; - - let share_control_header = rdp::headers::ShareControlHeader { - share_control_pdu: pdu, - pdu_source, - share_id, - }; - - encode_send_data_request(initiator_id, channel_id, &share_control_header, buf) -} - -#[derive(Debug, Clone)] -pub struct ShareControlCtx { - pub initiator_id: u16, - pub channel_id: u16, - pub share_id: u32, - pub pdu_source: u16, - pub pdu: rdp::headers::ShareControlPdu, -} - -pub fn decode_share_control(ctx: SendDataIndicationCtx<'_>) -> ConnectorResult { - let user_msg = ctx.decode_user_data::()?; - - Ok(ShareControlCtx { - initiator_id: ctx.initiator_id, - channel_id: ctx.channel_id, - share_id: user_msg.share_id, - pdu_source: user_msg.pdu_source, - pdu: user_msg.share_control_pdu, - }) -} - -pub fn encode_share_data( - initiator_id: u16, - channel_id: u16, - share_id: u32, - pdu: rdp::headers::ShareDataPdu, - buf: &mut WriteBuf, -) -> ConnectorResult { - let share_data_header = rdp::headers::ShareDataHeader { - share_data_pdu: pdu, - stream_priority: rdp::headers::StreamPriority::Medium, - compression_flags: rdp::headers::CompressionFlags::empty(), - compression_type: rdp::client_info::CompressionType::K8, // ignored if CompressionFlags::empty() - }; - - let share_control_pdu = rdp::headers::ShareControlPdu::Data(share_data_header); - - encode_share_control(initiator_id, channel_id, share_id, share_control_pdu, buf) -} - -#[derive(Debug, Clone)] -pub struct ShareDataCtx { - pub initiator_id: u16, - pub channel_id: u16, - pub share_id: u32, - pub pdu_source: u16, - pub pdu: rdp::headers::ShareDataPdu, -} - -pub fn decode_share_data(ctx: SendDataIndicationCtx<'_>) -> ConnectorResult { - let ctx = decode_share_control(ctx)?; - - let rdp::headers::ShareControlPdu::Data(share_data_header) = ctx.pdu else { - return Err(general_err!( - "received unexpected Share Control Pdu (expected Share Data Header)" - )); - }; - - Ok(ShareDataCtx { - initiator_id: ctx.initiator_id, - channel_id: ctx.channel_id, - share_id: ctx.share_id, - pdu_source: ctx.pdu_source, - pdu: share_data_header.share_data_pdu, - }) -} - -pub enum IoChannelPdu { - Data(ShareDataCtx), - DeactivateAll(ServerDeactivateAll), -} - -pub fn decode_io_channel(ctx: SendDataIndicationCtx<'_>) -> ConnectorResult { - let ctx = decode_share_control(ctx)?; - - match ctx.pdu { - rdp::headers::ShareControlPdu::ServerDeactivateAll(deactivate_all) => { - Ok(IoChannelPdu::DeactivateAll(deactivate_all)) - } - rdp::headers::ShareControlPdu::Data(share_data_header) => { - let share_data_ctx = ShareDataCtx { - initiator_id: ctx.initiator_id, - channel_id: ctx.channel_id, - share_id: ctx.share_id, - pdu_source: ctx.pdu_source, - pdu: share_data_header.share_data_pdu, - }; - - Ok(IoChannelPdu::Data(share_data_ctx)) - } - _ => Err(general_err!( - "received unexpected Share Control Pdu (expected Share Data Header or Server Deactivate All)" - )), - } -} diff --git a/crates/ironrdp-connector/src/lib.rs b/crates/ironrdp-connector/src/lib.rs index 971e149ae5..477f080e6c 100644 --- a/crates/ironrdp-connector/src/lib.rs +++ b/crates/ironrdp-connector/src/lib.rs @@ -3,8 +3,6 @@ mod macros; -pub mod legacy; - mod channel_connection; mod connection; pub mod connection_activation; @@ -17,16 +15,16 @@ use core::any::Any; use core::fmt; use std::sync::Arc; -use ironrdp_core::{encode_buf, encode_vec, Encode, WriteBuf}; +use ironrdp_core::{Encode, WriteBuf, encode_buf, encode_vec}; use ironrdp_pdu::nego::NegoRequestData; use ironrdp_pdu::rdp::capability_sets::{self, BitmapCodecs}; -use ironrdp_pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; +use ironrdp_pdu::rdp::client_info::{self, PerformanceFlags, TimezoneInfo}; use ironrdp_pdu::x224::X224; -use ironrdp_pdu::{gcc, x224, PduHint}; +use ironrdp_pdu::{PduHint, gcc, x224}; pub use sspi; pub use self::channel_connection::{ChannelConnectionSequence, ChannelConnectionState}; -pub use self::connection::{encode_send_data_request, ClientConnector, ClientConnectorState, ConnectionResult}; +pub use self::connection::{ClientConnector, ClientConnectorState, ConnectionResult, encode_send_data_request}; pub use self::connection_finalization::{ConnectionFinalizationSequence, ConnectionFinalizationState}; pub use self::license_exchange::{LicenseExchangeSequence, LicenseExchangeState}; pub use self::server_name::ServerName; @@ -82,14 +80,12 @@ impl fmt::Display for NegotiationFailure { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct DesktopSize { pub width: u16, pub height: u16, } #[derive(Debug, Clone)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BitmapConfig { pub lossy_compression: bool, pub color_depth: u32, @@ -139,7 +135,6 @@ impl Credentials { } #[derive(Debug, Clone)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Config { /// The initial desktop size to request pub desktop_size: DesktopSize, @@ -209,6 +204,12 @@ pub struct Config { pub bitmap: Option, pub dig_product_id: String, pub client_dir: String, + /// Alternate shell to execute on the remote server (e.g., specific application instead of desktop) + /// + /// Used by CyberArk PSM for privileged session tokens and remote application scenarios. + pub alternate_shell: String, + /// Working directory for the alternate shell + pub work_dir: String, pub platform: capability_sets::MajorPlatformType, /// Unique identifier for the computer /// @@ -232,9 +233,29 @@ pub struct Config { // For Timezone Redirection to sync the server's timezone with the client's. pub timezone_info: TimezoneInfo, + /// Bulk compression type to negotiate with the server. + /// + /// When set, the `INFO_COMPRESSION` flag is included in the Client Info PDU + /// and the specified compression type is advertised. The server may then + /// send compressed PDUs (FastPath or Share Data) using any compression + /// algorithm up to and including this level. + /// + /// - `None` — no compression (default) + /// - `Some(K8)` — MPPC with 8 KB history (RDP 4.0) + /// - `Some(K64)` — MPPC with 64 KB history (RDP 5.0) + /// - `Some(Rdp6)` — NCRUSH (RDP 6.0) + /// - `Some(Rdp61)` — XCRUSH (RDP 6.1) + pub compression_type: Option, + // FIXME(@CBenoit): these are client-only options, not part of the connector. pub enable_server_pointer: bool, pub pointer_software_rendering: bool, + + /// Flags to advertise in the [`MultiTransportChannelData`] GCC block. + /// + /// [\[MS-RDPBCGR\] 2.2.1.3.7]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/861f2bbb-6ca2-4c5a-8c44-0714fa901e70 + /// [`MultiTransportChannelData`]: ironrdp_pdu::gcc::MultiTransportChannelData + pub multitransport_flags: Option, } ironrdp_core::assert_impl!(Config: Send, Sync); @@ -370,22 +391,27 @@ pub trait ConnectorErrorExt { } impl ConnectorErrorExt for ConnectorError { + #[track_caller] fn encode(error: ironrdp_core::EncodeError) -> Self { Self::new("encode error", ConnectorErrorKind::Encode(error)) } + #[track_caller] fn decode(error: ironrdp_core::DecodeError) -> Self { Self::new("decode error", ConnectorErrorKind::Decode(error)) } + #[track_caller] fn general(context: &'static str) -> Self { Self::new(context, ConnectorErrorKind::General) } + #[track_caller] fn reason(context: &'static str, reason: impl Into) -> Self { Self::new(context, ConnectorErrorKind::Reason(reason.into())) } + #[track_caller] fn custom(context: &'static str, e: E) -> Self where E: core::error::Error + Sync + Send + 'static, @@ -406,7 +432,7 @@ pub trait ConnectorResultExt { impl ConnectorResultExt for ConnectorResult { fn with_context(self, context: &'static str) -> Self { self.map_err(|mut e| { - e.context = context; + e.set_context(context); e }) } diff --git a/crates/ironrdp-connector/src/license_exchange.rs b/crates/ironrdp-connector/src/license_exchange.rs index ae8b5d8520..8b77ec76a0 100644 --- a/crates/ironrdp-connector/src/license_exchange.rs +++ b/crates/ironrdp-connector/src/license_exchange.rs @@ -5,17 +5,16 @@ use std::str; use std::sync::Arc; use ironrdp_core::WriteBuf; -use ironrdp_pdu::rdp::server_license::{self, LicenseInformation, LicensePdu, ServerLicenseError}; use ironrdp_pdu::PduHint; +use ironrdp_pdu::rdp::server_license::{self, LicenseInformation, LicensePdu, ServerLicenseError}; use rand::RngCore as _; use tracing::{debug, error, info, trace}; -use super::{custom_err, general_err, legacy, ConnectorError, ConnectorErrorExt as _}; -use crate::{encode_send_data_request, ConnectorResult, ConnectorResultExt as _, Sequence, State, Written}; +use super::{ConnectorError, ConnectorErrorExt as _, custom_err, general_err}; +use crate::{ConnectorResult, ConnectorResultExt as _, Sequence, State, Written, encode_send_data_request}; #[derive(Default, Debug)] #[non_exhaustive] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum LicenseExchangeState { #[default] Consumed, @@ -56,7 +55,6 @@ impl State for LicenseExchangeState { /// /// [3.1.5.3.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpele/8f9b860a-3687-401d-b3bc-7e9f5d4f7528 #[derive(Debug)] -#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct LicenseExchangeSequence { pub state: LicenseExchangeState, pub io_channel_id: u16, @@ -124,13 +122,15 @@ impl Sequence for LicenseExchangeSequence { LicenseExchangeState::Consumed => { return Err(general_err!( "license exchange sequence state is consumed (this is a bug)", - )) + )); } LicenseExchangeState::NewLicenseRequest => { - let send_data_indication_ctx = legacy::decode_send_data_indication(input)?; + let send_data_indication_ctx = + ironrdp_pdu::mcs::decode_send_data_indication(input).map_err(ConnectorError::decode)?; let license_pdu = send_data_indication_ctx .decode_user_data::() + .map_err(ConnectorError::decode) .with_context("decode during LicenseExchangeState::NewLicenseRequest")?; match license_pdu { @@ -260,10 +260,12 @@ impl Sequence for LicenseExchangeSequence { } LicenseExchangeState::PlatformChallenge { encryption_data } => { - let send_data_indication_ctx = legacy::decode_send_data_indication(input)?; + let send_data_indication_ctx = + ironrdp_pdu::mcs::decode_send_data_indication(input).map_err(ConnectorError::decode)?; let license_pdu = send_data_indication_ctx .decode_user_data::() + .map_err(ConnectorError::decode) .with_context("decode during LicenseExchangeState::PlatformChallenge")?; match license_pdu { @@ -312,10 +314,12 @@ impl Sequence for LicenseExchangeSequence { } LicenseExchangeState::UpgradeLicense { encryption_data } => { - let send_data_indication_ctx = legacy::decode_send_data_indication(input)?; + let send_data_indication_ctx = + ironrdp_pdu::mcs::decode_send_data_indication(input).map_err(ConnectorError::decode)?; let license_pdu = send_data_indication_ctx .decode_user_data::() + .map_err(ConnectorError::decode) .with_context("decode during SERVER_NEW_LICENSE/LicenseExchangeState::UpgradeLicense")?; match license_pdu { diff --git a/crates/ironrdp-connector/src/macros.rs b/crates/ironrdp-connector/src/macros.rs index 435cc3a12e..b604e92c72 100644 --- a/crates/ironrdp-connector/src/macros.rs +++ b/crates/ironrdp-connector/src/macros.rs @@ -6,9 +6,7 @@ /// ``` #[macro_export] macro_rules! general_err { - ( $context:expr $(,)? ) => {{ - <$crate::ConnectorError as $crate::ConnectorErrorExt>::general($context) - }}; + ( $context:expr $(,)? ) => {{ <$crate::ConnectorError as $crate::ConnectorErrorExt>::general($context) }}; } /// Creates a `ConnectorError` with `Reason` kind @@ -32,7 +30,5 @@ macro_rules! reason_err { /// ``` #[macro_export] macro_rules! custom_err { - ( $context:expr, $source:expr $(,)? ) => {{ - <$crate::ConnectorError as $crate::ConnectorErrorExt>::custom($context, $source) - }}; + ( $context:expr, $source:expr $(,)? ) => {{ <$crate::ConnectorError as $crate::ConnectorErrorExt>::custom($context, $source) }}; } diff --git a/crates/ironrdp-connector/src/server_name.rs b/crates/ironrdp-connector/src/server_name.rs index 4f5854ebbe..f864db8b22 100644 --- a/crates/ironrdp-connector/src/server_name.rs +++ b/crates/ironrdp-connector/src/server_name.rs @@ -34,7 +34,7 @@ impl From<&str> for ServerName { } fn sanitize_server_name(name: String) -> String { - if let Some(idx) = name.rfind(':') { + if let Some(addr_split) = name.rsplit_once(':') { if let Ok(sock_addr) = name.parse::() { // A socket address, including a port sock_addr.ip().to_string() @@ -43,7 +43,7 @@ fn sanitize_server_name(name: String) -> String { name } else { // An IPv4 address or server hostname including a port after the `:` token - name[..idx].to_owned() + addr_split.0.to_owned() } } else { // An IPv4 address or server hostname which does not include a port, already sane diff --git a/crates/ironrdp-core/CHANGELOG.md b/crates/ironrdp-core/CHANGELOG.md index 96f7bce765..33b4c23fad 100644 --- a/crates/ironrdp-core/CHANGELOG.md +++ b/crates/ironrdp-core/CHANGELOG.md @@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-core-v0.2.0...ironrdp-core-v0.2.1)] - 2026-07-10 + +### Features + +- Add `WriteBuf::filled_mut`, the mutable counterpart of `filled` ([#1374](https://github.com/Devolutions/IronRDP/issues/1374)) ([d3705af18c](https://github.com/Devolutions/IronRDP/commit/d3705af18cff1851f4d48017affcb85aaa678d57)) + +### Bug Fixes + +- Propagate caller location through error constructor helpers ([#1392](https://github.com/Devolutions/IronRDP/issues/1392)) ([d6990d81a1](https://github.com/Devolutions/IronRDP/commit/d6990d81a17e8349e52768ad8a82f673b1e1462d)) + + The error constructor helpers in several crates wrap the #[track_caller] + ironrdp_error::Error::new, but were not themselves marked + #[track_caller]. As a result, the captured location pointed at the + helper body instead of the real call site, giving misleading "@ + file:line" info in error reports. + + + +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-core-v0.1.5...ironrdp-core-v0.2.0)] - 2026-05-27 + +### Build + +- [**breaking**] Update `ironrdp-error` public dependency to 0.2 + ## [[0.1.5](https://github.com/Devolutions/IronRDP/compare/ironrdp-core-v0.1.4...ironrdp-core-v0.1.5)] - 2025-05-28 ### Features @@ -25,7 +49,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-core-v0.1.1...ironrdp-core-v0.1.2)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-core/Cargo.toml b/crates/ironrdp-core/Cargo.toml index 554c06a4d2..5fa1a15247 100644 --- a/crates/ironrdp-core/Cargo.toml +++ b/crates/ironrdp-core/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-core" -version = "0.1.5" +version = "0.2.1" readme = "README.md" description = "IronRDP common traits and types" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -21,4 +22,4 @@ std = ["alloc", "ironrdp-error/std"] alloc = ["ironrdp-error/alloc"] [dependencies] -ironrdp-error = { path = "../ironrdp-error", version = "0.1" } # public +ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public diff --git a/crates/ironrdp-core/src/decode.rs b/crates/ironrdp-core/src/decode.rs index 910c95ff0e..5d3a7200aa 100644 --- a/crates/ironrdp-core/src/decode.rs +++ b/crates/ironrdp-core/src/decode.rs @@ -98,24 +98,28 @@ impl fmt::Display for DecodeErrorKind { } impl NotEnoughBytesErr for DecodeError { + #[track_caller] fn not_enough_bytes(context: &'static str, received: usize, expected: usize) -> Self { Self::new(context, DecodeErrorKind::NotEnoughBytes { received, expected }) } } impl InvalidFieldErr for DecodeError { + #[track_caller] fn invalid_field(context: &'static str, field: &'static str, reason: &'static str) -> Self { Self::new(context, DecodeErrorKind::InvalidField { field, reason }) } } impl UnexpectedMessageTypeErr for DecodeError { + #[track_caller] fn unexpected_message_type(context: &'static str, got: u8) -> Self { Self::new(context, DecodeErrorKind::UnexpectedMessageType { got }) } } impl UnsupportedVersionErr for DecodeError { + #[track_caller] fn unsupported_version(context: &'static str, got: u8) -> Self { Self::new(context, DecodeErrorKind::UnsupportedVersion { got }) } @@ -123,16 +127,19 @@ impl UnsupportedVersionErr for DecodeError { impl UnsupportedValueErr for DecodeError { #[cfg(feature = "alloc")] + #[track_caller] fn unsupported_value(context: &'static str, name: &'static str, value: String) -> Self { Self::new(context, DecodeErrorKind::UnsupportedValue { name, value }) } #[cfg(not(feature = "alloc"))] + #[track_caller] fn unsupported_value(context: &'static str, name: &'static str) -> Self { Self::new(context, DecodeErrorKind::UnsupportedValue { name }) } } impl OtherErr for DecodeError { + #[track_caller] fn other(context: &'static str, description: &'static str) -> Self { Self::new(context, DecodeErrorKind::Other { description }) } diff --git a/crates/ironrdp-core/src/encode.rs b/crates/ironrdp-core/src/encode.rs index a649d66fcf..a4d0cd7ee0 100644 --- a/crates/ironrdp-core/src/encode.rs +++ b/crates/ironrdp-core/src/encode.rs @@ -102,24 +102,28 @@ impl fmt::Display for EncodeErrorKind { } impl NotEnoughBytesErr for EncodeError { + #[track_caller] fn not_enough_bytes(context: &'static str, received: usize, expected: usize) -> Self { Self::new(context, EncodeErrorKind::NotEnoughBytes { received, expected }) } } impl InvalidFieldErr for EncodeError { + #[track_caller] fn invalid_field(context: &'static str, field: &'static str, reason: &'static str) -> Self { Self::new(context, EncodeErrorKind::InvalidField { field, reason }) } } impl UnexpectedMessageTypeErr for EncodeError { + #[track_caller] fn unexpected_message_type(context: &'static str, got: u8) -> Self { Self::new(context, EncodeErrorKind::UnexpectedMessageType { got }) } } impl UnsupportedVersionErr for EncodeError { + #[track_caller] fn unsupported_version(context: &'static str, got: u8) -> Self { Self::new(context, EncodeErrorKind::UnsupportedVersion { got }) } @@ -127,16 +131,19 @@ impl UnsupportedVersionErr for EncodeError { impl UnsupportedValueErr for EncodeError { #[cfg(feature = "alloc")] + #[track_caller] fn unsupported_value(context: &'static str, name: &'static str, value: String) -> Self { Self::new(context, EncodeErrorKind::UnsupportedValue { name, value }) } #[cfg(not(feature = "alloc"))] + #[track_caller] fn unsupported_value(context: &'static str, name: &'static str) -> Self { Self::new(context, EncodeErrorKind::UnsupportedValue { name }) } } impl OtherErr for EncodeError { + #[track_caller] fn other(context: &'static str, description: &'static str) -> Self { Self::new(context, EncodeErrorKind::Other { description }) } @@ -220,7 +227,7 @@ pub fn size(pdu: &T) -> usize { #[cfg(feature = "alloc")] mod legacy { use super::{Encode, EncodeResult}; - use crate::{ensure_size, WriteCursor}; + use crate::{WriteCursor, ensure_size}; impl Encode for alloc::vec::Vec { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { diff --git a/crates/ironrdp-core/src/lib.rs b/crates/ironrdp-core/src/lib.rs index 252c23159a..bbef57bdda 100644 --- a/crates/ironrdp-core/src/lib.rs +++ b/crates/ironrdp-core/src/lib.rs @@ -16,18 +16,37 @@ mod decode; mod encode; mod error; mod into_owned; +#[cfg(feature = "alloc")] +mod non_empty; mod padding; #[cfg(feature = "alloc")] mod write_buf; -// Flat API hierarchy of common traits and types +// Flat API hierarchy of common traits and types. +// +// Each `pub use` lists its exports explicitly so that adding a new `pub` +// item to a module is a conscious public-API commitment rather than +// auto-public via a wildcard. -pub use self::as_any::*; -pub use self::cursor::*; -pub use self::decode::*; -pub use self::encode::*; -pub use self::error::*; -pub use self::into_owned::*; -pub use self::padding::*; +pub use self::as_any::AsAny; +pub use self::cursor::{NotEnoughBytesError, ReadCursor, WriteCursor}; +pub use self::decode::{ + Decode, DecodeError, DecodeErrorKind, DecodeOwned, DecodeResult, decode, decode_cursor, decode_owned, + decode_owned_cursor, +}; +#[cfg(feature = "alloc")] +pub use self::encode::encode_buf; +#[cfg(any(feature = "alloc", test))] +pub use self::encode::encode_vec; +pub use self::encode::{Encode, EncodeError, EncodeErrorKind, EncodeResult, encode, encode_cursor, name, size}; +pub use self::error::{ + InvalidFieldErr, NotEnoughBytesErr, OtherErr, UnexpectedMessageTypeErr, UnsupportedValueErr, UnsupportedVersionErr, + WithSource, invalid_field_err, invalid_field_err_with_source, not_enough_bytes_err, other_err, + other_err_with_source, unexpected_message_type_err, unsupported_value_err, unsupported_version_err, +}; +pub use self::into_owned::IntoOwned; +#[cfg(feature = "alloc")] +pub use self::non_empty::NonEmpty; +pub use self::padding::{read_padding, write_padding}; #[cfg(feature = "alloc")] -pub use self::write_buf::*; +pub use self::write_buf::WriteBuf; diff --git a/crates/ironrdp-core/src/macros.rs b/crates/ironrdp-core/src/macros.rs index 521608394a..c6d6104020 100644 --- a/crates/ironrdp-core/src/macros.rs +++ b/crates/ironrdp-core/src/macros.rs @@ -60,12 +60,8 @@ macro_rules! function { /// If the context is not provided, it will use the current function name. #[macro_export] macro_rules! not_enough_bytes_err { - ( $context:expr, $received:expr , $expected:expr $(,)? ) => {{ - $crate::not_enough_bytes_err($context, $received, $expected) - }}; - ( $received:expr , $expected:expr $(,)? ) => {{ - $crate::not_enough_bytes_err!($crate::function!(), $received, $expected) - }}; + ( $context:expr, $received:expr , $expected:expr $(,)? ) => {{ $crate::not_enough_bytes_err($context, $received, $expected) }}; + ( $received:expr , $expected:expr $(,)? ) => {{ $crate::not_enough_bytes_err!($crate::function!(), $received, $expected) }}; } /// Creates an "invalid field" error with context information. @@ -92,12 +88,8 @@ macro_rules! not_enough_bytes_err { /// If the context is not provided, it will use the current function name. #[macro_export] macro_rules! invalid_field_err { - ( $context:expr, $field:expr , $reason:expr $(,)? ) => {{ - $crate::invalid_field_err($context, $field, $reason) - }}; - ( $field:expr , $reason:expr $(,)? ) => {{ - $crate::invalid_field_err!($crate::function!(), $field, $reason) - }}; + ( $context:expr, $field:expr , $reason:expr $(,)? ) => {{ $crate::invalid_field_err($context, $field, $reason) }}; + ( $field:expr , $reason:expr $(,)? ) => {{ $crate::invalid_field_err!($crate::function!(), $field, $reason) }}; } /// Creates an "unexpected message type" error with context information. @@ -123,12 +115,8 @@ macro_rules! invalid_field_err { /// If the context is not provided, it will use the current function name. #[macro_export] macro_rules! unexpected_message_type_err { - ( $context:expr, $got:expr $(,)? ) => {{ - $crate::unexpected_message_type_err($context, $got) - }}; - ( $got:expr $(,)? ) => {{ - $crate::unexpected_message_type_err!($crate::function!(), $got) - }}; + ( $context:expr, $got:expr $(,)? ) => {{ $crate::unexpected_message_type_err($context, $got) }}; + ( $got:expr $(,)? ) => {{ $crate::unexpected_message_type_err!($crate::function!(), $got) }}; } /// Creates an "unsupported version" error with context information. @@ -154,12 +142,8 @@ macro_rules! unexpected_message_type_err { /// If the context is not provided, it will use the current function name. #[macro_export] macro_rules! unsupported_version_err { - ( $context:expr, $got:expr $(,)? ) => {{ - $crate::unsupported_version_err($context, $got) - }}; - ( $got:expr $(,)? ) => {{ - $crate::unsupported_version_err!($crate::function!(), $got) - }}; + ( $context:expr, $got:expr $(,)? ) => {{ $crate::unsupported_version_err($context, $got) }}; + ( $got:expr $(,)? ) => {{ $crate::unsupported_version_err!($crate::function!(), $got) }}; } /// Creates an "unsupported value" error with context information. @@ -186,12 +170,8 @@ macro_rules! unsupported_version_err { /// If the context is not provided, it will use the current function name. #[macro_export] macro_rules! unsupported_value_err { - ( $context:expr, $name:expr, $value:expr $(,)? ) => {{ - $crate::unsupported_value_err($context, $name, $value) - }}; - ( $name:expr, $value:expr $(,)? ) => {{ - $crate::unsupported_value_err!($crate::function!(), $name, $value) - }}; + ( $context:expr, $name:expr, $value:expr $(,)? ) => {{ $crate::unsupported_value_err($context, $name, $value) }}; + ( $name:expr, $value:expr $(,)? ) => {{ $crate::unsupported_value_err!($crate::function!(), $name, $value) }}; } /// Creates a generic "other" error with optional context and source information. @@ -350,9 +330,7 @@ macro_rules! cast_length { $len.try_into() .map_err(|e| $crate::invalid_field_err_with_source($ctx, $field, "too many elements", e)) }}; - ($field:expr, $len:expr) => {{ - $crate::cast_length!($crate::function!(), $field, $len) - }}; + ($field:expr, $len:expr) => {{ $crate::cast_length!($crate::function!(), $field, $len) }}; } /// Safely casts an integer to a different integer type. @@ -387,9 +365,7 @@ macro_rules! cast_int { $crate::invalid_field_err_with_source($ctx, $field, "out of range integral type conversion", e) }) }}; - ($field:expr, $len:expr) => {{ - $crate::cast_int!($crate::function!(), $field, $len) - }}; + ($field:expr, $len:expr) => {{ $crate::cast_int!($crate::function!(), $field, $len) }}; } /// Writes zeroes using as few `write_u*` calls as possible. diff --git a/crates/ironrdp-core/src/non_empty.rs b/crates/ironrdp-core/src/non_empty.rs new file mode 100644 index 0000000000..48a93e2f61 --- /dev/null +++ b/crates/ironrdp-core/src/non_empty.rs @@ -0,0 +1,87 @@ +use alloc::vec::Vec; +use core::num::NonZeroUsize; + +/// A vector-like collection that is guaranteed to contain at least one element. +/// +/// The first element (the [head](NonEmpty::first)) is stored inline, so a single-element +/// `NonEmpty` performs no heap allocation. Additional elements are kept in a growable tail. +/// +/// Because the collection can never be empty, [`first`](NonEmpty::first) is infallible and +/// [`len`](NonEmpty::len) returns a [`NonZeroUsize`]: callers never have to branch on an +/// "is it empty?" case. +/// +/// Elements are kept in insertion order: the head is the first inserted element. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NonEmpty { + head: T, + tail: Vec, +} + +impl NonEmpty { + /// Creates a new collection containing a single element. + /// + /// No allocation is performed until a second element is [pushed](NonEmpty::push). + #[must_use] + pub const fn new(head: T) -> Self { + Self { head, tail: Vec::new() } + } + + /// Appends an element after the existing ones. + pub fn push(&mut self, value: T) { + self.tail.push(value); + } + + /// Returns a reference to the first element. + /// + /// This never fails: the collection always contains at least one element. + #[must_use] + pub const fn first(&self) -> &T { + &self.head + } + + /// Returns the number of elements, which is always at least one. + #[must_use] + pub fn len(&self) -> NonZeroUsize { + // INVARIANT: the head always counts for one, so the total is never zero. + NonZeroUsize::MIN.saturating_add(self.tail.len()) + } + + /// Returns an iterator over the elements, in insertion order, starting with the head. + pub fn iter(&self) -> impl Iterator { + core::iter::once(&self.head).chain(self.tail.iter()) + } + + /// Consumes the collection, keeping only the elements for which `predicate` returns `true`. + /// + /// Returns `None` when no element is kept (a `NonEmpty` cannot represent an empty result). + #[must_use] + pub fn filter(self, mut predicate: F) -> Option + where + F: FnMut(&T) -> bool, + { + let mut kept = core::iter::once(self.head) + .chain(self.tail) + .filter(|value| predicate(value)); + let head = kept.next()?; + let tail = kept.collect(); + Some(Self { head, tail }) + } +} + +impl IntoIterator for NonEmpty { + type Item = T; + type IntoIter = core::iter::Chain, alloc::vec::IntoIter>; + + fn into_iter(self) -> Self::IntoIter { + core::iter::once(self.head).chain(self.tail) + } +} + +impl<'a, T> IntoIterator for &'a NonEmpty { + type Item = &'a T; + type IntoIter = core::iter::Chain, core::slice::Iter<'a, T>>; + + fn into_iter(self) -> Self::IntoIter { + core::iter::once(&self.head).chain(self.tail.iter()) + } +} diff --git a/crates/ironrdp-core/src/write_buf.rs b/crates/ironrdp-core/src/write_buf.rs index 09023c0080..8316439e0b 100644 --- a/crates/ironrdp-core/src/write_buf.rs +++ b/crates/ironrdp-core/src/write_buf.rs @@ -62,6 +62,12 @@ impl WriteBuf { &self.inner[..self.filled] } + /// Returns a mutable reference to the filled portion of the buffer. + #[inline] + pub fn filled_mut(&mut self) -> &mut [u8] { + &mut self.inner[..self.filled] + } + /// Ensures initialized and unfilled portion of the buffer is big enough for `additional` more bytes. #[inline] pub fn initialize(&mut self, additional: usize) { diff --git a/crates/ironrdp-displaycontrol/CHANGELOG.md b/crates/ironrdp-displaycontrol/CHANGELOG.md index 9801a9660a..0d09e4ba4f 100644 --- a/crates/ironrdp-displaycontrol/CHANGELOG.md +++ b/crates/ironrdp-displaycontrol/CHANGELOG.md @@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-displaycontrol-v0.7.0...ironrdp-displaycontrol-v0.8.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency to 0.8 + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-svc` public dependency to 0.8 + + + +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-displaycontrol-v0.6.0...ironrdp-displaycontrol-v0.7.0)] - 2026-06-05 + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency + + + +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-displaycontrol-v0.5.0...ironrdp-displaycontrol-v0.6.0)] - 2026-05-27 + +### Build + +- [**breaking**] Update `ironrdp-core`, `ironrdp-dvc`, `ironrdp-pdu`, and `ironrdp-svc` public dependencies + ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-displaycontrol-v0.1.3...ironrdp-displaycontrol-v0.2.0)] - 2025-03-12 ### Build @@ -13,7 +39,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump ironrdp-pdu - ## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-displaycontrol-v0.1.2...ironrdp-displaycontrol-v0.1.3)] - 2025-03-12 ### Build @@ -28,7 +53,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-displaycontrol-v0.1.0...ironrdp-displaycontrol-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-displaycontrol/Cargo.toml b/crates/ironrdp-displaycontrol/Cargo.toml index 7c3424ff1a..45349de12a 100644 --- a/crates/ironrdp-displaycontrol/Cargo.toml +++ b/crates/ironrdp-displaycontrol/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-displaycontrol" -version = "0.3.0" +version = "0.8.0" readme = "README.md" description = "Display control dynamic channel extension implementation" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -16,10 +17,10 @@ doctest = false test = false [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.3" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public tracing = { version = "0.1", features = ["log"] } [lints] diff --git a/crates/ironrdp-displaycontrol/src/client.rs b/crates/ironrdp-displaycontrol/src/client.rs index 747648968f..ce483a0de1 100644 --- a/crates/ironrdp-displaycontrol/src/client.rs +++ b/crates/ironrdp-displaycontrol/src/client.rs @@ -1,11 +1,11 @@ -use ironrdp_core::{impl_as_any, Decode as _, EncodeResult, ReadCursor}; -use ironrdp_dvc::{encode_dvc_messages, DvcClientProcessor, DvcMessage, DvcProcessor}; -use ironrdp_pdu::{decode_err, PduResult}; +use ironrdp_core::{Decode as _, EncodeResult, ReadCursor, impl_as_any}; +use ironrdp_dvc::{DvcClientProcessor, DvcMessage, DvcProcessor, encode_dvc_messages}; +use ironrdp_pdu::{PduResult, decode_err}; use ironrdp_svc::{ChannelFlags, SvcMessage}; use tracing::debug; -use crate::pdu::{DisplayControlCapabilities, DisplayControlMonitorLayout, DisplayControlPdu}; use crate::CHANNEL_NAME; +use crate::pdu::{DisplayControlCapabilities, DisplayControlMonitorLayout, DisplayControlPdu}; /// A client for the Display Control Virtual Channel. pub struct DisplayControlClient { diff --git a/crates/ironrdp-displaycontrol/src/pdu/mod.rs b/crates/ironrdp-displaycontrol/src/pdu/mod.rs index 6d7fab0144..41efbc5a63 100644 --- a/crates/ironrdp-displaycontrol/src/pdu/mod.rs +++ b/crates/ironrdp-displaycontrol/src/pdu/mod.rs @@ -3,7 +3,8 @@ //! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpedisp/d2954508-f487-48bc-8731-39743e0854a9 use ironrdp_core::{ - ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + invalid_field_err, }; use ironrdp_dvc::DvcEncode; use tracing::warn; @@ -45,11 +46,11 @@ impl Encode for DisplayControlPdu { // This will never overflow as per invariants. #[expect(clippy::arithmetic_side_effects)] - let pdu_size = payload_length + Self::FIXED_PART_SIZE; + let pdu_size = cast_length!("pdu size", payload_length + Self::FIXED_PART_SIZE)?; // Write `DISPLAYCONTROL_HEADER` fields. dst.write_u32(kind); - dst.write_u32(pdu_size.try_into().unwrap()); + dst.write_u32(pdu_size); match self { DisplayControlPdu::Caps(caps) => caps.encode(dst), @@ -87,7 +88,7 @@ impl<'de> Decode<'de> for DisplayControlPdu { let pdu_length = src.read_u32(); let _payload_length = pdu_length - .checked_sub(Self::FIXED_PART_SIZE.try_into().unwrap()) + .checked_sub(Self::FIXED_PART_SIZE.try_into().expect("always in range")) .ok_or_else(|| invalid_field_err!("Length", "Display control PDU length is too small"))?; match kind { @@ -274,7 +275,7 @@ impl DisplayControlMonitorLayout { entry }; - Ok(DisplayControlMonitorLayout::new(&[entry]).unwrap()) + DisplayControlMonitorLayout::new(&[entry]) } pub fn monitors(&self) -> &[MonitorLayoutEntry] { @@ -286,7 +287,7 @@ impl Encode for DisplayControlMonitorLayout { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u32(MonitorLayoutEntry::FIXED_PART_SIZE.try_into().unwrap()); + dst.write_u32(MonitorLayoutEntry::FIXED_PART_SIZE.try_into().expect("always in range")); let monitors_count: u32 = self .monitors @@ -323,20 +324,20 @@ impl<'de> Decode<'de> for DisplayControlMonitorLayout { let monitor_layout_size = src.read_u32(); - if monitor_layout_size != MonitorLayoutEntry::FIXED_PART_SIZE.try_into().unwrap() { + if monitor_layout_size != MonitorLayoutEntry::FIXED_PART_SIZE.try_into().expect("always in range") { return Err(invalid_field_err!( "MonitorLayoutSize", "Monitor layout size is invalid" )); } - let num_monitors = src.read_u32(); + let num_monitors = cast_length!("number of monitors", src.read_u32())?; if num_monitors > MAX_SUPPORTED_MONITORS.into() { return Err(invalid_field_err!("NumMonitors", "Too many monitors")); } - let mut monitors = Vec::with_capacity(usize::try_from(num_monitors).unwrap()); + let mut monitors = Vec::with_capacity(num_monitors); for _ in 0..num_monitors { let monitor = MonitorLayoutEntry::decode(src)?; monitors.push(monitor); @@ -400,7 +401,7 @@ impl MonitorLayoutEntry { /// /// [2.2.2.2.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpedisp/ea2de591-9203-42cd-9908-be7a55237d1c fn new_impl(mut width: u32, height: u32) -> EncodeResult { - if width % 2 != 0 { + if !width.is_multiple_of(2) { let prev_width = width; width = width.saturating_sub(1); warn!( @@ -440,7 +441,7 @@ impl MonitorLayoutEntry { } let mut width = width; - if width % 2 != 0 { + if !width.is_multiple_of(2) { width = width.saturating_sub(1); } diff --git a/crates/ironrdp-displaycontrol/src/server.rs b/crates/ironrdp-displaycontrol/src/server.rs index 21bee8a26c..8b845aed8e 100644 --- a/crates/ironrdp-displaycontrol/src/server.rs +++ b/crates/ironrdp-displaycontrol/src/server.rs @@ -1,10 +1,10 @@ use ironrdp_core::{decode, impl_as_any}; use ironrdp_dvc::{DvcMessage, DvcProcessor, DvcServerProcessor}; -use ironrdp_pdu::{decode_err, PduResult}; +use ironrdp_pdu::{PduResult, decode_err}; use tracing::debug; -use crate::pdu::{DisplayControlCapabilities, DisplayControlMonitorLayout, DisplayControlPdu}; use crate::CHANNEL_NAME; +use crate::pdu::{DisplayControlCapabilities, DisplayControlMonitorLayout, DisplayControlPdu}; pub trait DisplayControlHandler: Send { fn monitor_layout(&self, layout: DisplayControlMonitorLayout) { diff --git a/crates/ironrdp-dvc-com-plugin/CHANGELOG.md b/crates/ironrdp-dvc-com-plugin/CHANGELOG.md new file mode 100644 index 0000000000..9cca74face --- /dev/null +++ b/crates/ironrdp-dvc-com-plugin/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-com-plugin-v0.1.2...ironrdp-dvc-com-plugin-v0.1.3)] - 2026-07-10 + +### Bug Fixes + +- Lower verbosity of routine logs in library crates ([c36032f91b](https://github.com/Devolutions/IronRDP/commit/c36032f91b27390a2cd34bfb300cfbe099d847a9)) + + Library crates should not emit info! for routine, repeating operations; + that floods the default logs of the final consumer, which owns the + verbosity decision. Reserve info! for rare connection/session lifecycle + milestones, debug! for significant one-off events, and trace! for the + fine-grained detail only needed when nothing else explains a problem. + + + +## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-com-plugin-v0.1.1...ironrdp-dvc-com-plugin-v0.1.2)] - 2026-06-05 + + + +## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-com-plugin-v0.1.0...ironrdp-dvc-com-plugin-v0.1.1)] - 2026-05-27 + +### Build + +- Update dependencies. diff --git a/crates/ironrdp-dvc-com-plugin/Cargo.toml b/crates/ironrdp-dvc-com-plugin/Cargo.toml new file mode 100644 index 0000000000..1c7b9aaba2 --- /dev/null +++ b/crates/ironrdp-dvc-com-plugin/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "ironrdp-dvc-com-plugin" +version = "0.1.3" +readme = "README.md" +description = "DVC COM client plugin loader for IronRDP (Windows)" +edition.workspace = true +rust-version = "1.89" +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +[lib] +doctest = false +test = false + +[dependencies] + +[target.'cfg(windows)'.dependencies] +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } +tracing = { version = "0.1", features = ["log"] } +windows = { version = "0.62", features = [ + "Win32_Foundation", + "Win32_System_RemoteDesktop", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", + "Win32_System_LibraryLoader", +] } +windows-core = "0.62" + +[lints] +workspace = true diff --git a/crates/ironrdp-dvc-com-plugin/README.md b/crates/ironrdp-dvc-com-plugin/README.md new file mode 100644 index 0000000000..7b1de8aebc --- /dev/null +++ b/crates/ironrdp-dvc-com-plugin/README.md @@ -0,0 +1,25 @@ +# IronRDP DVC COM Plugin + +Loader for native Windows [DVC (Dynamic Virtual Channel)][dvc-overview] client plugin DLLs +(e.g. `webauthn.dll`) that bridges them into IronRDP's DVC infrastructure. + +The plugin DLL is loaded via `LoadLibraryW`, its `VirtualChannelGetInstance` export is called +to obtain `IWTSPlugin` COM objects, and a Rust implementation of `IWTSVirtualChannelManager` +bridges data bidirectionally between the plugin's COM callbacks and IronRDP's DVC system. + +This crate is **Windows-only** (`#![cfg(windows)]`). + +## Architecture + +A dedicated COM worker thread owns all COM objects (which are `!Send`). The `DvcComChannel` +structs (which implement `DvcProcessor + Send`) are registered as DVC channels in IronRDP's +`DrdynvcClient` and communicate with the COM thread via `std::sync::mpsc` channels. + +Outbound data from the plugin (`IWTSVirtualChannel::Write`) is injected into the active +session loop via the `on_write_dvc` callback, following the same pattern as +`ironrdp-dvc-pipe-proxy`. + +This crate is part of the [IronRDP] project. + +[IronRDP]: https://github.com/Devolutions/IronRDP +[dvc-overview]: https://learn.microsoft.com/en-us/windows/win32/termserv/writing-a-client-dvc-component diff --git a/crates/ironrdp-dvc-com-plugin/src/channel.rs b/crates/ironrdp-dvc-com-plugin/src/channel.rs new file mode 100644 index 0000000000..a9feb570bd --- /dev/null +++ b/crates/ironrdp-dvc-com-plugin/src/channel.rs @@ -0,0 +1,331 @@ +//! [`DvcComChannel`] — the `DvcProcessor` implementation that bridges IronRDP ↔ COM plugin. +//! +//! Also contains the public [`load_dvc_plugin`] function which loads a plugin DLL, +//! initializes its COM objects, and returns a set of `DvcComChannel`s to register +//! with IronRDP's `DrdynvcClient`. + +use core::cell::Cell; +use core::ffi::c_void; +use std::collections::HashMap; +use std::os::windows::ffi::OsStrExt as _; +use std::path::Path; +use std::sync::{Arc, mpsc as std_mpsc}; +use std::thread; + +use ironrdp_core::impl_as_any; +use ironrdp_dvc::{DvcClientProcessor, DvcMessage, DvcProcessor}; +use ironrdp_pdu::{PduResult, pdu_other_err}; +use ironrdp_svc::SvcMessage; +use tracing::{debug, error, trace, warn}; +use windows::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; +use windows::Win32::System::RemoteDesktop::{IWTSListenerCallback, IWTSPlugin, IWTSVirtualChannelManager}; +use windows::core::{HRESULT, PCSTR, PCWSTR}; +use windows_core::{GUID, Interface as _}; + +use crate::com::{ChannelManager, OnWriteDvc}; +use crate::worker::{ComCommand, run_com_worker}; + +/// Type signature for the `VirtualChannelGetInstance` export in a DVC plugin DLL. +/// +/// ```c +/// HRESULT VCAPITYPE VirtualChannelGetInstance( +/// REFIID refiid, +/// ULONG *pNumObjs, +/// VOID **ppObjArray +/// ); +/// ``` +type VirtualChannelGetInstanceFn = + unsafe extern "system" fn(refiid: *const GUID, pnumobjs: *mut u32, ppobjarray: *mut *mut c_void) -> HRESULT; + +/// A DVC channel backed by a native COM plugin DLL. +/// +/// Each instance represents one listener (channel name) registered by the plugin +/// during `IWTSPlugin::Initialize`. It implements [`DvcProcessor`] + [`DvcClientProcessor`] +/// so it can be registered with IronRDP's `DrdynvcClient`. +/// +/// Communication with the COM worker thread happens via `std::sync::mpsc` channels. +pub struct DvcComChannel { + channel_name: String, + command_tx: std_mpsc::Sender, + on_write_dvc_tx: std_mpsc::Sender, + on_write_dvc_factory: Arc OnWriteDvcMessage + Send + Sync>, + /// Set to false after the first `start()` call sends `Connected` + needs_connected: bool, + _worker_handle: Option>, +} + +impl_as_any!(DvcComChannel); + +impl DvcProcessor for DvcComChannel { + fn channel_name(&self) -> &str { + &self.channel_name + } + + fn start(&mut self, channel_id: u32) -> PduResult> { + debug!( + channel_name = %self.channel_name, + channel_id, + "DVC COM channel start" + ); + + // Notify the plugin that the RDP connection is established (only once per plugin) + if self.needs_connected { + self.needs_connected = false; + let _ = self.command_tx.send(ComCommand::Connected); + } + + // Create a fresh write callback for this channel opening + let write_cb = (self.on_write_dvc_factory)(); + let _ = self.on_write_dvc_tx.send(write_cb); + + let (accept_tx, accept_rx) = std_mpsc::sync_channel(1); + + self.command_tx + .send(ComCommand::ChannelOpened { + channel_name: self.channel_name.clone(), + channel_id, + accept_tx, + }) + .map_err(|_| pdu_other_err!("COM worker thread is gone"))?; + + // Block until the COM thread processes the channel open + let accepted = accept_rx.recv().unwrap_or(false); + + if accepted { + debug!( + channel_name = %self.channel_name, + channel_id, + "COM plugin accepted DVC channel" + ); + } else { + warn!( + channel_name = %self.channel_name, + channel_id, + "COM plugin rejected DVC channel" + ); + } + + Ok(vec![]) + } + + fn process(&mut self, channel_id: u32, payload: &[u8]) -> PduResult> { + self.command_tx + .send(ComCommand::DataReceived { + channel_id, + data: payload.to_vec(), + }) + .map_err(|_| pdu_other_err!("COM worker thread is gone"))?; + + Ok(vec![]) + } + + fn close(&mut self, channel_id: u32) { + debug!( + channel_name = %self.channel_name, + channel_id, + "DVC COM channel close" + ); + + let _ = self.command_tx.send(ComCommand::ChannelClosed { channel_id }); + } +} + +impl DvcClientProcessor for DvcComChannel {} + +impl Drop for DvcComChannel { + fn drop(&mut self) { + // Send shutdown to the COM worker thread + let _ = self.command_tx.send(ComCommand::Shutdown); + // Don't join — the worker will clean up asynchronously + } +} + +/// Callback type matching the pipe proxy pattern: called when the plugin writes outbound DVC data. +pub(crate) type OnWriteDvcMessage = Box) -> PduResult<()> + Send + 'static>; + +/// Load a DVC client plugin DLL and return channels for each listener the plugin registers. +/// +/// # Arguments +/// +/// * `dll_path` — Path to the DVC plugin DLL (e.g. `C:\Windows\System32\webauthn.dll`) +/// * `on_write_dvc` — Factory function that creates a write callback for each channel. +/// The callback is invoked when the plugin calls `IWTSVirtualChannel::Write()`, +/// sending the encoded DVC messages back into IronRDP's session event loop. +/// +/// # Returns +/// +/// A `Vec`, one per listener the plugin registered during `Initialize`. +/// These should be added to a `DrdynvcClient` via `with_dynamic_channel()`. +/// +/// # Panics +/// +/// Panics if the COM worker thread cannot be spawned. +pub fn load_dvc_plugin(dll_path: &Path, on_write_dvc_factory: F) -> PduResult> +where + F: Fn() -> OnWriteDvcMessage + Send + Sync + 'static, +{ + debug!(dll = %dll_path.display(), "Loading DVC COM plugin"); + + // Channel for sending commands to the COM worker thread + let (command_tx, command_rx) = std_mpsc::channel(); + + // Channel for sending write callbacks to the COM worker thread + let (on_write_dvc_tx, on_write_dvc_rx) = std_mpsc::channel(); + + // Channel for receiving the list of registered listeners back from the COM thread + let (init_tx, init_rx) = std_mpsc::sync_channel::, String>>(1); + + let dll_path_owned = dll_path.to_path_buf(); + let _on_write_dvc_tx_clone = on_write_dvc_tx.clone(); + + let _worker_handle = thread::Builder::new() + .name("dvc-com-worker".into()) + .spawn(move || { + // Load and initialize on the COM thread + match initialize_plugin_on_thread(&dll_path_owned) { + Ok((plugin, manager, listeners)) => { + let channel_names: Vec = listeners.keys().cloned().collect(); + debug!( + channels = ?channel_names, + "Plugin initialized, registered {} listener(s)", + channel_names.len() + ); + let _ = init_tx.send(Ok(channel_names)); + + // Enter the command loop + run_com_worker(plugin, manager, listeners, command_rx, on_write_dvc_rx); + } + Err(e) => { + error!(error = %e, "Failed to initialize DVC COM plugin"); + let _ = init_tx.send(Err(e)); + } + } + }) + .expect("spawn COM worker thread"); + + // Wait for initialization to complete + let channel_names = init_rx + .recv() + .map_err(|_| pdu_other_err!("COM worker thread died during initialization"))? + .map_err(|e| pdu_other_err!("plugin initialization failed").with_source(std::io::Error::other(e)))?; + + if channel_names.is_empty() { + warn!(dll = %dll_path.display(), "Plugin registered no listeners"); + } + + // Create a DvcComChannel for each registered listener + let mut channels = Vec::with_capacity(channel_names.len()); + let is_first = Cell::new(true); + let factory: Arc OnWriteDvcMessage + Send + Sync> = Arc::new(on_write_dvc_factory); + + for name in channel_names { + debug!(channel_name = %name, "Creating DvcComChannel"); + + channels.push(DvcComChannel { + channel_name: name, + command_tx: command_tx.clone(), + on_write_dvc_tx: on_write_dvc_tx.clone(), + on_write_dvc_factory: Arc::clone(&factory), + needs_connected: is_first.get(), + _worker_handle: None, + }); + is_first.set(false); + } + + Ok(channels) +} + +/// Load the plugin DLL and call VirtualChannelGetInstance + Initialize on the COM thread. +/// +/// Returns the plugin COM object, the channel manager interface, and +/// the map of listener names → callbacks. +fn initialize_plugin_on_thread( + dll_path: &Path, +) -> Result< + ( + IWTSPlugin, + IWTSVirtualChannelManager, + HashMap, + ), + String, +> { + // Load the DLL + let dll_path_wide: Vec = dll_path.as_os_str().encode_wide().chain(core::iter::once(0)).collect(); + let dll_path_pcwstr = PCWSTR(dll_path_wide.as_ptr()); + + // SAFETY: loading the DLL into this process + let hmodule = unsafe { LoadLibraryW(dll_path_pcwstr) }.map_err(|e| format!("LoadLibraryW failed: {e}"))?; + + trace!(dll = %dll_path.display(), "DLL loaded successfully"); + + // Get the VirtualChannelGetInstance export + let proc_name = PCSTR::from_raw(c"VirtualChannelGetInstance".as_ptr().cast::()); + + // SAFETY: hmodule is valid, proc_name is a null-terminated ASCII string + let proc_addr = unsafe { GetProcAddress(hmodule, proc_name) } + .ok_or_else(|| "VirtualChannelGetInstance export not found in DLL".to_owned())?; + + // SAFETY: transmuting the function pointer; we trust the DLL follows the documented API + let get_instance: VirtualChannelGetInstanceFn = unsafe { core::mem::transmute(proc_addr) }; + + trace!("VirtualChannelGetInstance export found"); + + // Phase 1: query the number of plugin objects + let iid = IWTSPlugin::IID; + let mut num_objs: u32 = 0; + + // SAFETY: first call with null array to get count + let hr = unsafe { get_instance(&iid, &mut num_objs, core::ptr::null_mut()) }; + if hr.is_err() { + return Err(format!( + "VirtualChannelGetInstance phase 1 failed: HRESULT 0x{:08X}", + hr.0 + )); + } + + trace!(count = num_objs, "Plugin reports {} object(s)", num_objs); + + if num_objs == 0 { + return Err("plugin returned 0 objects".to_owned()); + } + + // Phase 2: get the actual plugin objects + let mut obj_array: Vec<*mut c_void> = + vec![core::ptr::null_mut(); usize::try_from(num_objs).expect("u32 fits in usize")]; + + // SAFETY: second call with allocated array + let hr = unsafe { get_instance(&iid, &mut num_objs, obj_array.as_mut_ptr()) }; + if hr.is_err() { + return Err(format!( + "VirtualChannelGetInstance phase 2 failed: HRESULT 0x{:08X}", + hr.0 + )); + } + + // Use the first plugin object + let plugin_ptr = obj_array[0]; + if plugin_ptr.is_null() { + return Err("VirtualChannelGetInstance returned null plugin pointer".to_owned()); + } + + // SAFETY: the plugin pointer is a valid IWTSPlugin COM interface pointer + let plugin: IWTSPlugin = unsafe { IWTSPlugin::from_raw(plugin_ptr) }; + + trace!("Got IWTSPlugin COM object"); + + // Create shared state for listeners: we keep an Rc clone so we can read the + // map after Initialize() without needing an unsafe cast from the COM pointer. + let listeners_rc = std::rc::Rc::new(core::cell::RefCell::new(HashMap::new())); + let channel_manager_impl = ChannelManager::new(std::rc::Rc::clone(&listeners_rc)); + let manager: IWTSVirtualChannelManager = channel_manager_impl.into(); + + // SAFETY: calling IWTSPlugin::Initialize with our channel manager + unsafe { plugin.Initialize(&manager) }.map_err(|e| format!("IWTSPlugin::Initialize failed: {e}"))?; + + trace!("IWTSPlugin::Initialize succeeded"); + + // Read the listener map that the plugin populated during Initialize. + let listeners: HashMap = listeners_rc.borrow().clone(); + + Ok((plugin, manager, listeners)) +} diff --git a/crates/ironrdp-dvc-com-plugin/src/com.rs b/crates/ironrdp-dvc-com-plugin/src/com.rs new file mode 100644 index 0000000000..e09f739d46 --- /dev/null +++ b/crates/ironrdp-dvc-com-plugin/src/com.rs @@ -0,0 +1,185 @@ +//! COM interface implementations for the DVC plugin bridge. +//! +//! We implement the "RDC client framework" side of the DVC plugin API: +//! - [`ChannelManager`] implements `IWTSVirtualChannelManager` (provides `CreateListener`) +//! - [`VirtualChannel`] implements `IWTSVirtualChannel` (provides `Write` / `Close`) +//! - [`Listener`] implements `IWTSListener` (stub `GetConfiguration`) +//! +//! The plugin DLL implements the other side: +//! - `IWTSPlugin` (lifecycle) +//! - `IWTSListenerCallback` (accept incoming channels) +//! - `IWTSVirtualChannelCallback` (receive data, close notifications) + +use core::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use ironrdp_dvc::encode_dvc_messages; +use ironrdp_svc::{ChannelFlags, SvcMessage}; +use tracing::{debug, trace}; +use windows::Win32::Foundation::{E_FAIL, E_INVALIDARG, E_NOTIMPL}; +use windows::Win32::System::Com::StructuredStorage::IPropertyBag; +use windows::Win32::System::RemoteDesktop::{ + IWTSListener, IWTSListener_Impl, IWTSListenerCallback, IWTSVirtualChannel, IWTSVirtualChannel_Impl, + IWTSVirtualChannelCallback, IWTSVirtualChannelManager, IWTSVirtualChannelManager_Impl, +}; +use windows::core::{Error, IUnknown, PCSTR, Ref, Result}; +use windows_core::implement; + +/// Callback type for sending DVC messages from the COM plugin back into IronRDP's session loop. +pub(crate) type OnWriteDvc = Box) -> ironrdp_pdu::PduResult<()> + Send>; + +// ─── IWTSVirtualChannelManager ────────────────────────────────────────────── + +/// Rust implementation of `IWTSVirtualChannelManager`. +/// +/// The plugin calls `CreateListener` during `IWTSPlugin::Initialize` to register +/// interest in named DVC channels. We store the channel name → listener callback +/// mapping so the worker can later dispatch `OnNewChannelConnection` when the +/// server opens a matching DVC. +#[implement(IWTSVirtualChannelManager)] +pub(crate) struct ChannelManager { + /// channel_name → IWTSListenerCallback provided by the plugin. + /// Shared via `Rc` so the caller can read the map after `Initialize` completes + /// without needing an unsafe cast from the COM interface pointer. + pub(crate) listeners: Rc>>, +} + +impl ChannelManager { + pub(crate) fn new(listeners: Rc>>) -> Self { + Self { listeners } + } +} + +impl IWTSVirtualChannelManager_Impl for ChannelManager_Impl { + fn CreateListener( + &self, + pszchannelname: &PCSTR, + uflags: u32, + plistenercallback: Ref<'_, IWTSListenerCallback>, + ) -> Result { + // SAFETY: pszchannelname is a null-terminated C string from the plugin + let name = unsafe { pszchannelname.to_string() } + .map_err(|e| Error::new(E_INVALIDARG, format!("invalid channel name: {e}")))?; + + debug!(channel_name = %name, flags = uflags, "Plugin registered DVC listener"); + + let callback: IWTSListenerCallback = plistenercallback + .ok() + .map_err(|_| Error::new(E_INVALIDARG, "null listener callback"))? + .clone(); + self.listeners.borrow_mut().insert(name.clone(), callback); + + let listener: IWTSListener = Listener { channel_name: name }.into(); + + Ok(listener) + } +} + +// ─── IWTSListener ─────────────────────────────────────────────────────────── + +/// Stub `IWTSListener` implementation. Most plugins don't use `GetConfiguration`. +#[implement(IWTSListener)] +struct Listener { + channel_name: String, +} + +impl IWTSListener_Impl for Listener_Impl { + fn GetConfiguration(&self) -> Result { + trace!(channel = %self.channel_name, "IWTSListener::GetConfiguration called (not implemented)"); + Err(Error::new(E_NOTIMPL, "GetConfiguration not implemented")) + } +} + +// ─── IWTSVirtualChannel ───────────────────────────────────────────────────── + +/// Rust implementation of `IWTSVirtualChannel`. +/// +/// When the plugin calls `Write()`, we encode the raw bytes as DVC data PDUs +/// and send them into IronRDP's session loop via the `on_write_dvc` callback. +#[implement(IWTSVirtualChannel)] +pub(crate) struct VirtualChannel { + channel_id: u32, + on_write_dvc: OnWriteDvc, + closed: RefCell, +} + +impl VirtualChannel { + pub(crate) fn new(channel_id: u32, on_write_dvc: OnWriteDvc) -> Self { + Self { + channel_id, + on_write_dvc, + closed: RefCell::new(false), + } + } +} + +/// A trivial DvcEncode wrapper for raw bytes going from the plugin to the server. +struct RawDvcData(Vec); + +impl ironrdp_core::Encode for RawDvcData { + fn encode(&self, dst: &mut ironrdp_core::WriteCursor<'_>) -> ironrdp_core::EncodeResult<()> { + dst.write_slice(&self.0); + Ok(()) + } + + fn name(&self) -> &'static str { + "RawDvcData" + } + + fn size(&self) -> usize { + self.0.len() + } +} + +impl ironrdp_dvc::DvcEncode for RawDvcData {} + +impl IWTSVirtualChannel_Impl for VirtualChannel_Impl { + fn Write(&self, cbsize: u32, pbuffer: *const u8, _preserved: Ref<'_, IUnknown>) -> Result<()> { + if *self.closed.borrow() { + return Err(Error::new(E_FAIL, "channel is closed")); + } + + let size = usize::try_from(cbsize).expect("u32 fits in usize"); + if pbuffer.is_null() && size > 0 { + return Err(Error::new(E_INVALIDARG, "null buffer")); + } + + // SAFETY: the plugin guarantees the buffer is valid for the duration of Write() + let data = if size > 0 { + unsafe { core::slice::from_raw_parts(pbuffer, size) }.to_vec() + } else { + Vec::new() + }; + + trace!( + channel_id = self.channel_id, + size = data.len(), + "IWTSVirtualChannel::Write" + ); + + let msg: ironrdp_dvc::DvcMessage = Box::new(RawDvcData(data)); + let svc_messages = encode_dvc_messages(self.channel_id, vec![msg], ChannelFlags::empty()) + .map_err(|e| Error::new(E_FAIL, format!("encode error: {e}")))?; + + (self.on_write_dvc)(self.channel_id, svc_messages) + .map_err(|e| Error::new(E_FAIL, format!("send error: {e}")))?; + + Ok(()) + } + + fn Close(&self) -> Result<()> { + debug!(channel_id = self.channel_id, "IWTSVirtualChannel::Close"); + *self.closed.borrow_mut() = true; + Ok(()) + } +} + +// ─── Active channel state ─────────────────────────────────────────────────── + +/// Per-channel state held on the COM thread. Tracks the COM objects for a single +/// open DVC channel so we can forward data from IronRDP → plugin. +pub(crate) struct ActiveChannel { + pub(crate) callback: IWTSVirtualChannelCallback, + pub(crate) _channel: IWTSVirtualChannel, +} diff --git a/crates/ironrdp-dvc-com-plugin/src/lib.rs b/crates/ironrdp-dvc-com-plugin/src/lib.rs new file mode 100644 index 0000000000..e001f475ca --- /dev/null +++ b/crates/ironrdp-dvc-com-plugin/src/lib.rs @@ -0,0 +1,38 @@ +//! DVC COM client plugin loader for IronRDP (Windows-only). +//! +//! This crate enables loading native Windows DVC (Dynamic Virtual Channel) client plugin DLLs +//! such as `webauthn.dll` into IronRDP's DVC channel infrastructure. +//! +//! The plugin DLL is loaded via `LoadLibraryW`, its `VirtualChannelGetInstance` export is called +//! to obtain `IWTSPlugin` COM objects, and a Rust implementation of `IWTSVirtualChannelManager` +//! bridges data bidirectionally between the plugin's COM callbacks and IronRDP's DVC system. +//! +//! # Architecture +//! +//! A dedicated COM worker thread owns all COM objects (which are `!Send`). The [`DvcComChannel`] +//! structs (which implement `DvcProcessor + Send`) are registered as DVC channels in IronRDP's +//! `DrdynvcClient` and communicate with the COM thread via `std::sync::mpsc` channels. +//! +//! Outbound data from the plugin (`IWTSVirtualChannel::Write`) is injected into the active +//! session loop via the `on_write_dvc` callback, following the same pattern as +//! `ironrdp-dvc-pipe-proxy`. +//! +//! # References +//! +//! - [Writing a Client DVC Component](https://learn.microsoft.com/en-us/windows/win32/termserv/writing-a-client-dvc-component) +//! - [tsvirtualchannels.h](https://learn.microsoft.com/en-us/windows/win32/api/tsvirtualchannels/) + +#![cfg(windows)] +// The `windows` crate's `#[implement]` macro generates code that triggers these lints. +// We must allow them crate-wide since the generated code is not under our control. +#![allow(clippy::inline_always)] +#![allow(clippy::as_pointer_underscore)] +#![allow(clippy::multiple_unsafe_ops_per_block)] +#![allow(clippy::undocumented_unsafe_blocks)] +#![allow(clippy::unnecessary_safety_comment)] + +mod channel; +mod com; +mod worker; + +pub use channel::{DvcComChannel, load_dvc_plugin}; diff --git a/crates/ironrdp-dvc-com-plugin/src/worker.rs b/crates/ironrdp-dvc-com-plugin/src/worker.rs new file mode 100644 index 0000000000..b5e0f4d04d --- /dev/null +++ b/crates/ironrdp-dvc-com-plugin/src/worker.rs @@ -0,0 +1,220 @@ +//! COM worker thread that drives the plugin lifecycle. +//! +//! All COM objects live on this single thread. Communication with the +//! [`DvcComChannel`](crate::channel::DvcComChannel) instances (which live on +//! IronRDP's async runtime threads) happens via `std::sync::mpsc` channels. + +use std::collections::HashMap; +use std::sync::mpsc as std_mpsc; + +use tracing::{debug, error, trace, warn}; +use windows::Win32::System::RemoteDesktop::{ + IWTSListenerCallback, IWTSPlugin, IWTSVirtualChannel, IWTSVirtualChannelCallback, IWTSVirtualChannelManager, +}; +use windows_core::{BOOL, BSTR}; + +use crate::com::{ActiveChannel, OnWriteDvc, VirtualChannel}; + +/// Commands sent from [`DvcComChannel`] to the COM worker thread. +pub(crate) enum ComCommand { + /// The server created a DVC matching one of our listeners. + /// The COM worker should call `OnNewChannelConnection` on the plugin's listener callback. + ChannelOpened { + channel_name: String, + channel_id: u32, + /// Reply channel: send `true` if the plugin accepted the channel. + accept_tx: std_mpsc::SyncSender, + }, + + /// Data arrived from the RDP server for this channel. + DataReceived { channel_id: u32, data: Vec }, + + /// The server (or IronRDP) closed this channel. + ChannelClosed { channel_id: u32 }, + + /// The RDP connection is established; notify the plugin. + Connected, + + /// The session is ending; tell the plugin to clean up. + Shutdown, +} + +/// Run the COM worker loop on the current thread. +/// +/// This function blocks until a `Shutdown` command is received. It must be called +/// on a dedicated thread since COM objects are `!Send`. +pub(crate) fn run_com_worker( + plugin: IWTSPlugin, + _manager: IWTSVirtualChannelManager, + listeners: HashMap, + command_rx: std_mpsc::Receiver, + on_write_dvc_rx: std_mpsc::Receiver, +) { + debug!("COM worker thread started"); + + let mut active_channels: HashMap = HashMap::new(); + + loop { + let cmd = match command_rx.recv() { + Ok(cmd) => cmd, + Err(_) => { + debug!("Command channel closed, shutting down COM worker"); + break; + } + }; + + match cmd { + ComCommand::Connected => { + debug!("Notifying plugin: Connected"); + // SAFETY: calling COM method on the thread that owns the objects + let result = unsafe { plugin.Connected() }; + if let Err(e) = result { + // Per the spec, Connected() failure is non-fatal + warn!("IWTSPlugin::Connected returned error (non-fatal): {e}"); + } + } + + ComCommand::ChannelOpened { + channel_name, + channel_id, + accept_tx, + } => { + debug!(channel_name = %channel_name, channel_id, "Opening DVC channel via COM plugin"); + + let listener_callback = match listeners.get(&channel_name) { + Some(cb) => cb, + None => { + warn!(channel_name = %channel_name, "No listener registered for channel"); + let _ = accept_tx.send(false); + continue; + } + }; + + // Create the IWTSVirtualChannel that the plugin will use to Write() data + // + // We need to clone the on_write_dvc callback. Since it's boxed, we need + // to receive a new one for each channel. But for simplicity, we'll wrap + // the callback in an Arc-based approach. + // + // Actually, the write callback just sends to an mpsc channel, so we + // receive a fresh one for each channel that needs it. + let on_write: OnWriteDvc = match on_write_dvc_rx.try_recv() { + Ok(cb) => cb, + Err(_) => { + // Reuse the primary one by having the caller send a fresh one + // For the first channel, we already got it above. For subsequent channels + // we need a fresh callback. The caller sends one per ChannelOpened. + // If we can't get one, something went wrong. + error!("No write callback for channel {channel_name}"); + let _ = accept_tx.send(false); + continue; + } + }; + + let virtual_channel: IWTSVirtualChannel = VirtualChannel::new(channel_id, on_write).into(); + + let mut accept = BOOL::default(); + let mut channel_callback: Option = None; + + // SAFETY: calling COM method on the owner thread; pointers are valid for the call duration + let result = unsafe { + listener_callback.OnNewChannelConnection( + &virtual_channel, + &BSTR::default(), + &mut accept, + &mut channel_callback, + ) + }; + + match result { + Ok(()) if accept.as_bool() => { + if let Some(callback) = channel_callback { + debug!(channel_name = %channel_name, channel_id, "Plugin accepted DVC channel"); + active_channels.insert( + channel_id, + ActiveChannel { + callback, + _channel: virtual_channel, + }, + ); + let _ = accept_tx.send(true); + } else { + warn!( + channel_name = %channel_name, channel_id, + "Plugin accepted channel but returned no callback" + ); + let _ = accept_tx.send(false); + } + } + Ok(()) => { + debug!(channel_name = %channel_name, channel_id, "Plugin rejected DVC channel"); + let _ = accept_tx.send(false); + } + Err(e) => { + warn!( + channel_name = %channel_name, channel_id, + "OnNewChannelConnection failed: {e}" + ); + let _ = accept_tx.send(false); + } + } + } + + ComCommand::DataReceived { channel_id, data } => { + trace!(channel_id, size = data.len(), "Forwarding data to COM plugin"); + + if let Some(active) = active_channels.get(&channel_id) { + // SAFETY: calling COM method on the owner thread; buffer is valid for the call + let result = unsafe { active.callback.OnDataReceived(&data) }; + if let Err(e) = result { + warn!(channel_id, "OnDataReceived failed: {e}"); + } + } else { + warn!(channel_id, "Data received for unknown channel"); + } + } + + ComCommand::ChannelClosed { channel_id } => { + debug!(channel_id, "Closing DVC channel in COM plugin"); + + if let Some(active) = active_channels.remove(&channel_id) { + // SAFETY: calling COM method on the owner thread + let result = unsafe { active.callback.OnClose() }; + if let Err(e) = result { + warn!(channel_id, "OnClose failed: {e}"); + } + } + } + + ComCommand::Shutdown => { + debug!("Shutting down COM plugin"); + + // Close all active channels + for (channel_id, active) in active_channels.drain() { + // SAFETY: calling COM method on the owner thread + let result = unsafe { active.callback.OnClose() }; + if let Err(e) = result { + warn!(channel_id, "OnClose during shutdown failed: {e}"); + } + } + + // SAFETY: calling COM methods on the owner thread + unsafe { + let result = plugin.Disconnected(0); + if let Err(e) = result { + warn!("IWTSPlugin::Disconnected failed: {e}"); + } + + let result = plugin.Terminated(); + if let Err(e) = result { + warn!("IWTSPlugin::Terminated failed: {e}"); + } + } + + break; + } + } + } + + debug!("COM worker thread exiting"); +} diff --git a/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md b/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md new file mode 100644 index 0000000000..6aacd9649f --- /dev/null +++ b/crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-pipe-proxy-v0.4.1...ironrdp-dvc-pipe-proxy-v0.5.0)] - 2026-07-10 + +### Bug Fixes + +- Lower verbosity of routine logs in library crates ([c36032f91b](https://github.com/Devolutions/IronRDP/commit/c36032f91b27390a2cd34bfb300cfbe099d847a9)) + + Library crates should not emit info! for routine, repeating operations; + that floods the default logs of the final consumer, which owns the + verbosity decision. Reserve info! for rare connection/session lifecycle + milestones, debug! for significant one-off events, and trace! for the + fine-grained detail only needed when nothing else explains a problem. + +- Remove trailing punctuation from log messages ([#1380](https://github.com/Devolutions/IronRDP/issues/1380)) ([f38554277a](https://github.com/Devolutions/IronRDP/commit/f38554277a1af3085c1fa5739cda515939d09abf)) + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-svc` public dependency to 0.8 + + + +## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-pipe-proxy-v0.4.0...ironrdp-dvc-pipe-proxy-v0.4.1)] - 2026-06-05 + + + +## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-pipe-proxy-v0.3.0...ironrdp-dvc-pipe-proxy-v0.4.0)] - 2026-05-27 + +### Build + +- [**breaking**] Update `ironrdp-pdu` and `ironrdp-svc` public dependencies + +## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-pipe-proxy-v0.2.0...ironrdp-dvc-pipe-proxy-v0.2.1)] - 2025-09-24 + +### Bug Fixes + +- Change dvc proxy pipe mode from Message to Byte on Windows (#986) ([5f52a44b84](https://github.com/Devolutions/IronRDP/commit/5f52a44b840dd71eae6a355be00f1c4c671b3b58)) + +- Add blocking logic for sending dvc pipe messages ([3182a018e2](https://github.com/Devolutions/IronRDP/commit/3182a018e2972eb77c52ea248387c96a9eb6a6a6)) + +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-pipe-proxy-v0.1.0...ironrdp-dvc-pipe-proxy-v0.2.0)] - 2025-08-29 + +### Features + +- Make dvc named pipe proxy cross-platform (#896) ([166b76010c](https://github.com/Devolutions/IronRDP/commit/166b76010cbd8f8674e6e8d4801fee5cda1ad9e5)) + + - Make dvc named pipe proxy cross-platform (Unix implementation via + `tokio::net::unix::UnixStream`) + - Removed unsafe code for Windows implementation, switched to + `tokio::net::windows::named_pipe` diff --git a/crates/ironrdp-dvc-pipe-proxy/Cargo.toml b/crates/ironrdp-dvc-pipe-proxy/Cargo.toml index 924114ab71..2780869214 100644 --- a/crates/ironrdp-dvc-pipe-proxy/Cargo.toml +++ b/crates/ironrdp-dvc-pipe-proxy/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-dvc-pipe-proxy" -version = "0.1.0" +version = "0.5.0" readme = "README.md" description = "DVC named pipe proxy for IronRDP" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -16,13 +17,13 @@ doctest = false test = false [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public (PduResult type) -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.3" } -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public (SvcMessage type) +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public (PduResult type) +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public (SvcMessage type) tracing = { version = "0.1", features = ["log"] } -tokio = { version = "1", features = ["net", "rt", "sync", "macros", "io-util"]} +tokio = { version = "1", features = ["net", "rt", "sync", "macros", "io-util", "fs"]} async-trait = "0.1" [lints] diff --git a/crates/ironrdp-dvc-pipe-proxy/src/message.rs b/crates/ironrdp-dvc-pipe-proxy/src/message.rs index 3dbb330c28..5c31714956 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/message.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/message.rs @@ -1,4 +1,4 @@ -use ironrdp_core::{ensure_size, Encode, EncodeResult}; +use ironrdp_core::{Encode, EncodeResult, ensure_size}; use ironrdp_dvc::DvcEncode; pub(crate) struct RawDataDvcMessage(pub Vec); diff --git a/crates/ironrdp-dvc-pipe-proxy/src/platform/unix.rs b/crates/ironrdp-dvc-pipe-proxy/src/platform/unix.rs index b021004fc3..11c8888a85 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/platform/unix.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/platform/unix.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use tokio::fs; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; -use tracing::{info, trace}; +use tracing::{debug, trace}; use crate::error::DvcPipeProxyError; use crate::os_pipe::OsPipe; @@ -19,9 +19,9 @@ impl OsPipe for UnixPipe { Ok(metadata) => { use std::os::unix::fs::FileTypeExt as _; - info!( + debug!( %pipe_name, - "DVC pipe already exists, removing stale file." + "DVC pipe already exists, removing stale file" ); // Just to be sure, check if it's indeed a socket - @@ -38,7 +38,7 @@ impl OsPipe for UnixPipe { Err(e) if e.kind() == std::io::ErrorKind::NotFound => { trace!( %pipe_name, - "DVC pipe does not exist, creating it." + "DVC pipe does not exist, creating it" ); } Err(e) => { diff --git a/crates/ironrdp-dvc-pipe-proxy/src/platform/windows.rs b/crates/ironrdp-dvc-pipe-proxy/src/platform/windows.rs index 4c2b9c2764..7c69bcba9e 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/platform/windows.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/platform/windows.rs @@ -1,13 +1,16 @@ use async_trait::async_trait; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::windows::named_pipe; +use tracing::debug; use crate::error::DvcPipeProxyError; use crate::os_pipe::OsPipe; const PIPE_BUFFER_SIZE: u32 = 64 * 1024; +// ConnectNamedPipe reports this when the client wins the create/accept race. +const ERROR_PIPE_CONNECTED: i32 = 535; -/// Unix-specific implementation of the OS pipe trait. +/// Windows-specific implementation of the OS pipe trait. pub(crate) struct WindowsPipe { pipe_server: named_pipe::NamedPipeServer, } @@ -15,7 +18,8 @@ pub(crate) struct WindowsPipe { #[async_trait] impl OsPipe for WindowsPipe { async fn connect(pipe_name: &str) -> Result { - let pipe_name = format!("\\\\.\\pipe\\{pipe_name}"); + let pipe_path = format!("\\\\.\\pipe\\{pipe_name}"); + debug!(%pipe_name, %pipe_path, "Creating DVC proxy Windows named pipe"); let pipe_server = named_pipe::ServerOptions::new() .first_pipe_instance(true) @@ -24,11 +28,29 @@ impl OsPipe for WindowsPipe { .max_instances(2) .in_buffer_size(PIPE_BUFFER_SIZE) .out_buffer_size(PIPE_BUFFER_SIZE) - .pipe_mode(named_pipe::PipeMode::Message) - .create(pipe_name) - .map_err(DvcPipeProxyError::Io)?; + .pipe_mode(named_pipe::PipeMode::Byte) + .create(&pipe_path) + .map_err(|error| { + debug!(%pipe_name, %pipe_path, %error, "Failed to create DVC proxy Windows named pipe"); + DvcPipeProxyError::Io(error) + })?; - pipe_server.connect().await.map_err(DvcPipeProxyError::Io)?; + debug!(%pipe_name, %pipe_path, "Waiting for DVC proxy Windows named-pipe client"); + match pipe_server.connect().await { + Ok(()) => {} + Err(error) if error.raw_os_error() == Some(ERROR_PIPE_CONNECTED) => { + debug!( + %pipe_name, + %pipe_path, + "DVC proxy Windows named-pipe client connected before accept" + ); + } + Err(error) => { + debug!(%pipe_name, %pipe_path, %error, "Failed to accept DVC proxy Windows named-pipe client"); + return Err(DvcPipeProxyError::Io(error)); + } + } + debug!(%pipe_name, %pipe_path, "Connected DVC proxy Windows named-pipe client"); Ok(Self { pipe_server }) } diff --git a/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs b/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs index cc4966818b..11c7598a89 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/proxy.rs @@ -1,17 +1,17 @@ -use std::sync::Arc; +use std::sync::{Arc, mpsc}; use ironrdp_core::impl_as_any; use ironrdp_dvc::{DvcClientProcessor, DvcMessage, DvcProcessor}; -use ironrdp_pdu::{pdu_other_err, PduResult}; +use ironrdp_pdu::{PduResult, pdu_other_err}; use ironrdp_svc::SvcMessage; -use tracing::{debug, info}; +use tracing::{debug, error}; -use crate::worker::{run_worker, OnWriteDvcMessage, WorkerCtx}; +use crate::worker::{OnWriteDvcMessage, WorkerCtx, run_worker}; const IO_MPSC_CHANNEL_SIZE: usize = 100; struct WorkerControlCtx { - to_pipe_tx: tokio::sync::mpsc::Sender>, + to_pipe_tx: mpsc::SyncSender>, abort_event: Arc, } @@ -49,14 +49,14 @@ impl DvcProcessor for DvcNamedPipeProxy { } fn start(&mut self, channel_id: u32) -> PduResult> { - info!(%self.channel_name, %self.named_pipe_name, "Starting DVC named pipe proxy"); + debug!(%self.channel_name, %self.named_pipe_name, "Starting DVC named pipe proxy"); let on_write_dvc = self .dvc_write_callback .take() .expect("DvcProcessor::start called multiple times"); - let (to_pipe_tx, to_pipe_rx) = tokio::sync::mpsc::channel(IO_MPSC_CHANNEL_SIZE); + let (to_pipe_tx, to_pipe_rx) = mpsc::sync_channel(IO_MPSC_CHANNEL_SIZE); let abort_event = Arc::new(tokio::sync::Notify::new()); @@ -69,28 +69,50 @@ impl DvcProcessor for DvcNamedPipeProxy { channel_id, }; + #[cfg(not(target_os = "windows"))] + let worker = run_worker::(ctx); + + #[cfg(target_os = "windows")] + let worker = run_worker::(ctx); + + if let Err(worker_error) = worker { + error!( + channel_name = %self.channel_name, + pipe_name = %self.named_pipe_name, + %worker_error, + "Failed to start DVC pipe proxy worker thread" + ); + return Err(pdu_other_err!("start DVC pipe proxy worker: {worker_error}")); + } + self.worker = Some(WorkerControlCtx { to_pipe_tx, abort_event, }); - #[cfg(not(target_os = "windows"))] - run_worker::(ctx); - - #[cfg(target_os = "windows")] - run_worker::(ctx); - Ok(vec![]) } fn process(&mut self, _channel_id: u32, payload: &[u8]) -> PduResult> { if let Some(worker) = &self.worker { - if let Err(error) = worker.to_pipe_tx.try_send(payload.to_vec()) { + // TODO(@pacmancoder): Whatever buffer size we use here, we will hit buffer limit + // eventually and fail if we are not send it in a blocking manner. + // + // Architecturally, blocking whole IronRDP/async runitme is not ideal (even if we know + // that proxy worker is running on a separate thread and there should be no risk of + // deadlock). + // + // Therefore it is only a temporary solution until we have a better design for DVC + // channels which could block. However its the only way to stop the DVC message flow + // from the host. + // + // During testing, blocking here don't seem to affect performance in any noticeable + // way - there is no visible main RDP functionality slowdown during large IO + // stream transfer. + let result = worker.to_pipe_tx.send(payload.to_vec()); + if let Err(error) = result { match error { - tokio::sync::mpsc::error::TrySendError::Full(_) => { - return Err(pdu_other_err!("DVC pipe proxy channel is full")); - } - tokio::sync::mpsc::error::TrySendError::Closed(_) => { + mpsc::SendError(_) => { return Err(pdu_other_err!("DVC pipe proxy channel is closed")); } } diff --git a/crates/ironrdp-dvc-pipe-proxy/src/worker.rs b/crates/ironrdp-dvc-pipe-proxy/src/worker.rs index 7b4dbf0b9b..241e2c9813 100644 --- a/crates/ironrdp-dvc-pipe-proxy/src/worker.rs +++ b/crates/ironrdp-dvc-pipe-proxy/src/worker.rs @@ -1,16 +1,19 @@ -use std::sync::Arc; +use core::time::Duration; +use std::sync::{Arc, mpsc}; use ironrdp_dvc::encode_dvc_messages; use ironrdp_pdu::PduResult; use ironrdp_svc::{ChannelFlags, SvcMessage}; -use tokio::sync::{mpsc, Notify}; -use tracing::{error, info}; +use tokio::sync::Notify; +use tracing::{debug, error}; use crate::error::DvcPipeProxyError; use crate::message::RawDataDvcMessage; use crate::os_pipe::OsPipe; const IO_BUFFER_SIZE: usize = 1024 * 64; // 64K +const INITIAL_RECONNECT_DELAY: Duration = Duration::from_millis(100); +const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(5); pub(crate) type OnWriteDvcMessage = Box) -> PduResult<()> + Send>; @@ -23,38 +26,85 @@ pub(crate) struct WorkerCtx { pub(crate) channel_id: u32, } -pub(crate) fn run_worker(ctx: WorkerCtx) { - let _ = std::thread::spawn(move || { +pub(crate) fn run_worker(ctx: WorkerCtx) -> std::io::Result<()> { + let thread_name = format!("ironrdp-dvc-pipe-{}", ctx.channel_id); + let (startup_tx, startup_rx) = mpsc::sync_channel(1); + + std::thread::Builder::new().name(thread_name).spawn(move || { let channel_name = ctx.channel_name.clone(); let pipe_name = ctx.pipe_name.clone(); + debug!(%channel_name, %pipe_name, "Starting DVC pipe proxy worker thread"); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(DvcPipeProxyError::Io); - - let runtime = match runtime { + let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() { Ok(runtime) => runtime, Err(error) => { error!( %channel_name, %pipe_name, - ?error, - "DVC pipe proxy worker thread initialization failed." + %error, + "Failed to initialize DVC pipe proxy worker thread" ); + let _ = startup_tx.send(Err(error)); return; } }; - if let Err(error) = runtime.block_on(worker::

(ctx)) { + let (async_tx, async_rx) = tokio::sync::mpsc::unbounded_channel(); + let WorkerCtx { + on_write_dvc, + to_pipe_rx: std_rx, + abort_event, + pipe_name, + channel_name, + channel_id, + } = ctx; + + let bridge_thread_name = format!("ironrdp-dvc-pipe-{channel_id}-bridge"); + if let Err(error) = std::thread::Builder::new().name(bridge_thread_name).spawn(move || { + while let Ok(data) = std_rx.recv() { + if async_tx.send(data).is_err() { + break; // Receiver dropped + } + } + }) { error!( %channel_name, %pipe_name, - ?error, - "DVC pipe proxy worker thread has failed." + %error, + "Failed to start DVC pipe proxy bridge thread" ); + let _ = startup_tx.send(Err(error)); + return; } - }); + + let ctx = BridgedWorkerCtx { + on_write_dvc, + to_pipe_rx: async_rx, + abort_event, + pipe_name, + channel_name, + channel_id, + }; + + if startup_tx.send(Ok(())).is_err() { + return; + } + + debug!( + channel_name = %ctx.channel_name, + pipe_name = %ctx.pipe_name, + "Started DVC pipe proxy worker thread" + ); + if let Err(error) = runtime.block_on(worker::

(ctx)) { + error!(?error, "DVC pipe proxy worker thread has failed"); + } + })?; + + startup_rx.recv().unwrap_or_else(|_| { + Err(std::io::Error::other( + "dvc pipe proxy worker stopped before startup completed", + )) + }) } enum NextWorkerState { @@ -62,17 +112,26 @@ enum NextWorkerState { Reconnect, } -async fn process_client(ctx: &mut WorkerCtx) -> Result { +struct BridgedWorkerCtx { + on_write_dvc: OnWriteDvcMessage, + to_pipe_rx: tokio::sync::mpsc::UnboundedReceiver>, + abort_event: Arc, + pipe_name: String, + channel_name: String, + channel_id: u32, +} + +async fn process_client(ctx: &mut BridgedWorkerCtx) -> Result { let pipe_name = &ctx.pipe_name; let channel_name = &ctx.channel_name; let mut pipe = tokio::select! { pipe = P::connect(pipe_name) => { - info!(%channel_name, %pipe_name,"DVC proxy worker thread has started."); + debug!(%channel_name, %pipe_name, "DVC proxy worker thread has started"); pipe? } _ = ctx.abort_event.notified() => { - info!(%channel_name, %pipe_name, "DVC proxy worker thread has been aborted."); + debug!(%channel_name, %pipe_name, "DVC proxy worker thread has been aborted"); return Ok(NextWorkerState::Abort); } }; @@ -86,14 +145,14 @@ async fn process_client(ctx: &mut WorkerCtx) -> Result { - info!(%channel_name, %pipe_name, "Received abort signal for DVC proxy worker thread."); + debug!(%channel_name, %pipe_name, "Received abort signal for DVC proxy worker thread"); return Ok(NextWorkerState::Abort); } read_bytes_result = read_pipe => { let read_bytes = read_bytes_result?; if read_bytes == 0 { - info!(%channel_name, %pipe_name, "DVC proxy pipe returned EOF"); + debug!(%channel_name, %pipe_name, "DVC proxy pipe returned EOF"); // If client unexpectedly closed the connection, we should // still be able to reconnect to same session. @@ -115,7 +174,7 @@ async fn process_client(ctx: &mut WorkerCtx) -> Result data, None => { - info!(%channel_name, %pipe_name, "DVC mpsc channel returned EOF."); + debug!(%channel_name, %pipe_name, "DVC mpsc channel returned EOF"); // Server DVC has been closed, there is no point in // trying to reconnect. return Ok(NextWorkerState::Abort); @@ -125,29 +184,43 @@ async fn process_client(ctx: &mut WorkerCtx) -> Result(mut ctx: WorkerCtx) -> Result<(), DvcPipeProxyError> { +async fn worker(mut bridged_ctx: BridgedWorkerCtx) -> Result<(), DvcPipeProxyError> { + let mut reconnect_delay = INITIAL_RECONNECT_DELAY; + loop { - match process_client::

(&mut ctx).await? { - NextWorkerState::Abort => { - info!( - channel_name = %ctx.channel_name, - pipe_name = %ctx.pipe_name, - "Aborting DVC proxy worker thread." + match process_client::

(&mut bridged_ctx).await { + Err(error) => { + error!( + channel_name = %bridged_ctx.channel_name, + pipe_name = %bridged_ctx.pipe_name, + ?error, + retry_delay_ms = reconnect_delay.as_millis(), + "DVC pipe proxy connection failed; retrying" + ); + std::thread::sleep(reconnect_delay); + reconnect_delay = reconnect_delay.saturating_mul(2).min(MAX_RECONNECT_DELAY); + } + Ok(NextWorkerState::Abort) => { + debug!( + channel_name = %bridged_ctx.channel_name, + pipe_name = %bridged_ctx.pipe_name, + "Abort DVC proxy worker thread" ); break; } - NextWorkerState::Reconnect => { - info!( - channel_name = %ctx.channel_name, - pipe_name = %ctx.pipe_name, - "Reconnecting to DVC pipe..." + Ok(NextWorkerState::Reconnect) => { + reconnect_delay = INITIAL_RECONNECT_DELAY; + debug!( + channel_name = %bridged_ctx.channel_name, + pipe_name = %bridged_ctx.pipe_name, + "Reconnect to DVC pipe" ); continue; } diff --git a/crates/ironrdp-dvc/CHANGELOG.md b/crates/ironrdp-dvc/CHANGELOG.md index c6bc06caaa..854052722b 100644 --- a/crates/ironrdp-dvc/CHANGELOG.md +++ b/crates/ironrdp-dvc/CHANGELOG.md @@ -6,6 +6,67 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.7.0...ironrdp-dvc-v0.8.0)] - 2026-07-10 + +### Features + +- Expose dynamic channel accessors ([#1368](https://github.com/Devolutions/IronRDP/issues/1368)) ([985d353543](https://github.com/Devolutions/IronRDP/commit/985d353543cf45eacfe0cc57aca86502665a3a44)) + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-svc` public dependency to 0.8 + + + +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.6.0...ironrdp-dvc-v0.7.0)] - 2026-06-05 + +### Bug Fixes + +- [**breaking**] Add channel_id parameter to DvcChannelListener::create ([#1358](https://github.com/Devolutions/IronRDP/issues/1358)) ([f21470c6dc](https://github.com/Devolutions/IronRDP/commit/f21470c6dc20e1b10b4bbf750a406644479a4b35)) + + Updates the dynamic virtual channel (DVC) client listener interface in ironrdp-dvc to pass the channel_id (from the incoming DYNVC_CREATE_REQ) into the listener’s create method, enabling listeners to differentiate/control per-instance behavior based on the negotiated dynamic channel ID. + + + +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.5.0...ironrdp-dvc-v0.6.0)] - 2026-05-27 + +### Features + +- Implement ECHO virtual channel ([#1109](https://github.com/Devolutions/IronRDP/issues/1109)) ([6f6496ad29](https://github.com/Devolutions/IronRDP/commit/6f6496ad29395099563d50417d6dfff623914ee6)) + +- Add DvcChannelListener for multi-instance DVC support ([#1142](https://github.com/Devolutions/IronRDP/issues/1142)) ([28e8628f0e](https://github.com/Devolutions/IronRDP/commit/28e8628f0e3cea9f7723a73abf5fd7ed2da968f0)) + +- Close channel API for server and client ([#1302](https://github.com/Devolutions/IronRDP/issues/1302)) ([196d18dfaa](https://github.com/Devolutions/IronRDP/commit/196d18dfaa7ec899946bb90f4dcb8bad31872f48)) + +### Bug Fixes + +- Negotiate DVC version from server capabilities ([d094cbeb75](https://github.com/Devolutions/IronRDP/commit/d094cbeb7501c83fc6ad5401ba69d22f79d6657c)) + + The client was hardcoded to respond with CapsVersion::V1 regardless + of what the server requested. Servers that require V2 or V3 (such + as XRDP) would reject the channel with "Dynamic Virtual Channel + version 1 is not supported." + + Echo the server's requested version in the capabilities response + instead. This correctly handles V1, V2, and V3 depending on what + the server advertises. When a Create arrives before Capabilities + (fallback path), default to V2 as the most broadly compatible + version. + + Also bump the server-side capabilities request from V1 to V2 to + advertise priority charge support. + + Add CapabilitiesRequestPdu::version() accessor to expose the + server's requested version from the parsed PDU. + +## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.4.0...ironrdp-dvc-v0.4.1)] - 2025-09-04 + +### Features + +- Add API to attach dynamic channels to an already created `DrdynvcClient` instance (#938) ([17833fe009](https://github.com/Devolutions/IronRDP/commit/17833fe009279823c4076d3e2e0c7d063fd24a43)) + ## [[0.3.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.3.0...ironrdp-dvc-v0.3.1)] - 2025-06-27 ### Features @@ -18,8 +79,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump ironrdp-pdu - - ## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.1.2...ironrdp-dvc-v0.1.3)] - 2025-03-12 ### Build @@ -40,8 +99,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-dvc-v0.1.0...ironrdp-dvc-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-dvc/Cargo.toml b/crates/ironrdp-dvc/Cargo.toml index 5ea17b6436..837b85f92c 100644 --- a/crates/ironrdp-dvc/Cargo.toml +++ b/crates/ironrdp-dvc/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-dvc" -version = "0.3.1" +version = "0.8.0" readme = "README.md" description = "DRDYNVC static channel implementation and traits to implement dynamic virtual channels" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -20,11 +21,10 @@ default = [] std = [] [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5", features = ["alloc"] } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["alloc"] } # public tracing = { version = "0.1", features = ["log"] } -slab = "0.4" [lints] workspace = true diff --git a/crates/ironrdp-dvc/src/client.rs b/crates/ironrdp-dvc/src/client.rs index 30e663e94e..22ca79bfb1 100644 --- a/crates/ironrdp-dvc/src/client.rs +++ b/crates/ironrdp-dvc/src/client.rs @@ -1,22 +1,61 @@ +use alloc::boxed::Box; +use alloc::collections::btree_map::BTreeMap; use alloc::vec::Vec; use core::any::TypeId; use core::fmt; -use ironrdp_core::{impl_as_any, Decode as _, DecodeResult, ReadCursor}; +use crate::alloc::borrow::ToOwned as _; +use ironrdp_core::{Decode as _, DecodeResult, ReadCursor, impl_as_any}; use ironrdp_pdu::{self as pdu, decode_err, encode_err, pdu_other_err}; use ironrdp_svc::{ChannelFlags, CompressionCondition, SvcClientProcessor, SvcMessage, SvcProcessor}; -use pdu::gcc::ChannelName; use pdu::PduResult; +use pdu::gcc::ChannelName; use tracing::debug; use crate::pdu::{ CapabilitiesResponsePdu, CapsVersion, ClosePdu, CreateResponsePdu, CreationStatus, DrdynvcClientPdu, DrdynvcServerPdu, }; -use crate::{encode_dvc_messages, DvcProcessor, DynamicChannelSet, DynamicVirtualChannel}; +use crate::{DvcProcessor, DynamicChannelId, DynamicChannelName, DynamicVirtualChannel, encode_dvc_messages}; pub trait DvcClientProcessor: DvcProcessor {} +pub trait DvcChannelListener: Send { + fn channel_name(&self) -> &str; + + /// Called for each incoming DYNVC_CREATE_REQ matching this name. + /// Return `None` to reject (NO_LISTENER). + fn create(&mut self, channel_id: DynamicChannelId) -> Option>; +} + +pub type DynamicChannelListener = Box; + +/// For pre-registered DVC +struct OnceListener { + inner: Option>, +} + +impl OnceListener { + fn new(dvc_processor: impl DvcProcessor + 'static) -> Self { + Self { + inner: Some(Box::new(dvc_processor)), + } + } +} + +impl DvcChannelListener for OnceListener { + fn channel_name(&self) -> &str { + self.inner + .as_ref() + .expect("channel name called after created") + .channel_name() + } + + fn create(&mut self, _channel_id: DynamicChannelId) -> Option> { + self.inner.take() + } +} + /// DRDYNVC Static Virtual Channel (the Remote Desktop Protocol: Dynamic Virtual Channel Extension) /// /// It adds support for dynamic virtual channels (DVC). @@ -51,17 +90,66 @@ impl DrdynvcClient { } } - // FIXME(#61): it’s likely we want to enable adding dynamic channels at any point during the session (message passing? other approach?) - + /// Registers a pre-initialized dynamic virtual channel with the [`DrdynvcClient`], + /// making it available for immediate use when the session starts. + /// + /// # Note + /// + /// If a listener or a pre-registered channel with the same name already exists, + /// it will be silently overwritten. #[must_use] pub fn with_dynamic_channel(mut self, channel: T) -> Self where T: DvcProcessor + 'static, { - self.dynamic_channels.insert(channel); + self.dynamic_channels.register_once(channel); + self + } + + /// Attaches a pre-initialized dynamic virtual channel with the [`DrdynvcClient`], + /// making it available for immediate use when the session starts. + /// + /// # Note + /// + /// If a listener or a pre-registered channel with the same name already exists, + /// it will be silently overwritten. + pub fn attach_dynamic_channel(&mut self, channel: T) + where + T: DvcProcessor + 'static, + { + self.dynamic_channels.register_once(channel); + } + + /// Bind a listener. + /// + /// # Note + /// + /// * Doesn't support [TypeId] lookup via [DrdynvcClient::get_dvc_by_type_id]. + /// * If a listener or a pre-registered channel with the same name already exists, + /// it will be silently overwritten. + #[must_use] + pub fn with_listener(mut self, listener: T) -> Self + where + T: DvcChannelListener + 'static, + { + self.dynamic_channels.register_listener(listener); self } + /// Attaches a listener. + /// + /// # Note + /// + /// * Doesn't support [TypeId] lookup via [DrdynvcClient::get_dvc_by_type_id]. + /// * If a listener or a pre-registered channel with the same name already exists, + /// it will be silently overwritten. + pub fn attach_listener(&mut self, listener: T) + where + T: DvcChannelListener + 'static, + { + self.dynamic_channels.register_listener(listener); + } + pub fn get_dvc_by_type_id(&self) -> Option<&DynamicVirtualChannel> where T: DvcProcessor, @@ -73,12 +161,21 @@ impl DrdynvcClient { self.dynamic_channels.get_by_channel_id(channel_id) } - fn create_capabilities_response(&mut self) -> SvcMessage { - let caps_response = DrdynvcClientPdu::Capabilities(CapabilitiesResponsePdu::new(CapsVersion::V1)); + pub fn get_dvc_by_channel_id_mut(&mut self, channel_id: u32) -> Option<&mut DynamicVirtualChannel> { + self.dynamic_channels.get_by_channel_id_mut(channel_id) + } + + fn create_capabilities_response(&mut self, server_version: CapsVersion) -> SvcMessage { + let caps_response = DrdynvcClientPdu::Capabilities(CapabilitiesResponsePdu::new(server_version)); debug!("Send DVC Capabilities Response PDU: {caps_response:?}"); self.cap_handshake_done = true; SvcMessage::from(caps_response) } + + pub fn close_channel(&mut self, channel_id: u32) -> Option { + self.dynamic_channels.remove_by_channel_id(channel_id)?; + Some(SvcMessage::from(DrdynvcClientPdu::Close(ClosePdu::new(channel_id)))) + } } impl_as_any!(DrdynvcClient); @@ -105,32 +202,37 @@ impl SvcProcessor for DrdynvcClient { match pdu { DrdynvcServerPdu::Capabilities(caps_request) => { debug!("Got DVC Capabilities Request PDU: {caps_request:?}"); - responses.push(self.create_capabilities_response()); + responses.push(self.create_capabilities_response(caps_request.version())); } DrdynvcServerPdu::Create(create_request) => { debug!("Got DVC Create Request PDU: {create_request:?}"); - let channel_name = create_request.channel_name; - let channel_id = create_request.channel_id; + let channel_id = create_request.channel_id(); + let channel_name = create_request.into_channel_name(); if !self.cap_handshake_done { debug!( "Got DVC Create Request PDU before a Capabilities Request PDU. \ Sending Capabilities Response PDU before the Create Response PDU." ); - responses.push(self.create_capabilities_response()); + responses.push(self.create_capabilities_response(CapsVersion::V2)); } - let channel_exists = self.dynamic_channels.get_by_channel_name(&channel_name).is_some(); - let (creation_status, start_messages) = if channel_exists { - // If we have a handler for this channel, attach the channel ID - // and get any start messages. - self.dynamic_channels - .attach_channel_id(channel_name.clone(), channel_id); - let dynamic_channel = self.dynamic_channels.get_by_channel_name_mut(&channel_name).unwrap(); - (CreationStatus::OK, dynamic_channel.start()?) - } else { - (CreationStatus::NO_LISTENER, Vec::new()) - }; + let (creation_status, start_messages) = + if let Some(dvc) = self.dynamic_channels.try_create_channel(&channel_name, channel_id) { + match dvc.start(channel_id) { + Ok(messages) => (CreationStatus::OK, messages), + Err(e) => { + debug!( + ?channel_id, error = %e, + "DVC start failed; removing channel and reporting NO_LISTENER" + ); + self.dynamic_channels.remove_by_channel_id(channel_id); + (CreationStatus::NO_LISTENER, Vec::new()) + } + } + } else { + (CreationStatus::NO_LISTENER, Vec::new()) + }; let create_response = DrdynvcClientPdu::Create(CreateResponsePdu::new(channel_id, creation_status)); debug!("Send DVC Create Response PDU: {create_response:?}"); @@ -144,14 +246,14 @@ impl SvcProcessor for DrdynvcClient { ); } } - DrdynvcServerPdu::Close(close_request) => { - debug!("Got DVC Close Request PDU: {close_request:?}"); - self.dynamic_channels.remove_by_channel_id(close_request.channel_id); - - let close_response = DrdynvcClientPdu::Close(ClosePdu::new(close_request.channel_id)); - - debug!("Send DVC Close Response PDU: {close_response:?}"); - responses.push(SvcMessage::from(close_response)); + DrdynvcServerPdu::Close(close) => { + debug!("Got DVC Close PDU: {close:?}"); + let channel_id = close.channel_id(); + if self.dynamic_channels.remove_by_channel_id(channel_id).is_some() { + let close_response = DrdynvcClientPdu::Close(ClosePdu::new(channel_id)); + debug!("Send DVC Close Response PDU: {close_response:?}"); + responses.push(SvcMessage::from(close_response)); + } } DrdynvcServerPdu::Data(data) => { let channel_id = data.channel_id(); @@ -172,6 +274,107 @@ impl SvcProcessor for DrdynvcClient { } } +struct ListenerEntry { + listener: DynamicChannelListener, + /// `Some` only for channels registered via `with_dynamic_channel()`. + type_id: Option, +} + +struct DynamicChannelSet { + listeners: BTreeMap, + active_channels: BTreeMap, + type_id_to_channel_id: BTreeMap, +} + +impl DynamicChannelSet { + #[inline] + fn new() -> Self { + Self { + listeners: BTreeMap::new(), + active_channels: BTreeMap::new(), + type_id_to_channel_id: BTreeMap::new(), + } + } + + fn register_listener(&mut self, listener: T) { + let name = listener.channel_name().to_owned(); + self.listeners.insert( + name, + ListenerEntry { + listener: Box::new(listener), + type_id: None, + }, + ); + } + + fn register_once(&mut self, channel: T) { + let name = channel.channel_name().to_owned(); + self.listeners.insert( + name, + ListenerEntry { + listener: Box::new(OnceListener::new(channel)), + type_id: Some(TypeId::of::()), + }, + ); + } + + fn try_create_channel( + &mut self, + name: &DynamicChannelName, + channel_id: DynamicChannelId, + ) -> Option<&mut DynamicVirtualChannel> { + let entry = self.listeners.get_mut(name)?; + let processor = entry.listener.create(channel_id)?; + + if let Some(type_id) = entry.type_id { + self.type_id_to_channel_id.insert(type_id, channel_id); + } + + let dvc = DynamicVirtualChannel::from_boxed(processor); + // `dvc.channel_id` stays `None` here — it is set by `DynamicVirtualChannel::start` + // on success, so `Drop` only invokes `close` for channels that were actually opened. + let dvc = match self.active_channels.entry(channel_id) { + alloc::collections::btree_map::Entry::Occupied(mut e) => { + e.insert(dvc); + e.into_mut() + } + alloc::collections::btree_map::Entry::Vacant(e) => e.insert(dvc), + }; + Some(dvc) + } + + fn get_by_type_id(&self, type_id: TypeId) -> Option<&DynamicVirtualChannel> { + self.type_id_to_channel_id + .get(&type_id) + .and_then(|id| self.active_channels.get(id)) + } + + fn get_by_channel_id(&self, id: DynamicChannelId) -> Option<&DynamicVirtualChannel> { + self.active_channels.get(&id) + } + + fn get_by_channel_id_mut(&mut self, id: DynamicChannelId) -> Option<&mut DynamicVirtualChannel> { + self.active_channels.get_mut(&id) + } + + fn remove_by_channel_id(&mut self, id: DynamicChannelId) -> Option { + self.active_channels.remove(&id).inspect(|dvc| { + let type_id = dvc.processor_type_id(); + + // Only matters for pre-registered channels + if let alloc::collections::btree_map::Entry::Occupied(entry) = self.type_id_to_channel_id.entry(type_id) + && entry.get() == &id + { + entry.remove(); + } + }) + } + + #[inline] + fn values(&self) -> impl Iterator { + self.active_channels.values() + } +} impl SvcClientProcessor for DrdynvcClient {} fn decode_dvc_message(user_data: &[u8]) -> DecodeResult { diff --git a/crates/ironrdp-dvc/src/complete_data.rs b/crates/ironrdp-dvc/src/complete_data.rs index acb6d919d4..9c0cc8d371 100644 --- a/crates/ironrdp-dvc/src/complete_data.rs +++ b/crates/ironrdp-dvc/src/complete_data.rs @@ -1,7 +1,7 @@ use alloc::vec::Vec; use core::cmp; -use ironrdp_core::{cast_length, invalid_field_err, DecodeResult}; +use ironrdp_core::{DecodeResult, cast_length, invalid_field_err}; use tracing::error; use crate::pdu::{DataFirstPdu, DataPdu, DrdynvcDataPdu}; @@ -28,7 +28,7 @@ impl CompleteData { } fn process_data_first_pdu(&mut self, data_first: DataFirstPdu) -> DecodeResult>> { - let total_data_size: DecodeResult<_> = cast_length!("DataFirstPdu::length", data_first.length); + let total_data_size: DecodeResult<_> = cast_length!("DataFirstPdu::length", data_first.length()); let total_data_size = total_data_size?; if self.total_size != 0 || !self.data.is_empty() { error!("Incomplete DVC message, it will be skipped"); @@ -36,11 +36,11 @@ impl CompleteData { self.data.clear(); } - if total_data_size == data_first.data.len() { - Ok(Some(data_first.data)) + if total_data_size == data_first.data().len() { + Ok(Some(data_first.into_data())) } else { self.total_size = total_data_size; - self.data = data_first.data; + self.data = data_first.into_data(); Ok(None) } @@ -49,22 +49,22 @@ impl CompleteData { fn process_data_pdu(&mut self, mut data: DataPdu) -> DecodeResult>> { if self.total_size == 0 && self.data.is_empty() { // message is not fragmented - return Ok(Some(data.data)); + return Ok(Some(data.into_data())); } // The message is fragmented and needs to be reassembled. - match self.data.len().checked_add(data.data.len()) { + match self.data.len().checked_add(data.data().len()) { Some(actual_data_length) => { match actual_data_length.cmp(&(self.total_size)) { cmp::Ordering::Less => { // this is one of the fragmented messages, just append it - self.data.append(&mut data.data); + self.data.append(data.data_mut()); Ok(None) } cmp::Ordering::Equal => { // this is the last fragmented message, need to return the whole reassembled message self.total_size = 0; - self.data.append(&mut data.data); + self.data.append(data.data_mut()); Ok(Some(self.data.drain(..).collect())) } cmp::Ordering::Greater => { diff --git a/crates/ironrdp-dvc/src/lib.rs b/crates/ironrdp-dvc/src/lib.rs index bb2a9f6c2e..9fd4527b25 100644 --- a/crates/ironrdp-dvc/src/lib.rs +++ b/crates/ironrdp-dvc/src/lib.rs @@ -4,20 +4,19 @@ extern crate alloc; +use core::any::TypeId; + use alloc::boxed::Box; -use alloc::collections::BTreeMap; use alloc::string::String; use alloc::vec::Vec; -use core::any::TypeId; use pdu::DrdynvcDataPdu; -use crate::alloc::borrow::ToOwned as _; // Re-export ironrdp_pdu crate for convenience #[rustfmt::skip] // do not re-order this pub use pub use ironrdp_pdu; -use ironrdp_core::{assert_obj_safe, cast_length, encode_vec, other_err, AsAny, Encode, EncodeResult}; -use ironrdp_pdu::{decode_err, pdu_other_err, PduResult}; +use ironrdp_core::{AsAny, Encode, EncodeResult, assert_obj_safe, cast_length, encode_vec, other_err}; +use ironrdp_pdu::{PduResult, decode_err}; use ironrdp_svc::SvcMessage; mod complete_data; @@ -73,7 +72,9 @@ pub fn encode_dvc_messages( while off < total_length { let first = off == 0; - let remaining_length = total_length.checked_sub(off).unwrap(); + + #[expect(clippy::missing_panics_doc, reason = "unreachable panic (checked underflow)")] + let remaining_length = total_length.checked_sub(off).expect("never overflow"); let size = core::cmp::min(remaining_length, DrdynvcDataPdu::MAX_DATA_SIZE); let end = off .checked_add(size) @@ -104,19 +105,31 @@ pub struct DynamicVirtualChannel { complete_data: CompleteData, /// The channel ID assigned by the server. /// - /// This field is `None` until the server assigns a channel ID. + /// `Some` only after [`DynamicVirtualChannel::start`] has succeeded. This invariant channel_id: Option, } +impl Drop for DynamicVirtualChannel { + fn drop(&mut self) { + if let Some(id) = self.channel_id { + self.channel_processor.close(id); + } + } +} + impl DynamicVirtualChannel { - fn new(handler: T) -> Self { + fn from_boxed(processor: Box) -> Self { Self { - channel_processor: Box::new(handler), + channel_processor: processor, complete_data: CompleteData::new(), channel_id: None, } } + fn processor_type_id(&self) -> TypeId { + self.channel_processor.as_any().type_id() + } + pub fn is_open(&self) -> bool { self.channel_id.is_some() } @@ -129,12 +142,10 @@ impl DynamicVirtualChannel { self.channel_processor.as_any().downcast_ref() } - fn start(&mut self) -> PduResult> { - if let Some(channel_id) = self.channel_id { - self.channel_processor.start(channel_id) - } else { - Err(pdu_other_err!("DynamicVirtualChannel::start", "channel ID not set")) - } + fn start(&mut self, channel_id: DynamicChannelId) -> PduResult> { + let messages = self.channel_processor.start(channel_id)?; + self.channel_id = Some(channel_id); + Ok(messages) } fn process(&mut self, pdu: DrdynvcDataPdu) -> PduResult> { @@ -152,77 +163,51 @@ impl DynamicVirtualChannel { } } -struct DynamicChannelSet { - channels: BTreeMap, - name_to_channel_id: BTreeMap, - channel_id_to_name: BTreeMap, - type_id_to_name: BTreeMap, +#[derive(Debug, Clone, Copy)] +pub struct DynamicChannelRef<'a, T> { + channel_id: DynamicChannelId, + processor: &'a T, } -impl DynamicChannelSet { - #[inline] - fn new() -> Self { - Self { - channels: BTreeMap::new(), - name_to_channel_id: BTreeMap::new(), - channel_id_to_name: BTreeMap::new(), - type_id_to_name: BTreeMap::new(), - } - } - - fn insert(&mut self, channel: T) -> Option { - let name = channel.channel_name().to_owned(); - self.type_id_to_name.insert(TypeId::of::(), name.clone()); - self.channels.insert(name, DynamicVirtualChannel::new(channel)) - } - - fn attach_channel_id(&mut self, name: DynamicChannelName, id: DynamicChannelId) -> Option { - self.channel_id_to_name.insert(id, name.clone()); - self.name_to_channel_id.insert(name.clone(), id); - let dvc = self.get_by_channel_name_mut(&name)?; - let old_id = dvc.channel_id; - dvc.channel_id = Some(id); - old_id +impl DynamicChannelRef<'_, T> { + pub fn channel_id(&self) -> DynamicChannelId { + self.channel_id } +} - fn get_by_type_id(&self, type_id: TypeId) -> Option<&DynamicVirtualChannel> { - self.type_id_to_name - .get(&type_id) - .and_then(|name| self.channels.get(name)) +impl<'a, T: DvcProcessor> DynamicChannelRef<'a, T> { + fn new(channel_id: u32, processor: &'a T) -> Self { + Self { channel_id, processor } } - fn get_by_channel_name(&self, name: &DynamicChannelName) -> Option<&DynamicVirtualChannel> { - self.channels.get(name) + pub fn processor(&self) -> &'a T { + self.processor } +} - fn get_by_channel_name_mut(&mut self, name: &DynamicChannelName) -> Option<&mut DynamicVirtualChannel> { - self.channels.get_mut(name) - } +#[derive(Debug)] +pub struct DynamicChannelMut<'a, T> { + channel_id: DynamicChannelId, + processor: &'a mut T, +} - fn get_by_channel_id(&self, id: DynamicChannelId) -> Option<&DynamicVirtualChannel> { - self.channel_id_to_name - .get(&id) - .and_then(|name| self.channels.get(name)) +impl DynamicChannelMut<'_, T> { + pub fn channel_id(&self) -> DynamicChannelId { + self.channel_id } +} - fn get_by_channel_id_mut(&mut self, id: DynamicChannelId) -> Option<&mut DynamicVirtualChannel> { - self.channel_id_to_name - .get(&id) - .and_then(|name| self.channels.get_mut(name)) +impl<'a, T: DvcProcessor> DynamicChannelMut<'a, T> { + fn new(channel_id: u32, processor: &'a mut T) -> Self { + Self { channel_id, processor } } - fn remove_by_channel_id(&mut self, id: DynamicChannelId) -> Option { - if let Some(name) = self.channel_id_to_name.remove(&id) { - return self.name_to_channel_id.remove(&name); - // Channels are retained in the `self.channels` and `self.type_id_to_name` map to allow potential - // dynamic re-addition by the server. - } - None + pub fn processor(&self) -> &T { + self.processor } - #[inline] - fn values(&self) -> impl Iterator { - self.channels.values() + pub fn processor_mut(&mut self) -> &mut T { + self.processor } } diff --git a/crates/ironrdp-dvc/src/pdu.rs b/crates/ironrdp-dvc/src/pdu.rs index 0b081b6dbf..3a277be603 100644 --- a/crates/ironrdp-dvc/src/pdu.rs +++ b/crates/ironrdp-dvc/src/pdu.rs @@ -2,11 +2,11 @@ use alloc::format; use core::fmt; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, unsupported_value_err, Decode, DecodeError, - DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeError, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, + ensure_fixed_part_size, ensure_size, invalid_field_err, unsupported_value_err, }; use ironrdp_pdu::utils::{ - checked_sum, encoded_str_len, read_string_from_cursor, strict_sum, write_string_to_cursor, CharacterSet, + CharacterSet, checked_sum, encoded_str_len, read_string_from_cursor, strict_sum, write_string_to_cursor, }; use ironrdp_svc::SvcEncode; @@ -200,7 +200,7 @@ impl Header { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u8(((self.cmd as u8) << 4) | (Into::::into(self.sp) << 2) | Into::::into(self.cb_id)); + dst.write_u8(((self.cmd.as_u8()) << 4) | (Into::::into(self.sp) << 2) | Into::::into(self.cb_id)); Ok(()) } @@ -235,6 +235,16 @@ enum Cmd { SoftSyncResponse = 0x09, } +impl Cmd { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + impl TryFrom for Cmd { type Error = DecodeError; @@ -282,12 +292,12 @@ impl From for String { #[derive(Debug, PartialEq)] pub struct DataFirstPdu { header: Header, - pub channel_id: DynamicChannelId, + channel_id: DynamicChannelId, /// Length is the *total* length of the data to be sent, including the length /// of the data that will be sent by subsequent DVC_DATA PDUs. - pub length: u32, + length: u32, /// Data is just the data to be sent in this PDU. - pub data: Vec, + data: Vec, } impl DataFirstPdu { @@ -322,6 +332,18 @@ impl DataFirstPdu { } } + pub fn length(&self) -> u32 { + self.length + } + + pub fn data(&self) -> &[u8] { + &self.data + } + + pub fn into_data(self) -> Vec { + self.data + } + fn decode(header: Header, src: &mut ReadCursor<'_>) -> DecodeResult { let fixed_part_size = checked_sum(&[header.cb_id.size_of_val(), header.sp.size_of_val()])?; ensure_size!(in: src, size: fixed_part_size); @@ -434,8 +456,8 @@ impl From for u8 { #[derive(Debug, PartialEq)] pub struct DataPdu { header: Header, - pub channel_id: DynamicChannelId, - pub data: Vec, + channel_id: DynamicChannelId, + data: Vec, } impl DataPdu { @@ -447,6 +469,18 @@ impl DataPdu { } } + pub fn data(&self) -> &[u8] { + &self.data + } + + pub fn into_data(self) -> Vec { + self.data + } + + pub fn data_mut(&mut self) -> &mut Vec { + &mut self.data + } + fn decode(header: Header, src: &mut ReadCursor<'_>) -> DecodeResult { ensure_size!(in: src, size: header.cb_id.size_of_val()); let channel_id = header.cb_id.decode_val(src)?; @@ -485,8 +519,8 @@ impl DataPdu { #[derive(Debug, PartialEq)] pub struct CreateResponsePdu { header: Header, - pub channel_id: DynamicChannelId, - pub creation_status: CreationStatus, + channel_id: DynamicChannelId, + creation_status: CreationStatus, } impl CreateResponsePdu { @@ -498,6 +532,14 @@ impl CreateResponsePdu { } } + pub fn channel_id(&self) -> DynamicChannelId { + self.channel_id + } + + pub fn creation_status(&self) -> CreationStatus { + self.creation_status + } + fn name() -> &'static str { "DYNVC_CREATE_RSP" } @@ -564,7 +606,7 @@ impl From for u32 { #[derive(Debug, PartialEq)] pub struct ClosePdu { header: Header, - pub channel_id: DynamicChannelId, + channel_id: DynamicChannelId, } impl ClosePdu { @@ -583,6 +625,10 @@ impl ClosePdu { } } + pub fn channel_id(&self) -> DynamicChannelId { + self.channel_id + } + fn decode(header: Header, src: &mut ReadCursor<'_>) -> DecodeResult { ensure_size!(in: src, size: Self::headerless_size(&header)); let channel_id = header.cb_id.decode_val(src)?; @@ -666,7 +712,7 @@ impl CapsVersion { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: Self::size()); - dst.write_u16(*self as u16); + dst.write_u16(u16::from(*self)); Ok(()) } @@ -689,6 +735,10 @@ impl TryFrom for CapsVersion { } impl From for u16 { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] fn from(version: CapsVersion) -> Self { version as u16 } @@ -719,6 +769,14 @@ impl CapabilitiesRequestPdu { const PRIORITY_CHARGE_COUNT: usize = 4; // 4 priority charges const PRIORITY_CHARGES_SIZE: usize = Self::PRIORITY_CHARGE_COUNT * Self::PRIORITY_CHARGE_SIZE; + pub fn version(&self) -> CapsVersion { + match self { + Self::V1 { .. } => CapsVersion::V1, + Self::V2 { .. } => CapsVersion::V2, + Self::V3 { .. } => CapsVersion::V3, + } + } + pub fn new(version: CapsVersion, charges: Option<[u16; Self::PRIORITY_CHARGE_COUNT]>) -> Self { let header = Header::new(0, 0, Cmd::Capability); let charges = charges.unwrap_or([0; Self::PRIORITY_CHARGE_COUNT]); @@ -798,8 +856,8 @@ impl CapabilitiesRequestPdu { #[derive(Debug, PartialEq)] pub struct CreateRequestPdu { header: Header, - pub channel_id: DynamicChannelId, - pub channel_name: String, + channel_id: DynamicChannelId, + channel_name: String, } impl CreateRequestPdu { @@ -811,6 +869,18 @@ impl CreateRequestPdu { } } + pub fn channel_id(&self) -> DynamicChannelId { + self.channel_id + } + + pub fn channel_name(&self) -> &str { + &self.channel_name + } + + pub fn into_channel_name(self) -> String { + self.channel_name + } + fn decode(header: Header, src: &mut ReadCursor<'_>) -> DecodeResult { ensure_size!(in: src, size: Self::headerless_fixed_part_size(&header)); let channel_id = header.cb_id.decode_val(src)?; diff --git a/crates/ironrdp-dvc/src/server.rs b/crates/ironrdp-dvc/src/server.rs index 09c1700de8..b3d54bba94 100644 --- a/crates/ironrdp-dvc/src/server.rs +++ b/crates/ironrdp-dvc/src/server.rs @@ -1,25 +1,27 @@ use alloc::boxed::Box; +use alloc::collections::BTreeMap; use alloc::vec::Vec; +use core::any::TypeId; use core::fmt; -use ironrdp_core::{cast_length, impl_as_any, invalid_field_err, Decode as _, DecodeResult, ReadCursor}; +use ironrdp_core::{Decode as _, DecodeResult, ReadCursor, impl_as_any, invalid_field_err}; use ironrdp_pdu::{self as pdu, decode_err, encode_err, pdu_other_err}; use ironrdp_svc::{ChannelFlags, CompressionCondition, SvcMessage, SvcProcessor, SvcServerProcessor}; -use pdu::gcc::ChannelName; use pdu::PduResult; -use slab::Slab; +use pdu::gcc::ChannelName; use tracing::debug; use crate::pdu::{ - CapabilitiesRequestPdu, CapsVersion, CreateRequestPdu, CreationStatus, DrdynvcClientPdu, DrdynvcServerPdu, + CapabilitiesRequestPdu, CapsVersion, ClosePdu, CreateRequestPdu, CreationStatus, DrdynvcClientPdu, DrdynvcServerPdu, }; -use crate::{encode_dvc_messages, CompleteData, DvcProcessor}; +use crate::{CompleteData, DvcProcessor, DynamicChannelMut, DynamicChannelRef, encode_dvc_messages}; pub trait DvcServerProcessor: DvcProcessor {} #[derive(Debug, Copy, Clone, PartialEq, Eq)] enum ChannelState { - Closed, + Pending, + /// `Create Request` has been sent; awaiting `Create Response` from the client. Creation, Opened, CreationFailed(u32), @@ -29,32 +31,105 @@ struct DynamicChannel { state: ChannelState, processor: Box, complete_data: CompleteData, + channel_id: u32, +} + +impl Drop for DynamicChannel { + fn drop(&mut self) { + if self.state == ChannelState::Opened { + self.processor.close(self.channel_id); + } + } +} + +struct DynamicChannelAllocator { + dynamic_channels: BTreeMap, + next_channel_id: u32, +} + +impl<'a> IntoIterator for &'a DynamicChannelAllocator { + type Item = (&'a u32, &'a DynamicChannel); + + type IntoIter = alloc::collections::btree_map::Iter<'a, u32, DynamicChannel>; + + fn into_iter(self) -> Self::IntoIter { + self.dynamic_channels.iter() + } +} + +impl<'a> IntoIterator for &'a mut DynamicChannelAllocator { + type Item = (&'a u32, &'a mut DynamicChannel); + type IntoIter = alloc::collections::btree_map::IterMut<'a, u32, DynamicChannel>; + fn into_iter(self) -> Self::IntoIter { + self.dynamic_channels.iter_mut() + } +} + +impl DynamicChannelAllocator { + fn new() -> Self { + Self { + dynamic_channels: BTreeMap::new(), + next_channel_id: 0, + } + } + + fn insert_channel(&mut self, processor: T, state: ChannelState) -> u32 + where + T: DvcServerProcessor + 'static, + { + let channel_id = self.next_channel_id; + self.dynamic_channels + .insert(channel_id, DynamicChannel::new(processor, channel_id, state)); + self.next_channel_id = self + .next_channel_id + .checked_add(1) + .expect("dynamic channels reaches `u32::MAX`"); + channel_id + } + + fn get(&self, channel_id: u32) -> Option<&DynamicChannel> { + self.dynamic_channels.get(&channel_id) + } + + fn get_mut(&mut self, channel_id: u32) -> Option<&mut DynamicChannel> { + self.dynamic_channels.get_mut(&channel_id) + } + + fn remove(&mut self, channel_id: u32) -> Option { + self.dynamic_channels.remove(&channel_id) + } } impl DynamicChannel { - fn new(processor: T) -> Self + fn new(processor: T, channel_id: u32, state: ChannelState) -> Self where T: DvcServerProcessor + 'static, { Self { - state: ChannelState::Closed, + state, processor: Box::new(processor), complete_data: CompleteData::new(), + channel_id, } } + + fn processor_type_id(&self) -> TypeId { + self.processor.as_any().type_id() + } } /// DRDYNVC Static Virtual Channel (the Remote Desktop Protocol: Dynamic Virtual Channel Extension) /// /// It adds support for dynamic virtual channels (DVC). pub struct DrdynvcServer { - dynamic_channels: Slab, + dynamic_channels: DynamicChannelAllocator, + type_id_to_channel_id: BTreeMap, } impl fmt::Debug for DrdynvcServer { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "DrdynvcServer([")?; - for (i, (id, channel)) in self.dynamic_channels.iter().enumerate() { + for (i, (id, channel)) in self.dynamic_channels.into_iter().enumerate() { if i > 0 { write!(f, ", ")?; } @@ -70,27 +145,107 @@ impl DrdynvcServer { pub fn new() -> Self { Self { - dynamic_channels: Slab::new(), + dynamic_channels: DynamicChannelAllocator::new(), + type_id_to_channel_id: BTreeMap::new(), } } - // FIXME(#61): it’s likely we want to enable adding dynamic channels at any point during the session (message passing? other approach?) + pub fn get_channel_id_by_type(&self) -> Option + where + T: DvcServerProcessor + 'static, + { + self.type_id_to_channel_id.get(&TypeId::of::()).copied() + } + + /// Returns `true` if the DVC channel with the given ID has completed + /// its creation handshake and is in the `Opened` state. + pub fn is_channel_opened(&self, channel_id: u32) -> bool { + self.dynamic_channels + .get(channel_id) + .is_some_and(|c| c.state == ChannelState::Opened) + } + /// Registers a dynamic channel with the server. + /// + /// # Panics + /// + /// Panics if the number of registered dynamic channels reaches `u32::MAX`. #[must_use] pub fn with_dynamic_channel(mut self, channel: T) -> Self where T: DvcServerProcessor + 'static, { - self.dynamic_channels.insert(DynamicChannel::new(channel)); + let channel_id = self.dynamic_channels.insert_channel(channel, ChannelState::Pending); + self.type_id_to_channel_id.insert(TypeId::of::(), channel_id); self } fn channel_by_id(&mut self, id: u32) -> DecodeResult<&mut DynamicChannel> { - let id = cast_length!("DRDYNVC", "", id)?; self.dynamic_channels .get_mut(id) .ok_or_else(|| invalid_field_err!("DRDYNVC", "", "invalid channel id")) } + + pub fn dvc_by_id(&self, id: u32) -> Option> { + let channel = self.dynamic_channels.get(id)?; + if channel.state != ChannelState::Opened { + return None; + } + channel + .processor + .as_any() + .downcast_ref() + .map(|p| DynamicChannelRef::new(id, p)) + } + + pub fn dvc_by_id_mut(&mut self, id: u32) -> Option> { + let channel = self.dynamic_channels.get_mut(id)?; + if channel.state != ChannelState::Opened { + return None; + } + channel + .processor + .as_any_mut() + .downcast_mut() + .map(|p| DynamicChannelMut::new(id, p)) + } + + /// Creates a new DVC, returns CreateRequest PDU to send to client. + /// + /// # Panics + /// + /// Panics if the number of registered dynamic channels reaches `u32::MAX`. + pub fn create_channel(&mut self, channel: T) -> PduResult + where + T: DvcServerProcessor + 'static, + { + let channel_name = channel.channel_name().into(); + + let channel_id = self.dynamic_channels.insert_channel(channel, ChannelState::Creation); + let req = DrdynvcServerPdu::Create(CreateRequestPdu::new(channel_id, channel_name)); + as_svc_msg_with_flag(req) + } + + fn remove_by_channel_id(&mut self, id: u32) -> Option { + self.dynamic_channels.remove(id).inspect(|dvc| { + let type_id = dvc.processor_type_id(); + + // Only matters for pre-registered channels + if let alloc::collections::btree_map::Entry::Occupied(entry) = self.type_id_to_channel_id.entry(type_id) + && entry.get() == &id + { + entry.remove(); + } + }) + } + + pub fn close_channel(&mut self, channel_id: u32) -> Option { + self.remove_by_channel_id(channel_id)?; + Some( + SvcMessage::from(DrdynvcServerPdu::Close(ClosePdu::new(channel_id))) + .with_flags(ChannelFlags::SHOW_PROTOCOL), + ) + } } impl_as_any!(DrdynvcServer); @@ -111,7 +266,7 @@ impl SvcProcessor for DrdynvcServer { } fn start(&mut self) -> PduResult> { - let cap = CapabilitiesRequestPdu::new(CapsVersion::V1, None); + let cap = CapabilitiesRequestPdu::new(CapsVersion::V2, None); let req = DrdynvcServerPdu::Capabilities(cap); let msg = as_svc_msg_with_flag(req)?; Ok(alloc::vec![msg]) @@ -124,41 +279,34 @@ impl SvcProcessor for DrdynvcServer { match pdu { DrdynvcClientPdu::Capabilities(caps_resp) => { debug!("Got DVC Capabilities Response PDU: {caps_resp:?}"); - for (id, c) in self.dynamic_channels.iter_mut() { - if c.state != ChannelState::Closed { + for (id, c) in &mut self.dynamic_channels { + if c.state != ChannelState::Pending { continue; } - let req = DrdynvcServerPdu::Create(CreateRequestPdu::new( - id.try_into() - .map_err(|e| pdu_other_err!("invalid channel id", source: e))?, - c.processor.channel_name().into(), - )); + let req = DrdynvcServerPdu::Create(CreateRequestPdu::new(*id, c.processor.channel_name().into())); c.state = ChannelState::Creation; resp.push(as_svc_msg_with_flag(req)?); } } DrdynvcClientPdu::Create(create_resp) => { debug!("Got DVC Create Response PDU: {create_resp:?}"); - let id = create_resp.channel_id; + let id = create_resp.channel_id(); let c = self.channel_by_id(id).map_err(|e| decode_err!(e))?; if c.state != ChannelState::Creation { return Err(pdu_other_err!("invalid channel state")); } - if create_resp.creation_status != CreationStatus::OK { - c.state = ChannelState::CreationFailed(create_resp.creation_status.into()); + if create_resp.creation_status() != CreationStatus::OK { + c.state = ChannelState::CreationFailed(create_resp.creation_status().into()); return Ok(resp); } c.state = ChannelState::Opened; - let msg = c.processor.start(create_resp.channel_id)?; + let msg = c.processor.start(create_resp.channel_id())?; resp.extend(encode_dvc_messages(id, msg, ChannelFlags::SHOW_PROTOCOL).map_err(|e| encode_err!(e))?); } - DrdynvcClientPdu::Close(close_resp) => { - debug!("Got DVC Close Response PDU: {close_resp:?}"); - let c = self.channel_by_id(close_resp.channel_id).map_err(|e| decode_err!(e))?; - if c.state != ChannelState::Opened { - return Err(pdu_other_err!("invalid channel state")); - } - c.state = ChannelState::Closed; + DrdynvcClientPdu::Close(close) => { + debug!("Got DVC Close PDU: {close:?}"); + let channel_id = close.channel_id(); + self.remove_by_channel_id(channel_id); } DrdynvcClientPdu::Data(data) => { let channel_id = data.channel_id(); diff --git a/crates/ironrdp-echo/CHANGELOG.md b/crates/ironrdp-echo/CHANGELOG.md new file mode 100644 index 0000000000..1b4f37fe26 --- /dev/null +++ b/crates/ironrdp-echo/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-echo-v0.3.0...ironrdp-echo-v0.4.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency to 0.8 + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + +## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-echo-v0.2.0...ironrdp-echo-v0.3.0)] - 2026-06-05 + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency + + + +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-echo-v0.1.0...ironrdp-echo-v0.2.0)] - 2026-05-27 + +### Build + +- [**breaking**] Update `ironrdp-core`, `ironrdp-dvc`, and `ironrdp-pdu` public dependencies + diff --git a/crates/ironrdp-echo/Cargo.toml b/crates/ironrdp-echo/Cargo.toml new file mode 100644 index 0000000000..7ee574dcf8 --- /dev/null +++ b/crates/ironrdp-echo/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "ironrdp-echo" +version = "0.4.0" +readme = "README.md" +description = "Virtual channel echo extension implementation" +edition.workspace = true +rust-version = "1.89" +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +[lib] +doctest = false +test = false + +[dependencies] +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +tracing = { version = "0.1", features = ["log"] } + +[lints] +workspace = true diff --git a/crates/ironrdp-echo/README.md b/crates/ironrdp-echo/README.md new file mode 100644 index 0000000000..38565bf212 --- /dev/null +++ b/crates/ironrdp-echo/README.md @@ -0,0 +1,11 @@ +# IronRDP Virtual Channel Echo Extension [MS-RDPEECO][1] implementation. + +Virtual Channel Echo Extension [MS-RDPEECO][1] implementation over Dynamic Virtual Channels [MS-RDPEDYC][2]. + +This library includes: +- ECHO request/response PDUs parsing and serialization +- ECHO dynamic virtual channel client processor +- ECHO dynamic virtual channel server processor + +[1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeeco/5f4f5b76-14f2-4807-bf8c-10fcb7f7f41c +[2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpedyc/3bd53020-9b64-4c9a-97fc-90a79e7e1e06 \ No newline at end of file diff --git a/crates/ironrdp-echo/src/client.rs b/crates/ironrdp-echo/src/client.rs new file mode 100644 index 0000000000..a13bdac389 --- /dev/null +++ b/crates/ironrdp-echo/src/client.rs @@ -0,0 +1,40 @@ +use ironrdp_core::{decode, impl_as_any}; +use ironrdp_dvc::{DvcClientProcessor, DvcMessage, DvcProcessor}; +use ironrdp_pdu::{PduResult, decode_err}; +use tracing::debug; + +use crate::CHANNEL_NAME; +use crate::pdu::{EchoRequestPdu, EchoResponsePdu}; + +/// A client for the ECHO virtual channel. +#[derive(Debug, Default)] +pub struct EchoClient; + +impl EchoClient { + /// Creates a new [`EchoClient`]. + pub fn new() -> Self { + Self + } +} + +impl_as_any!(EchoClient); + +impl DvcProcessor for EchoClient { + fn channel_name(&self) -> &str { + CHANNEL_NAME + } + + fn start(&mut self, _channel_id: u32) -> PduResult> { + Ok(Vec::new()) + } + + fn process(&mut self, _channel_id: u32, payload: &[u8]) -> PduResult> { + let request: EchoRequestPdu = decode(payload).map_err(|e| decode_err!(e))?; + debug!(size = request.payload().len(), "Received ECHO request"); + + let response = EchoResponsePdu::new(request.into_payload()); + Ok(vec![Box::new(response)]) + } +} + +impl DvcClientProcessor for EchoClient {} diff --git a/crates/ironrdp-echo/src/lib.rs b/crates/ironrdp-echo/src/lib.rs new file mode 100644 index 0000000000..63f0529b46 --- /dev/null +++ b/crates/ironrdp-echo/src/lib.rs @@ -0,0 +1,9 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] +#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] + +/// ECHO dynamic virtual channel name per MS-RDPEECO. +pub const CHANNEL_NAME: &str = "ECHO"; + +pub mod client; +pub mod pdu; +pub mod server; diff --git a/crates/ironrdp-echo/src/pdu.rs b/crates/ironrdp-echo/src/pdu.rs new file mode 100644 index 0000000000..e3ad1294a6 --- /dev/null +++ b/crates/ironrdp-echo/src/pdu.rs @@ -0,0 +1,104 @@ +//! ECHO virtual channel extension PDUs [MS-RDPEECO][1] implementation. +//! +//! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeeco/5f4f5b76-14f2-4807-bf8c-10fcb7f7f41c + +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_size}; +use ironrdp_dvc::DvcEncode; + +/// 2.2.1 ECHO_REQUEST_PDU +/// +/// [2.2.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeeco/bf2c9ef3-2f8b-40c2-b27d-ce9df72976f2 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EchoRequestPdu { + payload: Vec, +} + +impl EchoRequestPdu { + const NAME: &'static str = "ECHO_REQUEST_PDU"; + + pub fn new(payload: Vec) -> Self { + Self { payload } + } + + pub fn payload(&self) -> &[u8] { + &self.payload + } + + pub fn into_payload(self) -> Vec { + self.payload + } +} + +impl Encode for EchoRequestPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.payload.len()); + dst.write_slice(&self.payload); + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + self.payload.len() + } +} + +impl<'de> Decode<'de> for EchoRequestPdu { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + let payload = src.read_remaining().to_vec(); + Ok(Self { payload }) + } +} + +impl DvcEncode for EchoRequestPdu {} + +/// 2.2.2 ECHO_RESPONSE_PDU +/// +/// [2.2.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeeco/f95db8eb-fffd-4b76-9f8f-60322ea2dd2d +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EchoResponsePdu { + payload: Vec, +} + +impl EchoResponsePdu { + const NAME: &'static str = "ECHO_RESPONSE_PDU"; + + pub fn new(payload: Vec) -> Self { + Self { payload } + } + + pub fn payload(&self) -> &[u8] { + &self.payload + } + + pub fn into_payload(self) -> Vec { + self.payload + } +} + +impl Encode for EchoResponsePdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.payload.len()); + dst.write_slice(&self.payload); + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + self.payload.len() + } +} + +impl<'de> Decode<'de> for EchoResponsePdu { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + let payload = src.read_remaining().to_vec(); + Ok(Self { payload }) + } +} + +impl DvcEncode for EchoResponsePdu {} diff --git a/crates/ironrdp-echo/src/server.rs b/crates/ironrdp-echo/src/server.rs new file mode 100644 index 0000000000..8da803f9b2 --- /dev/null +++ b/crates/ironrdp-echo/src/server.rs @@ -0,0 +1,63 @@ +use ironrdp_core::{decode, impl_as_any}; +use ironrdp_dvc::{DvcMessage, DvcProcessor, DvcServerProcessor}; +use ironrdp_pdu::{PduResult, decode_err, pdu_other_err}; +use tracing::debug; + +use crate::CHANNEL_NAME; +use crate::pdu::{EchoRequestPdu, EchoResponsePdu}; + +/// A server for the ECHO virtual channel. +#[derive(Debug, Default)] +pub struct EchoServer { + initial_request: Option>, +} + +impl EchoServer { + /// Creates a new [`EchoServer`]. + pub fn new() -> Self { + Self::default() + } + + /// Configures an initial request that will be sent once the ECHO channel is opened. + #[must_use] + pub fn with_initial_request(mut self, payload: Vec) -> Self { + self.initial_request = Some(payload); + self + } + + /// Builds a request message. + pub fn request_message(payload: Vec) -> PduResult { + if payload.is_empty() { + return Err(pdu_other_err!( + "EchoServer::request_message", + "echoRequest payload must be at least one byte" + )); + } + + Ok(Box::new(EchoRequestPdu::new(payload))) + } +} + +impl_as_any!(EchoServer); + +impl DvcProcessor for EchoServer { + fn channel_name(&self) -> &str { + CHANNEL_NAME + } + + fn start(&mut self, _channel_id: u32) -> PduResult> { + if let Some(payload) = self.initial_request.take() { + return Ok(vec![Self::request_message(payload)?]); + } + + Ok(Vec::new()) + } + + fn process(&mut self, _channel_id: u32, payload: &[u8]) -> PduResult> { + let response: EchoResponsePdu = decode(payload).map_err(|e| decode_err!(e))?; + debug!(size = response.payload().len(), "Received ECHO response"); + Ok(Vec::new()) + } +} + +impl DvcServerProcessor for EchoServer {} diff --git a/crates/ironrdp-egfx/CHANGELOG.md b/crates/ironrdp-egfx/CHANGELOG.md new file mode 100644 index 0000000000..fb6d836b38 --- /dev/null +++ b/crates/ironrdp-egfx/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-egfx-v0.2.0...ironrdp-egfx-v0.3.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency to 0.8 + +- [**breaking**] Update `ironrdp-graphics` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-egfx-v0.1.0...ironrdp-egfx-v0.2.0)] - 2026-06-05 + +### Features + +- [**breaking**] Surface total_frames_decoded on the frame-ack callback ([#1345](https://github.com/Devolutions/IronRDP/issues/1345)) ([cf51bdd1d5](https://github.com/Devolutions/IronRDP/commit/cf51bdd1d5ba062132039f5ed6d7871e00af6412)) + +- Cascade Arbitrary derives across ironrdp-egfx public PDU types ([#1334](https://github.com/Devolutions/IronRDP/issues/1334)) ([479a13aa49](https://github.com/Devolutions/IronRDP/commit/479a13aa49478e333ccdc4c8fdf03aa4f36d2cac)) + +### Bug Fixes + +- [**breaking**] Make DecodedFrame fields private with getters to enforce size invariant ([#1331](https://github.com/Devolutions/IronRDP/issues/1331)) ([1534d1b40e](https://github.com/Devolutions/IronRDP/commit/1534d1b40e902a404b020fbae8e970a65ca74458)) + + + +## [0.1.0] - 2026-06-01 + +### Added + +- Initial release +- MS-RDPEGFX PDU types (all 23 PDUs) +- Client-side DVC processor +- Server-side implementation with: + - Multi-surface management (Offscreen Surfaces ADM element) + - Frame tracking with flow control (Unacknowledged Frames ADM element) + - V8/V8.1/V10/V10.1-V10.7 capability negotiation + - AVC420 and AVC444 frame sending + - QoE metrics processing + - Cache import handling + - Resize coordination diff --git a/crates/ironrdp-egfx/Cargo.toml b/crates/ironrdp-egfx/Cargo.toml new file mode 100644 index 0000000000..c4092470f3 --- /dev/null +++ b/crates/ironrdp-egfx/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "ironrdp-egfx" +version = "0.3.0" +readme = "README.md" +description = "Graphics pipeline dynamic channel extension implementation" +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +[lib] +doctest = false +# test = false # FIXME: turn off and keep tests in testsuite crates + +[dependencies] +arbitrary = { version = "1", features = ["derive"], optional = true } +bit_field = "0.10" +bitflags = "2.11" +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.9" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +openh264 = { version = "0.9", optional = true, default-features = false } +tracing = { version = "0.1", features = ["log"] } + +[features] +arbitrary = ["dep:arbitrary", "bitflags/arbitrary", "ironrdp-pdu/arbitrary"] +openh264 = ["dep:openh264"] +openh264-bundled = ["openh264", "openh264/source"] +openh264-libloading = ["openh264", "openh264/libloading"] + +[lints] +workspace = true diff --git a/crates/ironrdp-egfx/LICENSE-APACHE b/crates/ironrdp-egfx/LICENSE-APACHE new file mode 120000 index 0000000000..1cd601d0a3 --- /dev/null +++ b/crates/ironrdp-egfx/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/ironrdp-egfx/LICENSE-MIT b/crates/ironrdp-egfx/LICENSE-MIT new file mode 120000 index 0000000000..b2cfbdc7b0 --- /dev/null +++ b/crates/ironrdp-egfx/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/ironrdp-egfx/README.md b/crates/ironrdp-egfx/README.md new file mode 100644 index 0000000000..2145e61b38 --- /dev/null +++ b/crates/ironrdp-egfx/README.md @@ -0,0 +1,46 @@ +# ironrdp-egfx + +Graphics Pipeline Extension ([MS-RDPEGFX]) implementation for IronRDP. + +Provides PDU types and client/server processors for the Display Pipeline Virtual +Channel Extension, including H.264/AVC420 and AVC444 video streaming support. + +## OpenH264 Integration + +This crate contains optional integration support for OpenH264 through the +[openh264](https://crates.io/crates/openh264) crate. When that integration +is enabled by downstream consumers, the applicable BSD license notices for +OpenH264 / openh264 must be preserved. See +[`THIRD_PARTY_NOTICES`](THIRD_PARTY_NOTICES) for the full license texts. + +The OpenH264 integration is optional and disabled by default. Redistribution +scenarios using Cisco's prebuilt binary may also require compliance with +Cisco's separate binary-license conditions (documented in THIRD_PARTY_NOTICES). + +### Feature Flags + +- **`openh264-bundled`** -- Compiles OpenH264 from C source at build time + (requires a C compiler and NASM). Source-compiled binaries do not carry + H.264 patent coverage from Cisco's MPEG LA license. This is not the + recommended path for redistribution. + +- **`openh264-libloading`** -- Loads a prebuilt OpenH264 shared library at + runtime via `dlopen`/`LoadLibrary`. When using Cisco's official prebuilt + binaries (downloaded separately by the end user), those binaries carry + patent coverage under Cisco's OpenH264 license. This is the recommended + path for applications distributed via package managers. + +### Choosing between bundled and libloading + +Applications that distribute binaries to end users should use +`openh264-libloading` and arrange for the end user to download the Cisco +binary separately. Consumers are responsible for Cisco notice and EULA +placement as described in `THIRD_PARTY_NOTICES`. See the +[openh264 crate documentation](https://docs.rs/openh264) for details on +library discovery and hash verification. + +Applications in controlled environments (CI, development, testing) can +use `openh264-bundled` for a simpler build with no external dependencies +beyond a C toolchain. + +[MS-RDPEGFX]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/da5c75f9-cd99-450c-98c4-014a496942b0 diff --git a/crates/ironrdp-egfx/THIRD_PARTY_NOTICES b/crates/ironrdp-egfx/THIRD_PARTY_NOTICES new file mode 100644 index 0000000000..90330b923f --- /dev/null +++ b/crates/ironrdp-egfx/THIRD_PARTY_NOTICES @@ -0,0 +1,111 @@ +THIRD-PARTY NOTICES + +This file documents third-party software used by ironrdp-egfx when +optional integration features are enabled. + +================================================================================ + +OpenH264 Integration Notice + +ironrdp-egfx provides optional integration support for H.264 decoding +through the openh264 crate (https://crates.io/crates/openh264). This +integration is disabled by default and must be explicitly enabled by +downstream consumers via the `openh264-bundled` or `openh264-libloading` +feature flags. + +When enabled, the applicable BSD license notices below must be preserved. + +-------------------------------------------------------------------------------- + +1. Cisco OpenH264 + + Project: https://github.com/cisco/openh264 + License: BSD-2-Clause + + Copyright (c) 2013, Cisco Systems + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +-------------------------------------------------------------------------------- + +2. openh264 (Rust crate) + + Project: https://github.com/ralfbiedert/openh264-rs + Author: Ralf Biedert + License: BSD-2-Clause + + Copyright (c) Ralf Biedert + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +================================================================================ + +AVC/H.264 Patent Portfolio License Conditions + +When using Cisco's prebuilt OpenH264 binary (via the `openh264-libloading` +feature), the binary is licensed under Cisco's license from MPEG LA only +if the following conditions are met: + +1. The Cisco-provided binary is separately downloaded to an end user's + device, and not integrated into or combined with third party software + prior to being downloaded to the end user's device; + +2. The end user must have the ability to control (e.g., to enable, + disable, or re-enable) the use of the Cisco-provided binary; + +3. Third party software, in the location where end users can control + the use of the Cisco-provided binary, must display the following + text: + + "OpenH264 Video Codec provided by Cisco Systems, Inc." + +4. Any third-party software that makes use of the Cisco-provided binary + must reproduce all of the above text, as well as this last condition, + in the EULA and/or in another location where licensing information is + to be presented to the end user. diff --git a/crates/ironrdp-egfx/src/client.rs b/crates/ironrdp-egfx/src/client.rs new file mode 100644 index 0000000000..4610f7ca0f --- /dev/null +++ b/crates/ironrdp-egfx/src/client.rs @@ -0,0 +1,1071 @@ +//! Client-side EGFX implementation +//! +//! This module provides client-side support for the Graphics Pipeline Extension +//! ([MS-RDPEGFX]), including H.264 AVC420 decode and surface management. +//! +//! # Protocol Compliance +//! +//! This implementation follows MS-RDPEGFX client requirements: +//! +//! - **Capability Negotiation**: Advertises V8 through V10.7 ([2.2.3]) +//! - **Surface Management**: Tracks server-created surfaces ([3.3.1.6]) +//! - **Frame Acknowledgment**: Sends `FrameAcknowledge` after `EndFrame` ([3.3.5.12]) +//! - **Codec Dispatch**: Routes `WireToSurface1` by `codec_id` ([3.3.5.2]) +//! +//! # Architecture +//! +//! ```text +//! Server Client +//! | | +//! |--- CapabilitiesConfirm -------------->| +//! |--- ResetGraphics -------------------->| +//! |--- CreateSurface -------------------->| +//! |--- MapSurfaceToOutput --------------->| +//! | | +//! | (For each frame:) | +//! |--- StartFrame ----------------------->| +//! |--- WireToSurface1 (H.264) ----------->| -> H264Decoder::decode() +//! |--- EndFrame ------------------------->| -> FrameAcknowledge +//! | | +//! |<---------- FrameAcknowledge ----------| +//! ``` +//! +//! # Usage +//! +//! ```ignore +//! use ironrdp_egfx::client::{GraphicsPipelineClient, GraphicsPipelineHandler, BitmapUpdate}; +//! use ironrdp_egfx::decode::H264Decoder; +//! +//! struct MyHandler; +//! +//! impl GraphicsPipelineHandler for MyHandler { +//! fn on_bitmap_updated(&mut self, update: &BitmapUpdate) { +//! // Render decoded bitmap to screen +//! } +//! } +//! +//! let client = GraphicsPipelineClient::new(Box::new(MyHandler), None); +//! ``` +//! +//! [MS-RDPEGFX]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/da5c75f9-cd99-450c-98c4-014a496942b0 +//! [2.2.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/b5e09f90-6dde-47ca-8ec1-7dcdd5dc70b0 +//! [3.3.1.6]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/83cb08ff-c97f-4d08-b834-7aa69cdea6c5 +//! [3.3.5.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/90aba3e3-d4a8-4af1-b1bb-a94e2313bbf0 +//! [3.3.5.12]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/e3c80bff-3e4e-4e65-b7c2-c2cd6b1fb4f5 + +use std::collections::BTreeMap; + +use ironrdp_core::{Decode as _, ReadCursor, impl_as_any}; +use ironrdp_dvc::{DvcClientProcessor, DvcMessage, DvcProcessor}; +use ironrdp_graphics::zgfx; +use ironrdp_pdu::geometry::{ExclusiveRectangle, Rectangle as _}; +use ironrdp_pdu::{PduResult, decode_cursor, decode_err, pdu_other_err}; +use tracing::{debug, trace, warn}; + +use crate::CHANNEL_NAME; +use crate::decode::H264Decoder; +use crate::pdu::{ + Avc420BitmapStream, CacheImportReplyPdu, CacheToSurfacePdu, CapabilitiesAdvertisePdu, CapabilitiesV8Flags, + CapabilitiesV81Flags, CapabilitiesV107Flags, CapabilitySet, Codec1Type, DeleteEncodingContextPdu, + EvictCacheEntryPdu, FrameAcknowledgePdu, GfxPdu, MapSurfaceToScaledOutputPdu, MapSurfaceToScaledWindowPdu, + MapSurfaceToWindowPdu, PixelFormat, QueueDepth, RawCapabilitySet, SolidFillPdu, SurfaceToCachePdu, + SurfaceToSurfacePdu, WireToSurface2Pdu, +}; + +/// Max capacity to keep for decompressed buffer when cleared. +const MAX_DECOMPRESSED_BUFFER_CAPACITY: usize = 16384; // 16 KiB + +// ============================================================================ +// Surface Management +// ============================================================================ + +/// Client-side surface state +/// +/// Per [MS-RDPEGFX 3.3.1.6], the client maintains an "Offscreen Surfaces +/// ADM element" tracking surfaces created by the server. +/// +/// [MS-RDPEGFX 3.3.1.6]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/83cb08ff-c97f-4d08-b834-7aa69cdea6c5 +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct Surface { + /// Surface identifier (assigned by server) + pub id: u16, + /// Surface width in pixels + pub width: u16, + /// Surface height in pixels + pub height: u16, + /// Pixel format + pub pixel_format: PixelFormat, + /// Whether this surface is mapped to an output + pub is_mapped: bool, + /// Output X origin (if mapped) + pub output_origin_x: u32, + /// Output Y origin (if mapped) + pub output_origin_y: u32, +} + +// ============================================================================ +// Codec Capabilities +// ============================================================================ + +/// Codec capabilities determined from negotiated capability set +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct CodecCapabilities { + /// AVC420 (H.264 4:2:0) is available + pub avc420: bool, + /// AVC444 (H.264 4:4:4) is available + pub avc444: bool, + /// Small cache mode + pub small_cache: bool, + /// Thin client mode + pub thin_client: bool, +} + +impl CodecCapabilities { + fn from_capability_set(cap: &CapabilitySet) -> Self { + // Mirrors the server-side extraction logic + match cap { + CapabilitySet::V8 { flags } => Self { + avc420: false, + avc444: false, + small_cache: flags.contains(CapabilitiesV8Flags::SMALL_CACHE), + thin_client: flags.contains(CapabilitiesV8Flags::THIN_CLIENT), + }, + CapabilitySet::V8_1 { flags } => Self { + avc420: flags.contains(CapabilitiesV81Flags::AVC420_ENABLED), + avc444: false, + small_cache: flags.contains(CapabilitiesV81Flags::SMALL_CACHE), + thin_client: flags.contains(CapabilitiesV81Flags::THIN_CLIENT), + }, + CapabilitySet::V10 { flags } | CapabilitySet::V10_2 { flags } => Self { + avc420: !flags.contains(crate::pdu::CapabilitiesV10Flags::AVC_DISABLED), + avc444: !flags.contains(crate::pdu::CapabilitiesV10Flags::AVC_DISABLED), + small_cache: flags.contains(crate::pdu::CapabilitiesV10Flags::SMALL_CACHE), + thin_client: false, + }, + CapabilitySet::V10_1 => Self { + avc420: true, + avc444: true, + small_cache: false, + thin_client: false, + }, + CapabilitySet::V10_3 { flags } => Self { + avc420: !flags.contains(crate::pdu::CapabilitiesV103Flags::AVC_DISABLED), + avc444: !flags.contains(crate::pdu::CapabilitiesV103Flags::AVC_DISABLED), + small_cache: false, + thin_client: flags.contains(crate::pdu::CapabilitiesV103Flags::AVC_THIN_CLIENT), + }, + CapabilitySet::V10_4 { flags } + | CapabilitySet::V10_5 { flags } + | CapabilitySet::V10_6 { flags } + | CapabilitySet::V10_6Err { flags } => Self { + avc420: !flags.contains(crate::pdu::CapabilitiesV104Flags::AVC_DISABLED), + avc444: !flags.contains(crate::pdu::CapabilitiesV104Flags::AVC_DISABLED), + small_cache: flags.contains(crate::pdu::CapabilitiesV104Flags::SMALL_CACHE), + thin_client: flags.contains(crate::pdu::CapabilitiesV104Flags::AVC_THIN_CLIENT), + }, + CapabilitySet::V10_7 { flags } => Self { + avc420: !flags.contains(CapabilitiesV107Flags::AVC_DISABLED), + avc444: !flags.contains(CapabilitiesV107Flags::AVC_DISABLED), + small_cache: flags.contains(CapabilitiesV107Flags::SMALL_CACHE), + thin_client: flags.contains(CapabilitiesV107Flags::AVC_THIN_CLIENT), + }, + } + } +} + +// ============================================================================ +// Bitmap Update +// ============================================================================ + +/// Decoded bitmap data for a surface region +/// +/// Delivered to [`GraphicsPipelineHandler::on_bitmap_updated`] when +/// a `WireToSurface1` PDU is processed with decoded pixel data. +#[derive(Debug)] +#[non_exhaustive] +pub struct BitmapUpdate { + /// Surface this update applies to + pub surface_id: u16, + /// Destination rectangle within the surface (exclusive `right`/`bottom`) + pub destination_rectangle: ExclusiveRectangle, + /// Codec that produced this update + pub codec_id: Codec1Type, + /// RGBA pixel data (4 bytes per pixel), row-major + /// + /// Dimensions match `width * height * 4` bytes. + /// May be empty if decode was skipped (no decoder configured). + pub data: Vec, + /// Width of the decoded data in pixels + pub width: u16, + /// Height of the decoded data in pixels + pub height: u16, +} + +// ============================================================================ +// Handler Trait +// ============================================================================ + +/// Handler trait for client-side EGFX events +/// +/// Implement this trait to receive decoded bitmap data and surface +/// lifecycle notifications from the EGFX pipeline. +/// +/// All methods have default no-op implementations so you only need +/// to override the ones relevant to your use case. +pub trait GraphicsPipelineHandler: Send { + /// Returns the capability sets to advertise to the server + /// + /// The default advertises V10.7 (AVC420+AVC444), V8.1 (AVC420 only), + /// and V8 (no AVC) as fallback. + /// + /// Note: AVC-capable versions are automatically filtered out at + /// advertisement time if no H.264 decoder is configured on the + /// [`GraphicsPipelineClient`]. If all returned sets require AVC + /// and no decoder is available, a V8-only fallback is used. + fn capabilities(&self) -> Vec { + vec![ + CapabilitySet::V10_7 { + flags: CapabilitiesV107Flags::SMALL_CACHE, + }, + CapabilitySet::V8_1 { + flags: CapabilitiesV81Flags::AVC420_ENABLED | CapabilitiesV81Flags::SMALL_CACHE, + }, + CapabilitySet::V8 { + flags: CapabilitiesV8Flags::SMALL_CACHE, + }, + ] + } + + /// Called when the server confirms negotiated capabilities + fn on_capabilities_confirmed(&mut self, _caps: &CapabilitySet) {} + + /// Called when the server resets the graphics output buffer + fn on_reset_graphics(&mut self, _width: u32, _height: u32) {} + + /// Called when a surface is created by the server + fn on_surface_created(&mut self, _surface: &Surface) {} + + /// Called when a surface is deleted by the server + fn on_surface_deleted(&mut self, _surface_id: u16) {} + + /// Called when a surface is mapped to an output position + fn on_surface_mapped(&mut self, _surface_id: u16, _origin_x: u32, _origin_y: u32) {} + + /// Called when decoded bitmap data is available for a surface + /// + /// This is the primary output path. The `update` contains the + /// surface ID, destination rectangle, and RGBA pixel data. + fn on_bitmap_updated(&mut self, _update: &BitmapUpdate) {} + + /// Called when a logical frame is complete + /// + /// All bitmap updates between the corresponding `StartFrame` + /// and this notification belong to the same logical frame. + fn on_frame_complete(&mut self, _frame_id: u32) {} + + /// Called when the EGFX channel is closed + fn on_close(&mut self) {} + + // ======================================================================== + // Additional PDU handlers (server→client) + // ======================================================================== + + /// Called when the server fills a surface region with a solid color + /// + /// Per [MS-RDPEGFX 3.3.5.4]. + /// + /// [MS-RDPEGFX 3.3.5.4]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/d696ab07-fd47-42f6-a601-c8b6fae26577 + fn on_solid_fill(&mut self, _pdu: &SolidFillPdu) {} + + /// Called when the server copies pixels between surfaces + /// + /// Per [MS-RDPEGFX 3.3.5.5]. + /// + /// [MS-RDPEGFX 3.3.5.5]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/0b19d058-fff0-43e5-8671-8c4186d60529 + fn on_surface_to_surface(&mut self, _pdu: &SurfaceToSurfacePdu) {} + + /// Called when the server caches a surface region + /// + /// Per [MS-RDPEGFX 3.3.5.6]. + /// + /// [MS-RDPEGFX 3.3.5.6]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/01108b9f-a888-4e5c-b790-42d5c5985998 + fn on_surface_to_cache(&mut self, _pdu: &SurfaceToCachePdu) {} + + /// Called when the server renders cached content to a surface + /// + /// Per [MS-RDPEGFX 3.3.5.7]. + /// + /// [MS-RDPEGFX 3.3.5.7]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/78c00bcd-f5cb-4c33-8d6c-f4cd50facfab + fn on_cache_to_surface(&mut self, _pdu: &CacheToSurfacePdu) {} + + /// Called when the server evicts a cache entry + /// + /// Per [MS-RDPEGFX 3.3.5.8]. + /// + /// [MS-RDPEGFX 3.3.5.8]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/9dd32c5c-fabc-497b-81be-776fa581a4f6 + fn on_evict_cache_entry(&mut self, _pdu: &EvictCacheEntryPdu) {} + + /// Called when the server maps a surface to a RAIL window + /// + /// Per [MS-RDPEGFX 2.2.2.20]. + /// + /// [MS-RDPEGFX 2.2.2.20]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/2ec1357c-ee65-4d9b-89f3-8fc49348c92a + fn on_map_surface_to_window(&mut self, _pdu: &MapSurfaceToWindowPdu) {} + + /// Called when the server maps a surface to a scaled output + /// + /// Per [MS-RDPEGFX 2.2.2.22]. + /// + /// [MS-RDPEGFX 2.2.2.22]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/3fcc3e63-e5a2-4b18-a572-26bbeb87b3aa + fn on_map_surface_to_scaled_output(&mut self, _pdu: &MapSurfaceToScaledOutputPdu) {} + + /// Called when the server maps a surface to a scaled RAIL window + /// + /// Per [MS-RDPEGFX 2.2.2.23]. + /// + /// [MS-RDPEGFX 2.2.2.23]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/22fc0ec7-38ce-4d9d-ad6d-93a0e9f3c38c + fn on_map_surface_to_scaled_window(&mut self, _pdu: &MapSurfaceToScaledWindowPdu) {} + + /// Called for progressive codec (RFX Progressive) bitmap data + /// + /// Per [MS-RDPEGFX 3.3.5.3]. + /// + /// [MS-RDPEGFX 3.3.5.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/e6dbb3a7-3de0-44a5-a1ee-9de90f75e7e0 + fn on_wire_to_surface2(&mut self, _pdu: &WireToSurface2Pdu) {} + + /// Called when the server deletes a progressive encoding context + /// + /// Per [MS-RDPEGFX 2.2.2.3]. + /// + /// [MS-RDPEGFX 2.2.2.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/bd0c64d4-07b3-47e5-9f7b-ba5c14a3a2e2 + fn on_delete_encoding_context(&mut self, _pdu: &DeleteEncodingContextPdu) {} + + /// Called when the server replies to a cache import offer + /// + /// Per [MS-RDPEGFX 2.2.2.17]. + /// + /// [MS-RDPEGFX 2.2.2.17]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/7c7a0a5d-50c1-44b9-a2e7-44b47ce1e49d + fn on_cache_import_reply(&mut self, _pdu: &CacheImportReplyPdu) {} + + /// Called for PDUs that have no specific handler + /// + /// This is a catch-all for any GfxPdu variant not matched above. + fn on_unhandled_pdu(&mut self, _pdu: &GfxPdu) {} +} + +// ============================================================================ +// Client State Machine +// ============================================================================ + +/// Client state machine states +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ClientState { + /// Waiting for server `CapabilitiesConfirm` + WaitingForConfirm, + /// Channel is active, processing frames + Active, + /// Channel has been closed + Closed, +} + +// ============================================================================ +// Graphics Pipeline Client +// ============================================================================ + +/// Client for the Graphics Pipeline Virtual Channel (EGFX) +/// +/// This client handles capability negotiation, surface tracking, +/// H.264 AVC420 decode, and frame acknowledgment per [MS-RDPEGFX]. +/// +/// [MS-RDPEGFX]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/da5c75f9-cd99-450c-98c4-014a496942b0 +pub struct GraphicsPipelineClient { + handler: Box, + h264_decoder: Option>, + + decompressor: zgfx::Decompressor, + decompressed_buffer: Vec, + + state: ClientState, + negotiated_caps: Option, + codec_caps: CodecCapabilities, + + surfaces: BTreeMap, + current_frame_id: Option, + frames_queued: u32, + total_frames_decoded: u32, +} + +impl GraphicsPipelineClient { + /// Create a new `GraphicsPipelineClient` + /// + /// If `h264_decoder` is `None`, AVC420 frames are logged and skipped. + pub fn new(handler: Box, h264_decoder: Option>) -> Self { + Self { + handler, + h264_decoder, + decompressor: zgfx::Decompressor::new(), + decompressed_buffer: Vec::new(), + state: ClientState::WaitingForConfirm, + negotiated_caps: None, + codec_caps: CodecCapabilities::default(), + surfaces: BTreeMap::new(), + current_frame_id: None, + frames_queued: 0, + total_frames_decoded: 0, + } + } + + // ======================================================================== + // State Queries + // ======================================================================== + + /// Check if the client has completed capability negotiation + #[must_use] + pub fn is_active(&self) -> bool { + self.state == ClientState::Active + } + + /// Get the negotiated capability set + #[must_use] + pub fn negotiated_capabilities(&self) -> Option<&CapabilitySet> { + self.negotiated_caps.as_ref() + } + + /// Get codec capabilities determined from negotiation + #[must_use] + pub fn codec_capabilities(&self) -> &CodecCapabilities { + &self.codec_caps + } + + /// Get a surface by ID + #[must_use] + pub fn get_surface(&self, surface_id: u16) -> Option<&Surface> { + self.surfaces.get(&surface_id) + } + + /// Get the total number of frames decoded + #[must_use] + pub fn total_frames_decoded(&self) -> u32 { + self.total_frames_decoded + } + + // ======================================================================== + // PDU Handlers + // ======================================================================== + + fn handle_pdu(&mut self, pdu: GfxPdu) -> PduResult> { + match pdu { + GfxPdu::CapabilitiesConfirm(confirm) => { + self.handle_capabilities_confirm(confirm.0); + Ok(vec![]) + } + GfxPdu::ResetGraphics(reset) => { + self.handle_reset_graphics(reset.width, reset.height); + Ok(vec![]) + } + GfxPdu::CreateSurface(create) => { + self.handle_create_surface(create.surface_id, create.width, create.height, create.pixel_format); + Ok(vec![]) + } + GfxPdu::DeleteSurface(delete) => { + self.handle_delete_surface(delete.surface_id); + Ok(vec![]) + } + GfxPdu::MapSurfaceToOutput(map) => { + self.handle_map_surface(map.surface_id, map.output_origin_x, map.output_origin_y); + Ok(vec![]) + } + GfxPdu::StartFrame(start) => { + self.current_frame_id = Some(start.frame_id); + self.frames_queued = self.frames_queued.saturating_add(1); + trace!(frame_id = start.frame_id, "StartFrame"); + Ok(vec![]) + } + GfxPdu::WireToSurface1(wire) => { + self.handle_wire_to_surface1(wire)?; + Ok(vec![]) + } + GfxPdu::WireToSurface2(pdu) => { + trace!("WireToSurface2 (progressive codec)"); + self.handler.on_wire_to_surface2(&pdu); + Ok(vec![]) + } + GfxPdu::EndFrame(end) => self.handle_end_frame(end.frame_id), + + // Surface operations + GfxPdu::SolidFill(pdu) => { + trace!(surface_id = pdu.surface_id, "SolidFill"); + self.handler.on_solid_fill(&pdu); + Ok(vec![]) + } + GfxPdu::SurfaceToSurface(pdu) => { + trace!( + src = pdu.source_surface_id, + dst = pdu.destination_surface_id, + "SurfaceToSurface" + ); + self.handler.on_surface_to_surface(&pdu); + Ok(vec![]) + } + + // Cache operations + GfxPdu::SurfaceToCache(pdu) => { + trace!( + surface_id = pdu.surface_id, + cache_slot = pdu.cache_slot, + "SurfaceToCache" + ); + self.handler.on_surface_to_cache(&pdu); + Ok(vec![]) + } + GfxPdu::CacheToSurface(pdu) => { + trace!( + cache_slot = pdu.cache_slot, + surface_id = pdu.surface_id, + "CacheToSurface" + ); + self.handler.on_cache_to_surface(&pdu); + Ok(vec![]) + } + GfxPdu::EvictCacheEntry(pdu) => { + trace!(cache_slot = pdu.cache_slot, "EvictCacheEntry"); + self.handler.on_evict_cache_entry(&pdu); + Ok(vec![]) + } + GfxPdu::CacheImportReply(pdu) => { + trace!("CacheImportReply"); + self.handler.on_cache_import_reply(&pdu); + Ok(vec![]) + } + + // Surface mapping variants + GfxPdu::MapSurfaceToWindow(pdu) => { + trace!( + surface_id = pdu.surface_id, + window_id = pdu.window_id, + "MapSurfaceToWindow" + ); + self.handler.on_map_surface_to_window(&pdu); + Ok(vec![]) + } + GfxPdu::MapSurfaceToScaledOutput(pdu) => { + trace!(surface_id = pdu.surface_id, "MapSurfaceToScaledOutput"); + self.handler.on_map_surface_to_scaled_output(&pdu); + Ok(vec![]) + } + GfxPdu::MapSurfaceToScaledWindow(pdu) => { + trace!(surface_id = pdu.surface_id, "MapSurfaceToScaledWindow"); + self.handler.on_map_surface_to_scaled_window(&pdu); + Ok(vec![]) + } + + // Progressive codec context management + GfxPdu::DeleteEncodingContext(pdu) => { + trace!( + surface_id = pdu.surface_id, + codec_context_id = pdu.codec_context_id, + "DeleteEncodingContext" + ); + self.handler.on_delete_encoding_context(&pdu); + Ok(vec![]) + } + + // Catch-all for any remaining PDUs + other => { + self.handler.on_unhandled_pdu(&other); + Ok(vec![]) + } + } + } + + fn handle_capabilities_confirm(&mut self, cap: RawCapabilitySet) { + // Server confirms a single capability set. If we cannot interpret it + // (unknown version, or malformed body), we still transition to Active + // to avoid hanging the session, but we keep `negotiated_caps` empty + // and skip the typed callback so consumers don't observe a confirm + // they can't reason about. + let cap = match cap.parsed() { + Ok(Some(typed)) => typed, + Ok(None) => { + warn!( + version = cap.version.0, + "Server confirmed an unknown EGFX capability version; proceeding with defaults" + ); + self.state = ClientState::Active; + return; + } + Err(e) => { + warn!(error = %e, "Failed to parse server's EGFX capabilities confirmation"); + self.state = ClientState::Active; + return; + } + }; + + self.codec_caps = CodecCapabilities::from_capability_set(&cap); + self.state = ClientState::Active; + let cap = self.negotiated_caps.insert(cap); + + debug!( + avc420 = self.codec_caps.avc420, + avc444 = self.codec_caps.avc444, + "EGFX capabilities confirmed" + ); + + self.handler.on_capabilities_confirmed(cap); + } + + fn handle_reset_graphics(&mut self, width: u32, height: u32) { + // Per spec, ResetGraphics implicitly destroys all surfaces + self.surfaces.clear(); + + // Reset frame tracking state so subsequent FrameAcknowledge PDUs + // don't report stale queue depth from a previous stream. + // Capability state (negotiated_caps, codec_caps) is NOT reset here: + // per spec, capabilities are negotiated via CapabilitiesConfirm before + // ResetGraphics, and a ResetGraphics does not re-negotiate capabilities. + self.current_frame_id = None; + self.frames_queued = 0; + + // Reset decoder state for new stream + if let Some(ref mut decoder) = self.h264_decoder { + decoder.reset(); + } + + debug!(width, height, "Graphics reset"); + self.handler.on_reset_graphics(width, height); + } + + fn handle_create_surface(&mut self, surface_id: u16, width: u16, height: u16, pixel_format: PixelFormat) { + if width == 0 || height == 0 { + warn!(surface_id, width, height, "Ignoring CreateSurface with zero dimensions"); + return; + } + + let surface = Surface { + id: surface_id, + width, + height, + pixel_format, + is_mapped: false, + output_origin_x: 0, + output_origin_y: 0, + }; + + debug!(surface_id, width, height, ?pixel_format, "Surface created"); + self.handler.on_surface_created(&surface); + self.surfaces.insert(surface_id, surface); + } + + fn handle_delete_surface(&mut self, surface_id: u16) { + if self.surfaces.remove(&surface_id).is_some() { + debug!(surface_id, "Surface deleted"); + self.handler.on_surface_deleted(surface_id); + } else { + warn!(surface_id, "DeleteSurface for unknown surface"); + } + } + + fn handle_map_surface(&mut self, surface_id: u16, origin_x: u32, origin_y: u32) { + if let Some(surface) = self.surfaces.get_mut(&surface_id) { + surface.is_mapped = true; + surface.output_origin_x = origin_x; + surface.output_origin_y = origin_y; + debug!(surface_id, origin_x, origin_y, "Surface mapped to output"); + self.handler.on_surface_mapped(surface_id, origin_x, origin_y); + } else { + warn!(surface_id, "MapSurfaceToOutput for unknown surface"); + } + } + + fn handle_wire_to_surface1(&mut self, pdu: crate::pdu::WireToSurface1Pdu) -> PduResult<()> { + let surface = self + .surfaces + .get(&pdu.surface_id) + .ok_or_else(|| pdu_other_err!("unknown surface in WireToSurface1"))?; + + // Validate rectangle ordering (left <= right, top <= bottom) + let rect = &pdu.destination_rectangle; + if rect.left > rect.right || rect.top > rect.bottom { + warn!( + left = rect.left, + top = rect.top, + right = rect.right, + bottom = rect.bottom, + "invalid destination rectangle ordering" + ); + return Err(pdu_other_err!("invalid destination rectangle ordering")); + } + + // Validate destination rectangle against surface bounds. The rectangle + // uses exclusive `right`/`bottom`, so a full-surface update has + // `right == surface.width` and `bottom == surface.height`, which is valid. + if rect.right > surface.width || rect.bottom > surface.height { + warn!( + surface_id = pdu.surface_id, + rect_right = rect.right, + rect_bottom = rect.bottom, + surface_width = surface.width, + surface_height = surface.height, + "WireToSurface1 destination rectangle exceeds surface bounds" + ); + } + + match pdu.codec_id { + Codec1Type::Avc420 => { + self.decode_avc420(pdu.surface_id, &pdu.destination_rectangle, &pdu.bitmap_data)?; + } + Codec1Type::Avc444 | Codec1Type::Avc444v2 => { + debug!("AVC444 codec not yet implemented, forwarding to handler"); + self.handler.on_unhandled_pdu(&GfxPdu::WireToSurface1(pdu)); + } + Codec1Type::Uncompressed => { + self.handle_uncompressed(pdu); + } + _ => { + trace!(codec_id = ?pdu.codec_id, "Forwarding unsupported codec to handler"); + self.handler.on_unhandled_pdu(&GfxPdu::WireToSurface1(pdu)); + } + } + + Ok(()) + } + + fn decode_avc420(&mut self, surface_id: u16, dest_rect: &ExclusiveRectangle, bitmap_data: &[u8]) -> PduResult<()> { + let mut cursor = ReadCursor::new(bitmap_data); + let stream = Avc420BitmapStream::decode(&mut cursor).map_err(|e| decode_err!(e))?; + + let Some(ref mut decoder) = self.h264_decoder else { + debug!("No H.264 decoder configured, skipping AVC420 frame"); + return Ok(()); + }; + + let frame = decoder + .decode(stream.data) + .map_err(|e| pdu_other_err!("H.264 decode", source: e))?; + + let dest_width = dest_rect.width(); + let dest_height = dest_rect.height(); + + // Decoded frame must be at least as large as the destination rectangle. + // Larger is expected (macroblock alignment) and handled by cropping. + // Smaller means the server sent mismatched dimensions. + if frame.width() < u32::from(dest_width) || frame.height() < u32::from(dest_height) { + warn!( + frame_width = frame.width(), + frame_height = frame.height(), + dest_width, + dest_height, + "decoded frame smaller than destination rectangle" + ); + return Err(pdu_other_err!("decoded frame smaller than destination rectangle")); + } + + let cropped_data = crop_decoded_frame(frame.data(), frame.width(), frame.height(), dest_width, dest_height); + + let update = BitmapUpdate { + surface_id, + destination_rectangle: dest_rect.clone(), + codec_id: Codec1Type::Avc420, + data: cropped_data, + width: dest_width, + height: dest_height, + }; + + self.handler.on_bitmap_updated(&update); + Ok(()) + } + + fn handle_uncompressed(&mut self, pdu: crate::pdu::WireToSurface1Pdu) { + let dest_width = pdu.destination_rectangle.width(); + let dest_height = pdu.destination_rectangle.height(); + + // Convert wire-format pixels to RGBA. + // BitmapUpdate.data is always RGBA8888 regardless of codec -- this is + // the convention so that handlers get a uniform pixel format. + // Uncompressed wire format is 32-bit LE (0xAARRGGBB → bytes [B, G, R, A]). + let rgba_data = convert_uncompressed_to_rgba(&pdu.bitmap_data); + + let update = BitmapUpdate { + surface_id: pdu.surface_id, + destination_rectangle: pdu.destination_rectangle, + codec_id: Codec1Type::Uncompressed, + data: rgba_data, + width: dest_width, + height: dest_height, + }; + + self.handler.on_bitmap_updated(&update); + } + + #[expect(clippy::as_conversions, reason = "Box to Box coercion")] + fn handle_end_frame(&mut self, frame_id: u32) -> PduResult> { + self.total_frames_decoded = self.total_frames_decoded.wrapping_add(1); + self.current_frame_id = None; + self.frames_queued = self.frames_queued.saturating_sub(1); + + self.handler.on_frame_complete(frame_id); + + // Per [3.3.5.12]: client MUST send FrameAcknowledge after EndFrame + let ack = GfxPdu::FrameAcknowledge(FrameAcknowledgePdu { + queue_depth: QueueDepth::from_u32(self.frames_queued), + frame_id, + total_frames_decoded: self.total_frames_decoded, + }); + + trace!(frame_id, "Sending FrameAcknowledge"); + Ok(vec![Box::new(ack) as DvcMessage]) + } +} + +impl_as_any!(GraphicsPipelineClient); + +impl DvcProcessor for GraphicsPipelineClient { + fn channel_name(&self) -> &str { + CHANNEL_NAME + } + + fn start(&mut self, _channel_id: u32) -> PduResult> { + let caps = if self.h264_decoder.is_some() { + self.handler.capabilities() + } else { + // No H.264 decoder: filter out capability sets that imply AVC support. + // Only keep sets that work without a decoder (V8 without AVC flags). + let filtered: Vec = self + .handler + .capabilities() + .into_iter() + .filter(|cap| !CodecCapabilities::from_capability_set(cap).avc420) + .collect(); + + if filtered.is_empty() { + // All handler caps required AVC; fall back to V8-only + debug!("No H.264 decoder and all capabilities require AVC; falling back to V8"); + vec![CapabilitySet::V8 { + flags: CapabilitiesV8Flags::SMALL_CACHE, + }] + } else { + filtered + } + }; + + let pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&caps)); + + #[expect(clippy::as_conversions, reason = "Box to Box coercion")] + Ok(vec![Box::new(pdu) as DvcMessage]) + } + + fn close(&mut self, _channel_id: u32) { + self.state = ClientState::Closed; + self.handler.on_close(); + } + + fn process(&mut self, _channel_id: u32, payload: &[u8]) -> PduResult> { + // ZGFX decompress + self.decompressed_buffer.clear(); + self.decompressed_buffer.shrink_to(MAX_DECOMPRESSED_BUFFER_CAPACITY); + self.decompressor + .decompress(payload, &mut self.decompressed_buffer) + .map_err(|e| decode_err!(e))?; + + // Decode all PDUs first (cursor borrows decompressed_buffer) + let mut pdus = Vec::new(); + { + let mut cursor = ReadCursor::new(self.decompressed_buffer.as_slice()); + while !cursor.is_empty() { + let pdu: GfxPdu = decode_cursor(&mut cursor).map_err(|e| decode_err!(e))?; + pdus.push(pdu); + } + } + + // Process decoded PDUs + let mut responses: Vec = Vec::new(); + for pdu in pdus { + let pdu_responses = self.handle_pdu(pdu)?; + responses.extend(pdu_responses); + } + + Ok(responses) + } +} + +impl DvcClientProcessor for GraphicsPipelineClient {} + +// ============================================================================ +// Frame Cropping +// ============================================================================ + +/// Convert uncompressed 32bpp little-endian pixels to RGBA8888 +/// +/// The wire format for uncompressed graphics is 0xAARRGGBB in a 32-bit +/// little-endian word, which corresponds to bytes [B, G, R, A]. This +/// reorders to [R, G, B, 0xFF], treating all pixels as fully opaque. +fn convert_uncompressed_to_rgba(src: &[u8]) -> Vec { + let mut dst = Vec::with_capacity(src.len()); + for pixel in src.chunks_exact(4) { + let b = pixel[0]; + let g = pixel[1]; + let r = pixel[2]; + dst.extend_from_slice(&[r, g, b, 0xFF]); + } + dst +} + +/// Crop a decoded RGBA frame to target dimensions +/// +/// H.264 frames are macroblock-aligned (16x16), so decoded frames +/// may be larger than the destination rectangle. This function +/// extracts the top-left region matching the target size. +fn crop_decoded_frame( + data: &[u8], + decoded_width: u32, + decoded_height: u32, + target_width: u16, + target_height: u16, +) -> Vec { + let tw = u32::from(target_width); + let th = u32::from(target_height); + + if decoded_width == 0 || decoded_height == 0 || tw == 0 || th == 0 { + return Vec::new(); + } + + // If dimensions match, return as-is + if decoded_width == tw && decoded_height == th { + return data.to_vec(); + } + + let src_stride = decoded_width.saturating_mul(4); + let dst_stride = tw.saturating_mul(4); + let rows = th.min(decoded_height); + + #[expect(clippy::as_conversions, reason = "product of u32 values bounded by frame dimensions")] + let mut cropped = Vec::with_capacity((dst_stride as usize).saturating_mul(rows as usize)); + + for row in 0..rows { + #[expect(clippy::as_conversions, reason = "row * src_stride bounded by frame size")] + let src_start = (row.saturating_mul(src_stride)) as usize; + #[expect(clippy::as_conversions, reason = "bounded by frame dimensions")] + let copy_len = dst_stride.min(src_stride) as usize; + let src_end = src_start.saturating_add(copy_len); + if src_end <= data.len() { + cropped.extend_from_slice(&data[src_start..src_end]); + } + } + + #[expect(clippy::as_conversions, reason = "dst_stride * rows bounded by frame dimensions")] + let expected_len = (dst_stride as usize).saturating_mul(rows as usize); + if cropped.len() < expected_len { + tracing::warn!( + expected = expected_len, + actual = cropped.len(), + "Decoded frame data truncated during crop" + ); + } + + cropped +} + +/// Unit tests that require access to private fields (state, surfaces, frame tracking). +/// Integration tests exercising the public DVC API are in ironrdp-testsuite-core/tests/egfx/client.rs. +#[cfg(test)] +mod tests { + use super::*; + + struct TestHandler; + impl GraphicsPipelineHandler for TestHandler { + fn on_capabilities_confirmed(&mut self, _caps: &CapabilitySet) {} + fn on_reset_graphics(&mut self, _width: u32, _height: u32) {} + fn on_surface_created(&mut self, _surface: &Surface) {} + fn on_surface_deleted(&mut self, _surface_id: u16) {} + fn on_surface_mapped(&mut self, _surface_id: u16, _x: u32, _y: u32) {} + fn on_bitmap_updated(&mut self, _update: &BitmapUpdate) {} + fn on_frame_complete(&mut self, _frame_id: u32) {} + fn on_close(&mut self) {} + fn on_unhandled_pdu(&mut self, _pdu: &GfxPdu) {} + } + + #[test] + fn state_transitions() { + let mut client = GraphicsPipelineClient::new(Box::new(TestHandler), None); + + assert_eq!(client.state, ClientState::WaitingForConfirm); + assert!(!client.is_active()); + + let _ = client.handle_pdu(GfxPdu::CapabilitiesConfirm( + crate::pdu::CapabilitiesConfirmPdu::from_typed(&CapabilitySet::V8 { + flags: CapabilitiesV8Flags::empty(), + }), + )); + assert_eq!(client.state, ClientState::Active); + assert!(client.is_active()); + + client.close(0); + assert_eq!(client.state, ClientState::Closed); + assert!(!client.is_active()); + } + + #[test] + fn reset_graphics_clears_surfaces_and_frame_tracking() { + let mut client = GraphicsPipelineClient::new(Box::new(TestHandler), None); + + let _ = client.handle_pdu(GfxPdu::CreateSurface(crate::pdu::CreateSurfacePdu { + surface_id: 1, + width: 100, + height: 100, + pixel_format: PixelFormat::XRgb, + })); + assert_eq!(client.surfaces.len(), 1); + + // Simulate mid-stream state + let _ = client.handle_pdu(GfxPdu::StartFrame(crate::pdu::StartFramePdu { + timestamp: crate::pdu::Timestamp { + milliseconds: 0, + seconds: 0, + minutes: 0, + hours: 0, + }, + frame_id: 42, + })); + assert!(client.current_frame_id.is_some()); + assert_eq!(client.frames_queued, 1); + + let _ = client.handle_pdu(GfxPdu::ResetGraphics(crate::pdu::ResetGraphicsPdu { + width: 1920, + height: 1080, + monitors: vec![], + })); + + assert!(client.surfaces.is_empty(), "surfaces should be cleared"); + assert!(client.current_frame_id.is_none(), "frame_id should be reset"); + assert_eq!(client.frames_queued, 0, "frame queue should be reset"); + } + + #[test] + fn crop_decoded_frame_identity() { + let data = vec![0xFFu8; 4 * 4 * 4]; + let cropped = crop_decoded_frame(&data, 4, 4, 4, 4); + assert_eq!(cropped.len(), data.len()); + } + + #[test] + fn crop_decoded_frame_macroblock_alignment() { + // H.264 encodes 1920x1080 as 1920x1088 (rounded to 16-pixel macroblock boundary) + let data = vec![0xAAu8; 1920 * 1088 * 4]; + let cropped = crop_decoded_frame(&data, 1920, 1088, 1920, 1080); + assert_eq!(cropped.len(), 1920 * 1080 * 4); + } + + #[test] + fn convert_uncompressed_bgrx_to_rgba() { + // Wire format: [B, G, R, A] per pixel (0xAARRGGBB little-endian) + let wire_pixels = vec![ + 0x00, 0x80, 0xFF, 0xCC, // B=0, G=128, R=255, A=204 + 0x10, 0x20, 0x30, 0x40, // B=16, G=32, R=48, A=64 + ]; + let rgba = convert_uncompressed_to_rgba(&wire_pixels); + // Expected: [R, G, B, 0xFF] per pixel (alpha forced to opaque) + assert_eq!(rgba, vec![0xFF, 0x80, 0x00, 0xFF, 0x30, 0x20, 0x10, 0xFF]); + } +} diff --git a/crates/ironrdp-egfx/src/decode.rs b/crates/ironrdp-egfx/src/decode.rs new file mode 100644 index 0000000000..403924120f --- /dev/null +++ b/crates/ironrdp-egfx/src/decode.rs @@ -0,0 +1,345 @@ +//! Codec decoder traits for client-side EGFX processing +//! +//! This module provides pluggable decoder traits that allow consumers +//! to bring their own codec implementations (e.g., openh264, ffmpeg, +//! hardware decoders). The traits are designed for core tier: no I/O, +//! `Send` only. They are intended for use in `std` environments; +//! `no_std` + `alloc` support is not currently guaranteed. +//! +//! # Protocol Context +//! +//! H.264 data arrives inside [RFX_AVC420_BITMAP_STREAM][1] payloads +//! within `RDPGFX_WIRE_TO_SURFACE_PDU_1` messages. The NAL units +//! are in AVC format (4-byte big-endian length prefix per NAL unit), +//! not Annex B (start code prefix). +//! +//! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/d65c3f9c-2088-4302-90c0-53adc0e11a78 + +use core::fmt; + +// ============================================================================ +// Decoded Frame +// ============================================================================ + +/// Decoded bitmap frame from an H.264 decoder +/// +/// Contains RGBA pixel data for a decoded H.264 frame. +/// The pixel data is in RGBA format (4 bytes per pixel), +/// row-major, top-to-bottom, left-to-right. +#[derive(Clone)] +#[non_exhaustive] +pub struct DecodedFrame { + data: Vec, + width: u32, + height: u32, +} + +impl DecodedFrame { + #[expect( + clippy::as_conversions, + reason = "usize to u64 is lossless on all supported platforms (32/64-bit)" + )] + pub fn new(data: Vec, width: u32, height: u32) -> Self { + debug_assert_eq!( + data.len() as u64, + u64::from(width).saturating_mul(u64::from(height)).saturating_mul(4), + "DecodedFrame buffer must be RGBA8888 (width * height * 4 bytes)", + ); + Self { data, width, height } + } + + /// RGBA pixel data (4 bytes per pixel). + pub fn data(&self) -> &[u8] { + &self.data + } + + /// Frame width in pixels. + pub fn width(&self) -> u32 { + self.width + } + + /// Frame height in pixels. + pub fn height(&self) -> u32 { + self.height + } + + /// Consume the frame and return the owned RGBA buffer. + pub fn into_data(self) -> Vec { + self.data + } +} + +impl fmt::Debug for DecodedFrame { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DecodedFrame") + .field("width", &self.width) + .field("height", &self.height) + .field("data_len", &self.data.len()) + .finish() + } +} + +// ============================================================================ +// Decoder Error +// ============================================================================ + +/// Error type for decoder operations +#[derive(Debug)] +#[non_exhaustive] +pub struct DecoderError { + context: String, + source: Option>, +} + +impl DecoderError { + /// Create a decoder error with a source error + pub fn new(context: impl Into, source: impl core::error::Error + Send + Sync + 'static) -> Self { + Self { + context: context.into(), + source: Some(Box::new(source)), + } + } + + /// Create a decoder error with only a message + pub fn msg(context: impl Into) -> Self { + Self { + context: context.into(), + source: None, + } + } +} + +impl fmt::Display for DecoderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "decoder error: {}", self.context)?; + if let Some(ref source) = self.source { + write!(f, ": {source}")?; + } + Ok(()) + } +} + +impl core::error::Error for DecoderError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + self.source.as_deref().map(|e| { + let err: &(dyn core::error::Error + 'static) = e; + err + }) + } +} + +/// Result type for decoder operations +pub type DecoderResult = Result; + +// ============================================================================ +// H.264 Decoder Trait +// ============================================================================ + +/// Trait for H.264 (AVC) decoders +/// +/// Implement this trait to provide H.264 decode capability to the +/// EGFX client. The decoder receives AVC-format NAL units (length-prefixed, +/// not Annex B) from `RFX_AVC420_BITMAP_STREAM` payloads. +/// +/// # Thread Safety +/// +/// Implementations must be `Send` to work with the DVC framework. +/// +/// # Example +/// +/// ```ignore +/// use ironrdp_egfx::decode::{H264Decoder, DecodedFrame, DecoderResult}; +/// +/// struct MyH264Decoder { /* ... */ } +/// +/// impl H264Decoder for MyH264Decoder { +/// fn decode(&mut self, data: &[u8]) -> DecoderResult { +/// // Decode H.264 NAL units to RGBA +/// todo!() +/// } +/// } +/// ``` +pub trait H264Decoder: Send { + /// Decode AVC-format H.264 NAL units (4-byte BE length prefix, not Annex B) + /// into an RGBA bitmap. + /// + /// Frame dimensions may exceed the destination rectangle due to + /// macroblock alignment (16x16). The caller crops to fit. + fn decode(&mut self, data: &[u8]) -> DecoderResult; + + /// Reset the decoder state + /// + /// Called when surfaces are reset (e.g., on `ResetGraphics`). + /// The decoder should drop any internal state and prepare for + /// a new stream. + fn reset(&mut self) { + // Default: no-op + } +} + +// ============================================================================ +// OpenH264 Implementation +// ============================================================================ + +#[cfg(feature = "openh264")] +mod openh264_impl { + use tracing::warn; + + use super::{DecodedFrame, DecoderError, DecoderResult, H264Decoder}; + + /// H.264 decoder backed by Cisco's OpenH264 library + /// + /// This decoder converts AVC-format NAL units to Annex B format + /// (as required by OpenH264), decodes to YUV420p, then converts + /// to RGBA for the client pipeline. + /// + /// # Feature Gates + /// + /// Two construction paths are available depending on the feature flags: + /// + /// - `openh264-bundled`: compiles OpenH264 from source at build time. + /// Use [`OpenH264Decoder::new()`] to construct. + /// + /// - `openh264-libloading`: loads a prebuilt Cisco OpenH264 binary at + /// runtime. Use [`OpenH264Decoder::from_library_path()`] to construct. + /// The library is verified against known Cisco release hashes. + pub struct OpenH264Decoder { + decoder: openh264::decoder::Decoder, + annex_b_buffer: Vec, + } + + impl OpenH264Decoder { + /// Create a decoder using the bundled (source-compiled) OpenH264 library + /// + /// This compiles OpenH264 C code at build time. The resulting binary + /// has no patent coverage from Cisco's license agreement. + #[cfg(feature = "openh264-bundled")] + pub fn new() -> DecoderResult { + let decoder = openh264::decoder::Decoder::new() + .map_err(|e| DecoderError::new("failed to create OpenH264 decoder", e))?; + + Ok(Self { + decoder, + annex_b_buffer: Vec::new(), + }) + } + + /// Create a decoder using a dynamically loaded OpenH264 library + /// + /// `library_path` should point to a Cisco OpenH264 prebuilt binary, + /// which is verified against known Cisco release hashes before loading. + /// Cisco's prebuilt binaries carry patent coverage under their license. + #[cfg(feature = "openh264-libloading")] + pub fn from_library_path(library_path: &std::path::Path) -> DecoderResult { + let api = openh264::OpenH264API::from_blob_path(library_path) + .map_err(|e| DecoderError::new("failed to load OpenH264 library", e))?; + let decoder = openh264::decoder::Decoder::with_api_config(api, Default::default()) + .map_err(|e| DecoderError::new("failed to create OpenH264 decoder", e))?; + + Ok(Self { + decoder, + annex_b_buffer: Vec::new(), + }) + } + + /// Convert AVC format (4-byte BE length prefix) to Annex B (start codes) + fn avc_to_annex_b(&mut self, data: &[u8]) { + self.annex_b_buffer.clear(); + let mut offset = 0; + + while offset + 4 <= data.len() { + let nal_len = u32::from_be_bytes([data[offset], data[offset + 1], data[offset + 2], data[offset + 3]]); + + #[expect(clippy::as_conversions, reason = "NAL length from wire format")] + let nal_len = nal_len as usize; + offset += 4; + + // Use checked addition to prevent overflow on malicious input + let Some(end) = offset.checked_add(nal_len) else { + warn!(nal_len, offset, "AVC NAL length overflow, discarding remaining data"); + break; + }; + if end > data.len() { + warn!( + nal_len, + offset, + data_len = data.len(), + "AVC NAL extends beyond buffer, discarding remaining data" + ); + break; + } + + // Annex B start code + self.annex_b_buffer.extend_from_slice(&[0x00, 0x00, 0x00, 0x01]); + self.annex_b_buffer.extend_from_slice(&data[offset..offset + nal_len]); + offset += nal_len; + } + } + } + + impl H264Decoder for OpenH264Decoder { + fn decode(&mut self, data: &[u8]) -> DecoderResult { + self.avc_to_annex_b(data); + + let yuv = self + .decoder + .decode(&self.annex_b_buffer) + .map_err(|e| DecoderError::new("OpenH264 decode failed", e))? + .ok_or_else(|| DecoderError::msg("OpenH264 returned no picture"))?; + + let (width, height) = openh264::formats::YUVSource::dimensions(&yuv); + + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "H.264 frame dimensions are always within u32 range" + )] + let (w32, h32) = (width as u32, height as u32); + + let rgba_size = width + .checked_mul(height) + .and_then(|s| s.checked_mul(4)) + .ok_or_else(|| DecoderError::msg("frame dimensions too large for RGBA allocation"))?; + let mut rgba = vec![0u8; rgba_size]; + yuv.write_rgba8(&mut rgba); + + Ok(DecodedFrame::new(rgba, w32, h32)) + } + + fn reset(&mut self) { + // Recreate decoder from source when available + #[cfg(feature = "openh264-bundled")] + match openh264::decoder::Decoder::new() { + Ok(new_decoder) => self.decoder = new_decoder, + Err(e) => warn!("Failed to reset OpenH264 decoder, reusing existing state: {e}"), + } + // In libloading-only mode, we don't have the library path stored, + // so we can't recreate. The existing decoder handles new SPS/PPS + // transparently when the next I-frame arrives. + } + } +} + +#[cfg(feature = "openh264")] +pub use openh264_impl::OpenH264Decoder; + +#[cfg(test)] +mod tests { + use super::DecodedFrame; + + #[test] + fn getters_return_constructor_inputs() { + let data = vec![0u8; 2 * 3 * 4]; + let frame = DecodedFrame::new(data.clone(), 2, 3); + assert_eq!(frame.data(), data.as_slice()); + assert_eq!(frame.width(), 2); + assert_eq!(frame.height(), 3); + } + + #[test] + fn into_data_yields_owned_buffer() { + let data = vec![0xAAu8; 4 * 4 * 4]; + let frame = DecodedFrame::new(data.clone(), 4, 4); + assert_eq!(frame.into_data(), data); + } +} diff --git a/crates/ironrdp-egfx/src/lib.rs b/crates/ironrdp-egfx/src/lib.rs new file mode 100644 index 0000000000..aa2082c18e --- /dev/null +++ b/crates/ironrdp-egfx/src/lib.rs @@ -0,0 +1,10 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] +#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] + +/// EGFX dynamic virtual channel name per MS-RDPEGFX +pub const CHANNEL_NAME: &str = "Microsoft::Windows::RDS::Graphics"; + +pub mod client; +pub mod decode; +pub mod pdu; +pub mod server; diff --git a/crates/ironrdp-egfx/src/pdu/avc.rs b/crates/ironrdp-egfx/src/pdu/avc.rs new file mode 100644 index 0000000000..d5039bfa4a --- /dev/null +++ b/crates/ironrdp-egfx/src/pdu/avc.rs @@ -0,0 +1,599 @@ +use core::fmt; + +use bit_field::BitField as _; +use bitflags::bitflags; +use ironrdp_pdu::geometry::InclusiveRectangle; +use ironrdp_pdu::{ + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QuantQuality { + pub quantization_parameter: u8, + pub progressive: bool, + pub quality: u8, +} + +// Manual `Arbitrary` impl: the encoder packs `quantization_parameter` into bits 0..6 +// via `set_bits`, which panics when the value exceeds 6 bits. Mask the field to its +// wire-allowed range so fuzz inputs always round-trip through `Encode`. The other +// fields use their full type range. +#[cfg(feature = "arbitrary")] +impl<'a> arbitrary::Arbitrary<'a> for QuantQuality { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + Ok(Self { + quantization_parameter: u.arbitrary::()? & 0x3F, // 6 bits + progressive: u.arbitrary()?, + quality: u.arbitrary()?, + }) + } +} + +impl QuantQuality { + const NAME: &'static str = "GfxQuantQuality"; + + const FIXED_PART_SIZE: usize = 1 /* data */ + 1 /* quality */; +} + +impl Encode for QuantQuality { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + let mut data = 0u8; + data.set_bits(0..6, self.quantization_parameter); + data.set_bit(7, self.progressive); + dst.write_u8(data); + dst.write_u8(self.quality); + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'de> Decode<'de> for QuantQuality { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let data = src.read_u8(); + let qp = data.get_bits(0..6); + let progressive = data.get_bit(7); + let quality = src.read_u8(); + Ok(QuantQuality { + quantization_parameter: qp, + progressive, + quality, + }) + } +} + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Clone, PartialEq, Eq)] +pub struct Avc420BitmapStream<'a> { + pub rectangles: Vec, + pub quant_qual_vals: Vec, + pub data: &'a [u8], +} + +impl fmt::Debug for Avc420BitmapStream<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Avc420BitmapStream") + .field("rectangles", &self.rectangles) + .field("quant_qual_vals", &self.quant_qual_vals) + .field("data_len", &self.data.len()) + .finish() + } +} + +impl Avc420BitmapStream<'_> { + const NAME: &'static str = "Avc420BitmapStream"; + + const FIXED_PART_SIZE: usize = 4 /* nRect */; +} + +impl Encode for Avc420BitmapStream<'_> { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + // INVARIANT: rectangles.len() == quant_qual_vals.len() + debug_assert_eq!(self.rectangles.len(), self.quant_qual_vals.len()); + + dst.write_u32(cast_length!("len", self.rectangles.len())?); + for rectangle in &self.rectangles { + rectangle.encode(dst)?; + } + for quant_qual_val in &self.quant_qual_vals { + quant_qual_val.encode(dst)?; + } + dst.write_slice(self.data); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + // Each rectangle is 8 bytes and 2 bytes for each quant val + Self::FIXED_PART_SIZE + self.rectangles.len() * 10 + self.data.len() + } +} + +impl<'de> Decode<'de> for Avc420BitmapStream<'de> { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let num_regions = src.read_u32(); + #[expect(clippy::as_conversions, reason = "num_regions bounded by practical limits")] + let num_regions_usize = num_regions as usize; + // Cap pre-allocation against the remaining buffer to avoid OOM from a + // malicious num_regions: each region needs at least one rectangle + // (8 bytes) plus one QuantQuality entry (2 bytes). The actual read + // loop will fail with NotEnoughBytes if num_regions is bogus. + let per_region = InclusiveRectangle::FIXED_PART_SIZE + QuantQuality::FIXED_PART_SIZE; + let max_possible = src.len() / per_region; + let bounded_capacity = num_regions_usize.min(max_possible); + let mut rectangles = Vec::with_capacity(bounded_capacity); + let mut quant_qual_vals = Vec::with_capacity(bounded_capacity); + for _ in 0..num_regions { + rectangles.push(InclusiveRectangle::decode(src)?); + } + for _ in 0..num_regions { + quant_qual_vals.push(QuantQuality::decode(src)?); + } + let data = src.remaining(); + Ok(Avc420BitmapStream { + rectangles, + quant_qual_vals, + data, + }) + } +} + +bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct Encoding: u8 { + const LUMA_AND_CHROMA = 0x00; + const LUMA = 0x01; + const CHROMA = 0x02; + + const _ = !0; + } +} + +// Manual `Arbitrary` impl: the encoder packs `encoding.bits()` into 2 bits via +// `set_bits(30..32, ...)` on the Avc444BitmapStream stream-info field. The bitflag +// otherwise accepts any u8 value (via `const _ = !0`), so the bitflags-crate-provided +// derive would generate values that exceed the 2-bit wire range and panic the encoder. +// Mask to 2 bits. +#[cfg(feature = "arbitrary")] +impl<'a> arbitrary::Arbitrary<'a> for Encoding { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + Ok(Self::from_bits_retain(u.arbitrary::()? & 0x03)) + } +} + +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Avc444BitmapStream<'a> { + pub encoding: Encoding, + pub stream1: Avc420BitmapStream<'a>, + pub stream2: Option>, +} + +impl Avc444BitmapStream<'_> { + const NAME: &'static str = "Avc444BitmapStream"; + + const FIXED_PART_SIZE: usize = 4 /* streamInfo */; +} + +impl Encode for Avc444BitmapStream<'_> { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + let mut stream_info = 0u32; + stream_info.set_bits(0..30, cast_length!("stream1size", self.stream1.size())?); + stream_info.set_bits(30..32, self.encoding.bits().into()); + dst.write_u32(stream_info); + self.stream1.encode(dst)?; + if let Some(stream) = self.stream2.as_ref() { + stream.encode(dst)?; + } + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + let stream2_size = if let Some(stream) = self.stream2.as_ref() { + stream.size() + } else { + 0 + }; + + Self::FIXED_PART_SIZE + self.stream1.size() + stream2_size + } +} + +impl<'de> Decode<'de> for Avc444BitmapStream<'de> { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let stream_info = src.read_u32(); + let stream_len = stream_info.get_bits(0..30); + #[expect(clippy::unwrap_used, reason = "2-bit extraction always fits in u8")] + let encoding_raw: u8 = stream_info.get_bits(30..32).try_into().unwrap(); + // Only 0x00 (LUMA_AND_CHROMA), 0x01 (LUMA), 0x02 (CHROMA) are defined. + if encoding_raw > 2 { + return Err(invalid_field_err!("encoding", "reserved encoding value")); + } + let encoding = Encoding::from_bits_retain(encoding_raw); + + if stream_len == 0 { + if encoding == Encoding::LUMA_AND_CHROMA { + return Err(invalid_field_err!("encoding", "invalid encoding")); + } + + let stream1 = Avc420BitmapStream::decode(src)?; + Ok(Avc444BitmapStream { + encoding, + stream1, + stream2: None, + }) + } else { + #[expect(clippy::as_conversions, reason = "30-bit value fits in usize")] + let stream_len = stream_len as usize; + // Validate that the declared stream length fits in the remaining + // buffer; src.split_at panics on overflow, so a malformed + // streamLen field would otherwise crash the decoder. Surfaced + // by the pdu_decode fuzz target. + ensure_size!(ctx: Self::NAME, in: src, size: stream_len); + let (mut stream1, mut stream2) = src.split_at(stream_len); + let stream1 = Avc420BitmapStream::decode(&mut stream1)?; + let stream2 = if encoding == Encoding::LUMA_AND_CHROMA { + Some(Avc420BitmapStream::decode(&mut stream2)?) + } else { + None + }; + Ok(Avc444BitmapStream { + encoding, + stream1, + stream2, + }) + } + } +} + +// ============================================================================ +// Server-side utilities for H.264/AVC encoding +// ============================================================================ + +/// Region metadata for AVC420 bitmap streams (server-side) +/// +/// Describes a rectangular region within the frame along with its +/// H.264 encoding parameters. +/// +/// # Example +/// +/// ``` +/// use ironrdp_egfx::pdu::Avc420Region; +/// +/// // Create a region covering a 1920x1080 frame +/// let region = Avc420Region::full_frame(1920, 1080, 22); +/// assert_eq!(region.left, 0); +/// assert_eq!(region.right, 1919); +/// ``` +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Avc420Region { + /// Left edge of the region (inclusive) + pub left: u16, + /// Top edge of the region (inclusive) + pub top: u16, + /// Right edge of the region (inclusive) + pub right: u16, + /// Bottom edge of the region (inclusive) + pub bottom: u16, + /// H.264 quantization parameter (0-51, lower = higher quality) + pub quantization_parameter: u8, + /// Quality value (0-100) + pub quality: u8, +} + +impl Avc420Region { + /// Create a region covering the entire frame + /// + /// # Arguments + /// + /// * `width` - Frame width in pixels + /// * `height` - Frame height in pixels + /// * `qp` - H.264 quantization parameter (0-51) + #[must_use] + pub fn full_frame(width: u16, height: u16, qp: u8) -> Self { + Self { + left: 0, + top: 0, + right: width.saturating_sub(1), + bottom: height.saturating_sub(1), + quantization_parameter: qp, + quality: 100, + } + } + + /// Create a region with custom bounds + #[must_use] + pub fn new(left: u16, top: u16, right: u16, bottom: u16, qp: u8, quality: u8) -> Self { + Self { + left, + top, + right, + bottom, + quantization_parameter: qp, + quality, + } + } + + /// Convert to `InclusiveRectangle` for PDU encoding + #[must_use] + pub fn to_rectangle(&self) -> InclusiveRectangle { + InclusiveRectangle { + left: self.left, + top: self.top, + right: self.right, + bottom: self.bottom, + } + } + + /// Convert to `QuantQuality` for PDU encoding + #[must_use] + pub fn to_quant_quality(&self) -> QuantQuality { + QuantQuality { + quantization_parameter: self.quantization_parameter, + progressive: false, + quality: self.quality, + } + } +} + +/// Convert H.264 Annex B format to AVC format +/// +/// MS-RDPEGFX requires AVC format (length-prefixed NAL units), +/// but most encoders output Annex B format (start code prefixed). +/// +/// ```text +/// Annex B: 00 00 00 01 00 00 00 01 ... +/// AVC: <4-byte BE length> <4-byte BE length> ... +/// ``` +/// +/// # Arguments +/// +/// * `data` - H.264 bitstream in Annex B format +/// +/// # Returns +/// +/// H.264 bitstream in AVC format with 4-byte big-endian length prefixes +/// +/// # Example +/// +/// ``` +/// use ironrdp_egfx::pdu::annex_b_to_avc; +/// +/// // NAL unit with 3-byte start code +/// let annex_b = [0x00, 0x00, 0x01, 0x67, 0x42, 0x00]; +/// let avc = annex_b_to_avc(&annex_b); +/// // Result: [0x00, 0x00, 0x00, 0x03, 0x67, 0x42, 0x00] +/// assert_eq!(avc[0..4], [0, 0, 0, 3]); // 4-byte length = 3 +/// ``` +#[must_use] +pub fn annex_b_to_avc(data: &[u8]) -> Vec { + let mut result = Vec::with_capacity(data.len()); + let mut i = 0; + + while i < data.len() { + // Find start code (00 00 01 or 00 00 00 01) + let start; + + if i + 4 <= data.len() && data[i..i + 4] == [0, 0, 0, 1] { + start = i + 4; + } else if i + 3 <= data.len() && data[i..i + 3] == [0, 0, 1] { + start = i + 3; + } else { + i += 1; + continue; + } + + // Find next start code or end of data + let mut end = data.len(); + for j in start..data.len().saturating_sub(2) { + if data[j..j + 3] == [0, 0, 1] { + // Could be 3-byte or 4-byte start code + // Check if there's a leading zero (4-byte) + if j > 0 && data[j - 1] == 0 { + end = j - 1; + } else { + end = j; + } + break; + } + } + + // Write length-prefixed NAL unit + let nal_data = &data[start..end]; + if !nal_data.is_empty() { + // NAL units in H.264 are limited to ~4GB (32-bit length), so truncation is not a concern + #[expect( + clippy::cast_possible_truncation, + clippy::as_conversions, + reason = "NAL unit length fits in u32" + )] + let len = nal_data.len() as u32; + result.extend_from_slice(&len.to_be_bytes()); + result.extend_from_slice(nal_data); + } + + // Move to end of current NAL unit; next iteration will find the next start code + i = end; + } + + result +} + +/// Align a dimension to 16-pixel boundary +/// +/// H.264 operates on 16x16 macroblocks. This function rounds up +/// a dimension to the nearest multiple of 16. +/// +/// # Example +/// +/// ``` +/// use ironrdp_egfx::pdu::align_to_16; +/// +/// assert_eq!(align_to_16(1920), 1920); // Already aligned +/// assert_eq!(align_to_16(1080), 1088); // Rounded up +/// assert_eq!(align_to_16(1), 16); +/// ``` +#[must_use] +pub const fn align_to_16(dimension: u32) -> u32 { + (dimension + 15) & !15 +} + +/// Create an owned AVC420 bitmap stream from regions and H.264 data +/// +/// This is a helper for server-side frame encoding. It creates +/// the bitmap stream structure that can be embedded in a +/// `WireToSurface1Pdu`. +/// +/// # Arguments +/// +/// * `regions` - List of regions with their encoding parameters +/// * `h264_data` - H.264 encoded data (should be in AVC format, not Annex B) +/// +/// # Returns +/// +/// Encoded `Avc420BitmapStream` as a byte vector +/// +/// # Panics +/// +/// Panics if internal encoding fails (should not happen with valid inputs). +#[must_use] +pub fn encode_avc420_bitmap_stream(regions: &[Avc420Region], h264_data: &[u8]) -> Vec { + let rectangles: Vec = regions.iter().map(Avc420Region::to_rectangle).collect(); + + let quant_qual_vals: Vec = regions.iter().map(Avc420Region::to_quant_quality).collect(); + + let stream = Avc420BitmapStream { + rectangles, + quant_qual_vals, + data: h264_data, + }; + + // Calculate size and encode + let size = stream.size(); + let mut buf = vec![0u8; size]; + let mut cursor = WriteCursor::new(&mut buf); + + // This should not fail as we pre-allocated the exact size + stream + .encode(&mut cursor) + .expect("encode_avc420_bitmap_stream: encoding failed"); + + buf +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_avc420_region_full_frame() { + let region = Avc420Region::full_frame(1920, 1080, 22); + assert_eq!(region.left, 0); + assert_eq!(region.top, 0); + assert_eq!(region.right, 1919); + assert_eq!(region.bottom, 1079); + assert_eq!(region.quantization_parameter, 22); + assert_eq!(region.quality, 100); + } + + #[test] + fn test_align_to_16() { + assert_eq!(align_to_16(0), 0); + assert_eq!(align_to_16(1), 16); + assert_eq!(align_to_16(15), 16); + assert_eq!(align_to_16(16), 16); + assert_eq!(align_to_16(17), 32); + assert_eq!(align_to_16(1920), 1920); + assert_eq!(align_to_16(1080), 1088); + } + + #[test] + fn test_annex_b_to_avc_3byte_start() { + // NAL with 3-byte start code: 00 00 01 + let annex_b = [0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E]; + let avc = annex_b_to_avc(&annex_b); + + // Should be: 4-byte length (4) + NAL data + assert_eq!(avc.len(), 8); + assert_eq!(&avc[0..4], &[0, 0, 0, 4]); // Length = 4 + assert_eq!(&avc[4..8], &[0x67, 0x42, 0x00, 0x1E]); + } + + #[test] + fn test_annex_b_to_avc_4byte_start() { + // NAL with 4-byte start code: 00 00 00 01 + let annex_b = [0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0x00]; + let avc = annex_b_to_avc(&annex_b); + + assert_eq!(avc.len(), 7); + assert_eq!(&avc[0..4], &[0, 0, 0, 3]); // Length = 3 + assert_eq!(&avc[4..7], &[0x67, 0x42, 0x00]); + } + + #[test] + fn test_annex_b_to_avc_multiple_nals() { + // Two NAL units + let annex_b = [ + 0x00, 0x00, 0x00, 0x01, 0x67, 0x42, // SPS + 0x00, 0x00, 0x01, 0x68, 0xCE, // PPS with 3-byte start + ]; + let avc = annex_b_to_avc(&annex_b); + + // First NAL: 4 bytes length + 2 bytes data + // Second NAL: 4 bytes length + 2 bytes data + assert!(avc.len() >= 12); + } + + #[test] + fn test_annex_b_to_avc_empty() { + let avc = annex_b_to_avc(&[]); + assert!(avc.is_empty()); + } + + #[test] + fn test_encode_avc420_bitmap_stream() { + let regions = vec![Avc420Region::full_frame(1920, 1080, 22)]; + let h264_data = [0x00, 0x00, 0x00, 0x01, 0x67]; // Minimal H.264 + + let encoded = encode_avc420_bitmap_stream(®ions, &h264_data); + + // Should have: 4 bytes (nRect=1) + 8 bytes (rectangle) + 2 bytes (quant) + 5 bytes (data) + assert_eq!(encoded.len(), 4 + 8 + 2 + 5); + + // Verify we can decode it back + let mut cursor = ReadCursor::new(&encoded); + let decoded = Avc420BitmapStream::decode(&mut cursor).expect("decode failed"); + + assert_eq!(decoded.rectangles.len(), 1); + assert_eq!(decoded.quant_qual_vals.len(), 1); + assert_eq!(decoded.data, &h264_data); + } +} diff --git a/crates/ironrdp-egfx/src/pdu/cmd.rs b/crates/ironrdp-egfx/src/pdu/cmd.rs new file mode 100644 index 0000000000..c61c09e660 --- /dev/null +++ b/crates/ironrdp-egfx/src/pdu/cmd.rs @@ -0,0 +1,2245 @@ +use core::{fmt, iter}; + +use bit_field::BitField as _; +use bitflags::bitflags; +use ironrdp_core::{ + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, invalid_field_err, +}; +use ironrdp_dvc::DvcEncode; +use ironrdp_pdu::gcc::Monitor; +use ironrdp_pdu::geometry::ExclusiveRectangle; +use ironrdp_pdu::{DecodeError, cast_length, ensure_size, read_padding, write_padding}; +use tracing::warn; + +use super::{Color, PixelFormat, Point}; + +const RDPGFX_CMDID_WIRETOSURFACE_1: u16 = 0x0001; +const RDPGFX_CMDID_WIRETOSURFACE_2: u16 = 0x0002; +const RDPGFX_CMDID_DELETEENCODINGCONTEXT: u16 = 0x0003; +const RDPGFX_CMDID_SOLIDFILL: u16 = 0x0004; +const RDPGFX_CMDID_SURFACETOSURFACE: u16 = 0x0005; +const RDPGFX_CMDID_SURFACETOCACHE: u16 = 0x0006; +const RDPGFX_CMDID_CACHETOSURFACE: u16 = 0x0007; +const RDPGFX_CMDID_EVICTCACHEENTRY: u16 = 0x0008; +const RDPGFX_CMDID_CREATESURFACE: u16 = 0x0009; +const RDPGFX_CMDID_DELETESURFACE: u16 = 0x000a; +const RDPGFX_CMDID_STARTFRAME: u16 = 0x000b; +const RDPGFX_CMDID_ENDFRAME: u16 = 0x000c; +const RDPGFX_CMDID_FRAMEACKNOWLEDGE: u16 = 0x000d; +const RDPGFX_CMDID_RESETGRAPHICS: u16 = 0x000e; +const RDPGFX_CMDID_MAPSURFACETOOUTPUT: u16 = 0x000f; +const RDPGFX_CMDID_CACHEIMPORTOFFER: u16 = 0x0010; +const RDPGFX_CMDID_CACHEIMPORTREPLY: u16 = 0x0011; +const RDPGFX_CMDID_CAPSADVERTISE: u16 = 0x0012; +const RDPGFX_CMDID_CAPSCONFIRM: u16 = 0x0013; +const RDPGFX_CMDID_MAPSURFACETOWINDOW: u16 = 0x0015; +const RDPGFX_CMDID_QOEFRAMEACKNOWLEDGE: u16 = 0x0016; +const RDPGFX_CMDID_MAPSURFACETOSCALEDOUTPUT: u16 = 0x0017; +const RDPGFX_CMDID_MAPSURFACETOSCALEDWINDOW: u16 = 0x0018; + +const MAX_RESET_GRAPHICS_WIDTH_HEIGHT: u32 = 32_766; +const MONITOR_COUNT_MAX: u32 = 16; +const RESET_GRAPHICS_PDU_SIZE: usize = 340 - GfxPdu::FIXED_PART_SIZE; + +/// Display Pipeline Virtual Channel message (PDU prefixed with `RDPGFX_HEADER`) +/// +/// INVARIANTS: size of encoded inner PDU is always less than `u32::MAX - Self::FIXED_PART_SIZE` +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum GfxPdu { + WireToSurface1(WireToSurface1Pdu), + WireToSurface2(WireToSurface2Pdu), + DeleteEncodingContext(DeleteEncodingContextPdu), + SolidFill(SolidFillPdu), + SurfaceToSurface(SurfaceToSurfacePdu), + SurfaceToCache(SurfaceToCachePdu), + CacheToSurface(CacheToSurfacePdu), + EvictCacheEntry(EvictCacheEntryPdu), + CreateSurface(CreateSurfacePdu), + DeleteSurface(DeleteSurfacePdu), + StartFrame(StartFramePdu), + EndFrame(EndFramePdu), + FrameAcknowledge(FrameAcknowledgePdu), + ResetGraphics(ResetGraphicsPdu), + MapSurfaceToOutput(MapSurfaceToOutputPdu), + CacheImportOffer(CacheImportOfferPdu), + CacheImportReply(CacheImportReplyPdu), + CapabilitiesAdvertise(CapabilitiesAdvertisePdu), + CapabilitiesConfirm(CapabilitiesConfirmPdu), + MapSurfaceToWindow(MapSurfaceToWindowPdu), + QoeFrameAcknowledge(QoeFrameAcknowledgePdu), + MapSurfaceToScaledOutput(MapSurfaceToScaledOutputPdu), + MapSurfaceToScaledWindow(MapSurfaceToScaledWindowPdu), +} + +/// 2.2.1.5 RDPGFX_HEADER +/// +/// [2.2.1.5]: +impl GfxPdu { + const NAME: &'static str = "RDPGFX_HEADER"; + + const FIXED_PART_SIZE: usize = 2 /* CmdId */ + 2 /* flags */ + 4 /* Length */; +} + +impl Encode for GfxPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + let (cmdid, payload_length) = match self { + GfxPdu::WireToSurface1(pdu) => (RDPGFX_CMDID_WIRETOSURFACE_1, pdu.size()), + GfxPdu::WireToSurface2(pdu) => (RDPGFX_CMDID_WIRETOSURFACE_2, pdu.size()), + GfxPdu::DeleteEncodingContext(pdu) => (RDPGFX_CMDID_DELETEENCODINGCONTEXT, pdu.size()), + GfxPdu::SolidFill(pdu) => (RDPGFX_CMDID_SOLIDFILL, pdu.size()), + GfxPdu::SurfaceToSurface(pdu) => (RDPGFX_CMDID_SURFACETOSURFACE, pdu.size()), + GfxPdu::SurfaceToCache(pdu) => (RDPGFX_CMDID_SURFACETOCACHE, pdu.size()), + GfxPdu::CacheToSurface(pdu) => (RDPGFX_CMDID_CACHETOSURFACE, pdu.size()), + GfxPdu::EvictCacheEntry(pdu) => (RDPGFX_CMDID_EVICTCACHEENTRY, pdu.size()), + GfxPdu::CreateSurface(pdu) => (RDPGFX_CMDID_CREATESURFACE, pdu.size()), + GfxPdu::DeleteSurface(pdu) => (RDPGFX_CMDID_DELETESURFACE, pdu.size()), + GfxPdu::StartFrame(pdu) => (RDPGFX_CMDID_STARTFRAME, pdu.size()), + GfxPdu::EndFrame(pdu) => (RDPGFX_CMDID_ENDFRAME, pdu.size()), + GfxPdu::FrameAcknowledge(pdu) => (RDPGFX_CMDID_FRAMEACKNOWLEDGE, pdu.size()), + GfxPdu::ResetGraphics(pdu) => (RDPGFX_CMDID_RESETGRAPHICS, pdu.size()), + GfxPdu::MapSurfaceToOutput(pdu) => (RDPGFX_CMDID_MAPSURFACETOOUTPUT, pdu.size()), + GfxPdu::CacheImportOffer(pdu) => (RDPGFX_CMDID_CACHEIMPORTOFFER, pdu.size()), + GfxPdu::CacheImportReply(pdu) => (RDPGFX_CMDID_CACHEIMPORTREPLY, pdu.size()), + GfxPdu::CapabilitiesAdvertise(pdu) => (RDPGFX_CMDID_CAPSADVERTISE, pdu.size()), + GfxPdu::CapabilitiesConfirm(pdu) => (RDPGFX_CMDID_CAPSCONFIRM, pdu.size()), + GfxPdu::MapSurfaceToWindow(pdu) => (RDPGFX_CMDID_MAPSURFACETOWINDOW, pdu.size()), + GfxPdu::QoeFrameAcknowledge(pdu) => (RDPGFX_CMDID_QOEFRAMEACKNOWLEDGE, pdu.size()), + GfxPdu::MapSurfaceToScaledOutput(pdu) => (RDPGFX_CMDID_MAPSURFACETOSCALEDOUTPUT, pdu.size()), + GfxPdu::MapSurfaceToScaledWindow(pdu) => (RDPGFX_CMDID_MAPSURFACETOSCALEDWINDOW, pdu.size()), + }; + + // This will never overflow as per invariants. + #[expect(clippy::arithmetic_side_effects, reason = "guaranteed by GfxPdu invariants")] + let pdu_size = payload_length + Self::FIXED_PART_SIZE; + + // Write `RDPGFX_HEADER` fields. + dst.write_u16(cmdid); + dst.write_u16(0); /* flags */ + #[expect(clippy::unwrap_used, reason = "pdu_size bounded by GfxPdu invariants")] + dst.write_u32(pdu_size.try_into().unwrap()); + + match self { + GfxPdu::WireToSurface1(pdu) => pdu.encode(dst), + GfxPdu::WireToSurface2(pdu) => pdu.encode(dst), + GfxPdu::DeleteEncodingContext(pdu) => pdu.encode(dst), + GfxPdu::SolidFill(pdu) => pdu.encode(dst), + GfxPdu::SurfaceToSurface(pdu) => pdu.encode(dst), + GfxPdu::SurfaceToCache(pdu) => pdu.encode(dst), + GfxPdu::CacheToSurface(pdu) => pdu.encode(dst), + GfxPdu::EvictCacheEntry(pdu) => pdu.encode(dst), + GfxPdu::CreateSurface(pdu) => pdu.encode(dst), + GfxPdu::DeleteSurface(pdu) => pdu.encode(dst), + GfxPdu::StartFrame(pdu) => pdu.encode(dst), + GfxPdu::EndFrame(pdu) => pdu.encode(dst), + GfxPdu::FrameAcknowledge(pdu) => pdu.encode(dst), + GfxPdu::ResetGraphics(pdu) => pdu.encode(dst), + GfxPdu::MapSurfaceToOutput(pdu) => pdu.encode(dst), + GfxPdu::CacheImportOffer(pdu) => pdu.encode(dst), + GfxPdu::CacheImportReply(pdu) => pdu.encode(dst), + GfxPdu::CapabilitiesAdvertise(pdu) => pdu.encode(dst), + GfxPdu::CapabilitiesConfirm(pdu) => pdu.encode(dst), + GfxPdu::MapSurfaceToWindow(pdu) => pdu.encode(dst), + GfxPdu::QoeFrameAcknowledge(pdu) => pdu.encode(dst), + GfxPdu::MapSurfaceToScaledOutput(pdu) => pdu.encode(dst), + GfxPdu::MapSurfaceToScaledWindow(pdu) => pdu.encode(dst), + }?; + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + // As per invariants: This will never overflow. + #[expect(clippy::arithmetic_side_effects, reason = "guaranteed by GfxPdu invariants")] + let size = Self::FIXED_PART_SIZE + + match self { + GfxPdu::WireToSurface1(pdu) => pdu.size(), + GfxPdu::WireToSurface2(pdu) => pdu.size(), + GfxPdu::DeleteEncodingContext(pdu) => pdu.size(), + GfxPdu::SolidFill(pdu) => pdu.size(), + GfxPdu::SurfaceToSurface(pdu) => pdu.size(), + GfxPdu::SurfaceToCache(pdu) => pdu.size(), + GfxPdu::CacheToSurface(pdu) => pdu.size(), + GfxPdu::EvictCacheEntry(pdu) => pdu.size(), + GfxPdu::CreateSurface(pdu) => pdu.size(), + GfxPdu::DeleteSurface(pdu) => pdu.size(), + GfxPdu::StartFrame(pdu) => pdu.size(), + GfxPdu::EndFrame(pdu) => pdu.size(), + GfxPdu::FrameAcknowledge(pdu) => pdu.size(), + GfxPdu::ResetGraphics(pdu) => pdu.size(), + GfxPdu::MapSurfaceToOutput(pdu) => pdu.size(), + GfxPdu::CacheImportOffer(pdu) => pdu.size(), + GfxPdu::CacheImportReply(pdu) => pdu.size(), + GfxPdu::CapabilitiesAdvertise(pdu) => pdu.size(), + GfxPdu::CapabilitiesConfirm(pdu) => pdu.size(), + GfxPdu::MapSurfaceToWindow(pdu) => pdu.size(), + GfxPdu::QoeFrameAcknowledge(pdu) => pdu.size(), + GfxPdu::MapSurfaceToScaledOutput(pdu) => pdu.size(), + GfxPdu::MapSurfaceToScaledWindow(pdu) => pdu.size(), + }; + + size + } +} + +impl DvcEncode for GfxPdu {} + +impl<'de> Decode<'de> for GfxPdu { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + // Read `RDPGFX_HEADER` fields. + let cmdid = src.read_u16(); + let flags = src.read_u16(); /* flags */ + if flags != 0 { + warn!(?flags, "invalid GFX flag"); + } + let pdu_length = src.read_u32(); + + #[expect(clippy::unwrap_used, reason = "FIXED_PART_SIZE is a small constant")] + let _payload_length = pdu_length + .checked_sub(Self::FIXED_PART_SIZE.try_into().unwrap()) + .ok_or_else(|| invalid_field_err!("Length", "GFX PDU length is too small"))?; + + match cmdid { + RDPGFX_CMDID_WIRETOSURFACE_1 => { + let pdu = WireToSurface1Pdu::decode(src)?; + Ok(GfxPdu::WireToSurface1(pdu)) + } + RDPGFX_CMDID_WIRETOSURFACE_2 => { + let pdu = WireToSurface2Pdu::decode(src)?; + Ok(GfxPdu::WireToSurface2(pdu)) + } + RDPGFX_CMDID_DELETEENCODINGCONTEXT => { + let pdu = DeleteEncodingContextPdu::decode(src)?; + Ok(GfxPdu::DeleteEncodingContext(pdu)) + } + RDPGFX_CMDID_SOLIDFILL => { + let pdu = SolidFillPdu::decode(src)?; + Ok(GfxPdu::SolidFill(pdu)) + } + RDPGFX_CMDID_SURFACETOSURFACE => { + let pdu = SurfaceToSurfacePdu::decode(src)?; + Ok(GfxPdu::SurfaceToSurface(pdu)) + } + RDPGFX_CMDID_SURFACETOCACHE => { + let pdu = SurfaceToCachePdu::decode(src)?; + Ok(GfxPdu::SurfaceToCache(pdu)) + } + RDPGFX_CMDID_CACHETOSURFACE => { + let pdu = CacheToSurfacePdu::decode(src)?; + Ok(GfxPdu::CacheToSurface(pdu)) + } + RDPGFX_CMDID_EVICTCACHEENTRY => { + let pdu = EvictCacheEntryPdu::decode(src)?; + Ok(GfxPdu::EvictCacheEntry(pdu)) + } + RDPGFX_CMDID_CREATESURFACE => { + let pdu = CreateSurfacePdu::decode(src)?; + Ok(GfxPdu::CreateSurface(pdu)) + } + RDPGFX_CMDID_DELETESURFACE => { + let pdu = DeleteSurfacePdu::decode(src)?; + Ok(GfxPdu::DeleteSurface(pdu)) + } + RDPGFX_CMDID_STARTFRAME => { + let pdu = StartFramePdu::decode(src)?; + Ok(GfxPdu::StartFrame(pdu)) + } + RDPGFX_CMDID_ENDFRAME => { + let pdu = EndFramePdu::decode(src)?; + Ok(GfxPdu::EndFrame(pdu)) + } + RDPGFX_CMDID_FRAMEACKNOWLEDGE => { + let pdu = FrameAcknowledgePdu::decode(src)?; + Ok(GfxPdu::FrameAcknowledge(pdu)) + } + RDPGFX_CMDID_RESETGRAPHICS => { + let pdu = ResetGraphicsPdu::decode(src)?; + Ok(GfxPdu::ResetGraphics(pdu)) + } + RDPGFX_CMDID_MAPSURFACETOOUTPUT => { + let pdu = MapSurfaceToOutputPdu::decode(src)?; + Ok(GfxPdu::MapSurfaceToOutput(pdu)) + } + RDPGFX_CMDID_CACHEIMPORTOFFER => { + let pdu = CacheImportOfferPdu::decode(src)?; + Ok(GfxPdu::CacheImportOffer(pdu)) + } + RDPGFX_CMDID_CACHEIMPORTREPLY => { + let pdu = CacheImportReplyPdu::decode(src)?; + Ok(GfxPdu::CacheImportReply(pdu)) + } + RDPGFX_CMDID_CAPSADVERTISE => { + let pdu = CapabilitiesAdvertisePdu::decode(src)?; + Ok(GfxPdu::CapabilitiesAdvertise(pdu)) + } + RDPGFX_CMDID_CAPSCONFIRM => { + let pdu = CapabilitiesConfirmPdu::decode(src)?; + Ok(GfxPdu::CapabilitiesConfirm(pdu)) + } + RDPGFX_CMDID_MAPSURFACETOWINDOW => { + let pdu = MapSurfaceToWindowPdu::decode(src)?; + Ok(GfxPdu::MapSurfaceToWindow(pdu)) + } + RDPGFX_CMDID_QOEFRAMEACKNOWLEDGE => { + let pdu = QoeFrameAcknowledgePdu::decode(src)?; + Ok(GfxPdu::QoeFrameAcknowledge(pdu)) + } + RDPGFX_CMDID_MAPSURFACETOSCALEDOUTPUT => { + let pdu = MapSurfaceToScaledOutputPdu::decode(src)?; + Ok(GfxPdu::MapSurfaceToScaledOutput(pdu)) + } + RDPGFX_CMDID_MAPSURFACETOSCALEDWINDOW => { + let pdu = MapSurfaceToScaledWindowPdu::decode(src)?; + Ok(GfxPdu::MapSurfaceToScaledWindow(pdu)) + } + _ => Err(invalid_field_err!("Type", "Unknown GFX PDU type")), + } + } +} + +/// 2.2.2.1 RDPGFX_WIRE_TO_SURFACE_PDU_1 +/// +/// `destination_rectangle` is an [`ExclusiveRectangle`] because MS-RDPEGFX +/// 2.2.1.4.1 RDPGFX_RECT16 specifies `right` and `bottom` as exclusive +/// (one-past-end), matching FreeRDP and the Windows reference clients. +/// +/// [2.2.2.1]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Clone, PartialEq, Eq)] +pub struct WireToSurface1Pdu { + pub surface_id: u16, + pub codec_id: Codec1Type, + pub pixel_format: PixelFormat, + pub destination_rectangle: ExclusiveRectangle, + pub bitmap_data: Vec, +} + +impl fmt::Debug for WireToSurface1Pdu { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WireToSurface1Pdu") + .field("surface_id", &self.surface_id) + .field("codec_id", &self.codec_id) + .field("pixel_format", &self.pixel_format) + .field("destination_rectangle", &self.destination_rectangle) + .field("bitmap_data_length", &self.bitmap_data.len()) + .finish() + } +} + +impl WireToSurface1Pdu { + const NAME: &'static str = "WireToSurface1Pdu"; + + const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 2 /* CodecId */ + 1 /* PixelFormat */ + ExclusiveRectangle::ENCODED_SIZE /* Dest */ + 4 /* BitmapDataLen */; +} + +impl Encode for WireToSurface1Pdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u16(self.surface_id); + dst.write_u16(self.codec_id.into()); + dst.write_u8(self.pixel_format.into()); + self.destination_rectangle.encode(dst)?; + dst.write_u32(cast_length!("BitmapDataLen", self.bitmap_data.len())?); + dst.write_slice(&self.bitmap_data); + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.bitmap_data.len() + } +} + +impl<'a> Decode<'a> for WireToSurface1Pdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let surface_id = src.read_u16(); + let codec_id = Codec1Type::try_from(src.read_u16())?; + let pixel_format = PixelFormat::try_from(src.read_u8())?; + let destination_rectangle = ExclusiveRectangle::decode(src)?; + let bitmap_data_length = cast_length!("BitmapDataLen", src.read_u32())?; + + ensure_size!(in: src, size: bitmap_data_length); + let bitmap_data = src.read_slice(bitmap_data_length).to_vec(); + + Ok(Self { + surface_id, + codec_id, + pixel_format, + destination_rectangle, + bitmap_data, + }) + } +} + +/// 2.2.2.2 RDPGFX_WIRE_TO_SURFACE_PDU_2 +/// +/// [2.2.2.2]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Clone, PartialEq, Eq)] +pub struct WireToSurface2Pdu { + pub surface_id: u16, + pub codec_id: Codec2Type, + pub codec_context_id: u32, + pub pixel_format: PixelFormat, + pub bitmap_data: Vec, +} + +impl fmt::Debug for WireToSurface2Pdu { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WireToSurface2Pdu") + .field("surface_id", &self.surface_id) + .field("codec_id", &self.codec_id) + .field("codec_context_id", &self.codec_context_id) + .field("pixel_format", &self.pixel_format) + .field("bitmap_data_length", &self.bitmap_data.len()) + .finish() + } +} + +impl WireToSurface2Pdu { + const NAME: &'static str = "WireToSurface2Pdu"; + + const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 2 /* CodecId */ + 4 /* ContextId */ + 1 /* PixelFormat */ + 4 /* BitmapDataLen */; +} + +impl Encode for WireToSurface2Pdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u16(self.surface_id); + dst.write_u16(self.codec_id.into()); + dst.write_u32(self.codec_context_id); + dst.write_u8(self.pixel_format.into()); + dst.write_u32(cast_length!("BitmapDataLen", self.bitmap_data.len())?); + dst.write_slice(&self.bitmap_data); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.bitmap_data.len() + } +} + +impl<'a> Decode<'a> for WireToSurface2Pdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let surface_id = src.read_u16(); + let codec_id = Codec2Type::try_from(src.read_u16())?; + let codec_context_id = src.read_u32(); + let pixel_format = PixelFormat::try_from(src.read_u8())?; + let bitmap_data_length = cast_length!("BitmapDataLen", src.read_u32())?; + + ensure_size!(in: src, size: bitmap_data_length); + let bitmap_data = src.read_slice(bitmap_data_length).to_vec(); + + Ok(Self { + surface_id, + codec_id, + codec_context_id, + pixel_format, + bitmap_data, + }) + } +} + +/// 2.2.2.3 RDPGFX_DELETE_ENCODING_CONTEXT_PDU +/// +/// [2.2.2.3]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteEncodingContextPdu { + pub surface_id: u16, + pub codec_context_id: u32, +} + +impl DeleteEncodingContextPdu { + const NAME: &'static str = "DeleteEncodingContextPdu"; + + const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 4 /* CodecContextId */; +} + +impl Encode for DeleteEncodingContextPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u16(self.surface_id); + dst.write_u32(self.codec_context_id); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> Decode<'a> for DeleteEncodingContextPdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let surface_id = src.read_u16(); + let codec_context_id = src.read_u32(); + + Ok(Self { + surface_id, + codec_context_id, + }) + } +} + +/// 2.2.2.4 RDPGFX_SOLID_FILL_PDU +/// +/// [2.2.2.4]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SolidFillPdu { + pub surface_id: u16, + pub fill_pixel: Color, + /// Filled regions; each is an exclusive `RDPGFX_RECT16` per + /// MS-RDPEGFX 2.2.1.4.1 (`right` / `bottom` are one-past-end). + pub rectangles: Vec, +} + +impl SolidFillPdu { + const NAME: &'static str = "SolidFillPdu"; + + const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + Color::FIXED_PART_SIZE /* Color */ + 2 /* RectCount */; +} + +impl Encode for SolidFillPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u16(self.surface_id); + self.fill_pixel.encode(dst)?; + dst.write_u16(cast_length!("nRect", self.rectangles.len())?); + + for rectangle in self.rectangles.iter() { + rectangle.encode(dst)?; + } + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.rectangles.iter().map(|r| r.size()).sum::() + } +} + +impl<'a> Decode<'a> for SolidFillPdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let surface_id = src.read_u16(); + let fill_pixel = Color::decode(src)?; + let rectangles_count = src.read_u16(); + + ensure_size!(in: src, size: usize::from(rectangles_count) * ExclusiveRectangle::ENCODED_SIZE); + let rectangles = iter::repeat_with(|| ExclusiveRectangle::decode(src)) + .take(usize::from(rectangles_count)) + .collect::>()?; + + Ok(Self { + surface_id, + fill_pixel, + rectangles, + }) + } +} + +/// 2.2.2.5 RDPGFX_SURFACE_TO_SURFACE_PDU +/// +/// [2.2.2.5]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SurfaceToSurfacePdu { + pub source_surface_id: u16, + pub destination_surface_id: u16, + /// Source region; an exclusive `RDPGFX_RECT16` per MS-RDPEGFX 2.2.1.4.1 + /// (`right` / `bottom` are one-past-end). + pub source_rectangle: ExclusiveRectangle, + pub destination_points: Vec, +} + +impl SurfaceToSurfacePdu { + const NAME: &'static str = "SurfaceToSurfacePdu"; + + const FIXED_PART_SIZE: usize = 2 /* SourceId */ + 2 /* DestId */ + ExclusiveRectangle::ENCODED_SIZE /* SourceRect */ + 2 /* DestPointsCount */; +} + +impl Encode for SurfaceToSurfacePdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u16(self.source_surface_id); + dst.write_u16(self.destination_surface_id); + self.source_rectangle.encode(dst)?; + + dst.write_u16(cast_length!("DestinationPoints", self.destination_points.len())?); + for rectangle in self.destination_points.iter() { + rectangle.encode(dst)?; + } + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.destination_points.iter().map(|r| r.size()).sum::() + } +} + +impl<'a> Decode<'a> for SurfaceToSurfacePdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let source_surface_id = src.read_u16(); + let destination_surface_id = src.read_u16(); + let source_rectangle = ExclusiveRectangle::decode(src)?; + let destination_points_count = src.read_u16(); + + let destination_points = iter::repeat_with(|| Point::decode(src)) + .take(usize::from(destination_points_count)) + .collect::>()?; + + Ok(Self { + source_surface_id, + destination_surface_id, + source_rectangle, + destination_points, + }) + } +} + +/// 2.2.2.6 RDPGFX_SURFACE_TO_CACHE_PDU +/// +/// [2.2.2.6]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SurfaceToCachePdu { + pub surface_id: u16, + pub cache_key: u64, + pub cache_slot: u16, + /// Source region; an exclusive `RDPGFX_RECT16` per MS-RDPEGFX 2.2.1.4.1 + /// (`right` / `bottom` are one-past-end). + pub source_rectangle: ExclusiveRectangle, +} + +impl SurfaceToCachePdu { + const NAME: &'static str = "SurfaceToCachePdu"; + + const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 8 /* CacheKey */ + 2 /* CacheSlot */ + ExclusiveRectangle::ENCODED_SIZE /* SourceRect */; +} + +impl Encode for SurfaceToCachePdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u16(self.surface_id); + dst.write_u64(self.cache_key); + dst.write_u16(self.cache_slot); + self.source_rectangle.encode(dst)?; + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> Decode<'a> for SurfaceToCachePdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let surface_id = src.read_u16(); + let cache_key = src.read_u64(); + let cache_slot = src.read_u16(); + let source_rectangle = ExclusiveRectangle::decode(src)?; + + Ok(Self { + surface_id, + cache_key, + cache_slot, + source_rectangle, + }) + } +} + +/// 2.2.2.7 RDPGFX_CACHE_TO_SURFACE_PDU +/// +/// [2.2.2.7]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CacheToSurfacePdu { + pub cache_slot: u16, + pub surface_id: u16, + pub destination_points: Vec, +} + +impl CacheToSurfacePdu { + const NAME: &'static str = "CacheToSurfacePdu"; + + const FIXED_PART_SIZE: usize = 2 /* cache_slot */ + 2 /* surface_id */ + 2 /* npoints */; +} + +impl Encode for CacheToSurfacePdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u16(self.cache_slot); + dst.write_u16(self.surface_id); + dst.write_u16(cast_length!("npoints", self.destination_points.len())?); + for point in self.destination_points.iter() { + point.encode(dst)?; + } + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.destination_points.iter().map(|p| p.size()).sum::() + } +} + +impl<'de> Decode<'de> for CacheToSurfacePdu { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let cache_slot = src.read_u16(); + let surface_id = src.read_u16(); + let destination_points_count = src.read_u16(); + + let destination_points = iter::repeat_with(|| Point::decode(src)) + .take(usize::from(destination_points_count)) + .collect::>()?; + + Ok(Self { + cache_slot, + surface_id, + destination_points, + }) + } +} + +/// 2.2.2.8 RDPGFX_EVICT_CACHE_ENTRY_PDU +/// +/// [2.2.2.8]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EvictCacheEntryPdu { + pub cache_slot: u16, +} + +impl EvictCacheEntryPdu { + const NAME: &'static str = "EvictCacheEntryPdu"; + + const FIXED_PART_SIZE: usize = 2; +} + +impl Encode for EvictCacheEntryPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u16(self.cache_slot); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> Decode<'a> for EvictCacheEntryPdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let cache_slot = src.read_u16(); + + Ok(Self { cache_slot }) + } +} + +/// 2.2.2.9 RDPGFX_CREATE_SURFACE_PDU +/// +/// [2.2.2.9]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateSurfacePdu { + pub surface_id: u16, + pub width: u16, + pub height: u16, + pub pixel_format: PixelFormat, +} + +impl CreateSurfacePdu { + const NAME: &'static str = "CreateSurfacePdu"; + + const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 2 /* Width */ + 2 /* Height */ + 1 /* PixelFormat */; +} + +impl Encode for CreateSurfacePdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u16(self.surface_id); + dst.write_u16(self.width); + dst.write_u16(self.height); + dst.write_u8(self.pixel_format.into()); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> Decode<'a> for CreateSurfacePdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let surface_id = src.read_u16(); + let width = src.read_u16(); + let height = src.read_u16(); + let pixel_format = PixelFormat::try_from(src.read_u8())?; + + Ok(Self { + surface_id, + width, + height, + pixel_format, + }) + } +} + +/// 2.2.2.10 RDPGFX_DELETE_SURFACE_PDU +/// +/// [2.2.2.10]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeleteSurfacePdu { + pub surface_id: u16, +} + +impl DeleteSurfacePdu { + const NAME: &'static str = "DeleteSurfacePdu"; + + const FIXED_PART_SIZE: usize = 2 /* SurfaceId */; +} + +impl Encode for DeleteSurfacePdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u16(self.surface_id); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> Decode<'a> for DeleteSurfacePdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let surface_id = src.read_u16(); + + Ok(Self { surface_id }) + } +} + +/// 2.2.2.11 RDPGFX_START_FRAME_PDU +/// +/// [2.2.2.11]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StartFramePdu { + pub timestamp: Timestamp, + pub frame_id: u32, +} + +impl StartFramePdu { + const NAME: &'static str = "StartFramePdu"; + + const FIXED_PART_SIZE: usize = Timestamp::FIXED_PART_SIZE + 4 /* FrameId */; +} + +impl Encode for StartFramePdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + self.timestamp.encode(dst)?; + dst.write_u32(self.frame_id); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> Decode<'a> for StartFramePdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let timestamp = Timestamp::decode(src)?; + let frame_id = src.read_u32(); + + Ok(Self { timestamp, frame_id }) + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct Timestamp { + pub milliseconds: u16, + pub seconds: u8, + pub minutes: u8, + pub hours: u16, +} + +// Manual `Arbitrary` impl: the encoder packs the four fields into a single u32 via +// `set_bits` (milliseconds: 10 bits, seconds: 6 bits, minutes: 6 bits, hours: 10 bits). +// `derive(Arbitrary)` would generate the full `u8` / `u16` range, but `set_bits` panics +// when the value exceeds the requested bit width. Mask each field to its wire-allowed +// range so fuzz inputs always round-trip through `Encode`. +#[cfg(feature = "arbitrary")] +impl<'a> arbitrary::Arbitrary<'a> for Timestamp { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + Ok(Self { + milliseconds: u.arbitrary::()? & 0x03FF, // 10 bits + seconds: u.arbitrary::()? & 0x3F, // 6 bits + minutes: u.arbitrary::()? & 0x3F, // 6 bits + hours: u.arbitrary::()? & 0x03FF, // 10 bits + }) + } +} + +impl Timestamp { + const NAME: &'static str = "GfxTimestamp"; + + const FIXED_PART_SIZE: usize = 4; +} + +impl Encode for Timestamp { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + let mut timestamp: u32 = 0; + + timestamp.set_bits(..10, u32::from(self.milliseconds)); + timestamp.set_bits(10..16, u32::from(self.seconds)); + timestamp.set_bits(16..22, u32::from(self.minutes)); + timestamp.set_bits(22.., u32::from(self.hours)); + + dst.write_u32(timestamp); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> Decode<'a> for Timestamp { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let timestamp = src.read_u32(); + + // All these bit extractions are bounded by the bit ranges specified, + // so the conversions will never fail + #[expect(clippy::unwrap_used, reason = "bit field extraction bounded by range")] + let milliseconds = timestamp.get_bits(..10).try_into().unwrap(); + #[expect(clippy::unwrap_used, reason = "bit field extraction bounded by range")] + let seconds = timestamp.get_bits(10..16).try_into().unwrap(); + #[expect(clippy::unwrap_used, reason = "bit field extraction bounded by range")] + let minutes = timestamp.get_bits(16..22).try_into().unwrap(); + #[expect(clippy::unwrap_used, reason = "bit field extraction bounded by range")] + let hours = timestamp.get_bits(22..).try_into().unwrap(); + + Ok(Self { + milliseconds, + seconds, + minutes, + hours, + }) + } +} + +/// 2.2.2.12 RDPGFX_END_FRAME_PDU +/// +/// [2.2.2.12]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EndFramePdu { + pub frame_id: u32, +} + +impl EndFramePdu { + const NAME: &'static str = "EndFramePdu"; + + const FIXED_PART_SIZE: usize = 4; +} + +impl Encode for EndFramePdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u32(self.frame_id); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> Decode<'a> for EndFramePdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let frame_id = src.read_u32(); + + Ok(Self { frame_id }) + } +} + +/// 2.2.2.13 RDPGFX_FRAME_ACKNOWLEDGE_PDU +/// +/// [2.2.2.13]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FrameAcknowledgePdu { + pub queue_depth: QueueDepth, + pub frame_id: u32, + pub total_frames_decoded: u32, +} + +impl FrameAcknowledgePdu { + const NAME: &'static str = "FrameAcknowledgePdu"; + + const FIXED_PART_SIZE: usize = 4 /* QueueDepth */ + 4 /* FrameId */ + 4 /* TotalFramesDecoded */; +} + +impl Encode for FrameAcknowledgePdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u32(self.queue_depth.to_u32()); + dst.write_u32(self.frame_id); + dst.write_u32(self.total_frames_decoded); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> Decode<'a> for FrameAcknowledgePdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let queue_depth = QueueDepth::from_u32(src.read_u32()); + let frame_id = src.read_u32(); + let total_frames_decoded = src.read_u32(); + + Ok(Self { + queue_depth, + frame_id, + total_frames_decoded, + }) + } +} + +#[repr(u32)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum QueueDepth { + Unavailable, + AvailableBytes(u32), + Suspend, +} + +impl QueueDepth { + pub fn from_u32(v: u32) -> Self { + match v { + 0x0000_0000 => Self::Unavailable, + 0x0000_0001..=0xFFFF_FFFE => Self::AvailableBytes(v), + 0xFFFF_FFFF => Self::Suspend, + } + } + + pub fn to_u32(self) -> u32 { + match self { + Self::Unavailable => 0x0000_0000, + Self::AvailableBytes(v) => v, + Self::Suspend => 0xFFFF_FFFF, + } + } +} + +/// 2.2.2.14 RDPGFX_RESET_GRAPHICS_PDU +/// +/// [2.2.2.14]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResetGraphicsPdu { + pub width: u32, + pub height: u32, + pub monitors: Vec, +} + +impl ResetGraphicsPdu { + const NAME: &'static str = "ResetGraphicsPdu"; + + const FIXED_PART_SIZE: usize = 4 /* Width */ + 4 /* Height */ + 4 /* nMonitors */; + + fn padding_size(&self) -> usize { + RESET_GRAPHICS_PDU_SIZE - Self::FIXED_PART_SIZE - self.monitors.iter().map(|m| m.size()).sum::() + } +} + +impl Encode for ResetGraphicsPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u32(self.width); + dst.write_u32(self.height); + dst.write_u32(cast_length!("nMonitors", self.monitors.len())?); + + for monitor in self.monitors.iter() { + monitor.encode(dst)?; + } + + write_padding!(dst, self.padding_size()); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.monitors.iter().map(|m| m.size()).sum::() + self.padding_size() + } +} + +impl<'a> Decode<'a> for ResetGraphicsPdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let width = src.read_u32(); + if width > MAX_RESET_GRAPHICS_WIDTH_HEIGHT { + return Err(invalid_field_err!("width", "invalid reset graphics width")); + } + + let height = src.read_u32(); + if height > MAX_RESET_GRAPHICS_WIDTH_HEIGHT { + return Err(invalid_field_err!("height", "invalid reset graphics height")); + } + + let monitor_count = src.read_u32(); + if monitor_count > MONITOR_COUNT_MAX { + return Err(invalid_field_err!( + "monitor_count", + "invalid reset graphics monitor count" + )); + } + + #[expect(clippy::as_conversions, reason = "monitor_count validated above")] + let monitors = iter::repeat_with(|| Monitor::decode(src)) + .take(monitor_count as usize) + .collect::, _>>()?; + + let pdu = Self { + width, + height, + monitors, + }; + + read_padding!(src, pdu.padding_size()); + + Ok(pdu) + } +} + +/// 2.2.2.15 RDPGFX_MAP_SURFACE_TO_OUTPUT_PDU +/// +/// [2.2.2.15]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MapSurfaceToOutputPdu { + pub surface_id: u16, + pub output_origin_x: u32, + pub output_origin_y: u32, +} + +impl MapSurfaceToOutputPdu { + const NAME: &'static str = "MapSurfaceToOutputPdu"; + + const FIXED_PART_SIZE: usize = 2 /* surfaceId */ + 2 /* reserved */ + 4 /* OutOriginX */ + 4 /* OutOriginY */; +} + +impl Encode for MapSurfaceToOutputPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u16(self.surface_id); + dst.write_u16(0); // reserved + dst.write_u32(self.output_origin_x); + dst.write_u32(self.output_origin_y); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> Decode<'a> for MapSurfaceToOutputPdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let surface_id = src.read_u16(); + let _reserved = src.read_u16(); + let output_origin_x = src.read_u32(); + let output_origin_y = src.read_u32(); + + Ok(Self { + surface_id, + output_origin_x, + output_origin_y, + }) + } +} + +/// 2.2.2.16 RDPGFX_CACHE_IMPORT_OFFER_PDU +/// +/// [2.2.2.16]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CacheImportOfferPdu { + pub cache_entries: Vec, +} + +impl CacheImportOfferPdu { + const NAME: &'static str = "CacheImportOfferPdu"; + + const FIXED_PART_SIZE: usize = 2 /* Count */; +} + +impl Encode for CacheImportOfferPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u16(cast_length!("Count", self.cache_entries.len())?); + + for e in self.cache_entries.iter() { + e.encode(dst)?; + } + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.cache_entries.iter().map(|e| e.size()).sum::() + } +} + +impl<'a> Decode<'a> for CacheImportOfferPdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let entries_count = src.read_u16(); + + let cache_entries = iter::repeat_with(|| CacheEntryMetadata::decode(src)) + .take(usize::from(entries_count)) + .collect::, _>>()?; + + Ok(Self { cache_entries }) + } +} + +/// 2.2.2.17 RDPGFX_CACHE_IMPORT_REPLY_PDU +/// +/// [2.2.2.17]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CacheImportReplyPdu { + pub cache_slots: Vec, +} + +impl CacheImportReplyPdu { + const NAME: &'static str = "CacheImportReplyPdu"; + + const FIXED_PART_SIZE: usize = 2 /* Count */; +} + +impl Encode for CacheImportReplyPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u16(cast_length!("Count", self.cache_slots.len())?); + + for cache_slot in self.cache_slots.iter() { + dst.write_u16(*cache_slot); + } + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.cache_slots.iter().map(|_| 2).sum::() + } +} + +impl<'a> Decode<'a> for CacheImportReplyPdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let entries_count = src.read_u16(); + ensure_size!(in: src, size: 2 * usize::from(entries_count)); + + let cache_slots = iter::repeat_with(|| src.read_u16()) + .take(usize::from(entries_count)) + .collect(); + + Ok(Self { cache_slots }) + } +} + +/// 2.2.2.16.1 RDPGFX_CACHE_ENTRY_METADATA +/// +/// [2.2.2.16.1]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CacheEntryMetadata { + pub cache_key: u64, + pub bitmap_len: u32, +} + +impl CacheEntryMetadata { + const NAME: &'static str = "CacheEntryMetadata"; + + const FIXED_PART_SIZE: usize = 8 /* cache_key */ + 4 /* bitmap_len */; +} + +impl Encode for CacheEntryMetadata { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u64(self.cache_key); + dst.write_u32(self.bitmap_len); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> Decode<'a> for CacheEntryMetadata { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let cache_key = src.read_u64(); + let bitmap_len = src.read_u32(); + + Ok(Self { cache_key, bitmap_len }) + } +} + +/// 2.2.2.18 RDPGFX_CAPS_ADVERTISE_PDU +/// +/// [2.2.2.18]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilitiesAdvertisePdu(pub Vec); + +impl CapabilitiesAdvertisePdu { + const NAME: &'static str = "CapabilitiesAdvertisePdu"; + + const FIXED_PART_SIZE: usize = 2 /* Count */; + + /// Build the PDU from a list of typed capability sets. + pub fn from_typed(caps: &[CapabilitySet]) -> Self { + Self(caps.iter().map(RawCapabilitySet::from).collect()) + } +} + +impl Encode for CapabilitiesAdvertisePdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u16(cast_length!("Count", self.0.len())?); + + for capability_set in self.0.iter() { + capability_set.encode(dst)?; + } + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.0.iter().map(|c| c.size()).sum::() + } +} + +impl<'a> Decode<'a> for CapabilitiesAdvertisePdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let capabilities_count = cast_length!("Count", src.read_u16())?; + + ensure_size!(in: src, size: capabilities_count * RawCapabilitySet::FIXED_PART_SIZE); + + let capabilities = iter::repeat_with(|| RawCapabilitySet::decode(src)) + .take(capabilities_count) + .collect::>()?; + + Ok(Self(capabilities)) + } +} + +/// 2.2.2.19 RDPGFX_CAPS_CONFIRM_PDU +/// +/// [2.2.2.19]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilitiesConfirmPdu(pub RawCapabilitySet); + +impl CapabilitiesConfirmPdu { + const NAME: &'static str = "CapabilitiesConfirmPdu"; + + const FIXED_PART_SIZE: usize = 0; + + /// Build the PDU from a typed capability set. + pub fn from_typed(cap: &CapabilitySet) -> Self { + Self(RawCapabilitySet::from(cap)) + } +} + +impl Encode for CapabilitiesConfirmPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + self.0.encode(dst)?; + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + self.0.size() + } +} + +impl<'a> Decode<'a> for CapabilitiesConfirmPdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let cap = RawCapabilitySet::decode(src)?; + + Ok(Self(cap)) + } +} + +/// 2.2.1.6 RDPGFX_CAPSET — lossless wire-level representation. +/// +/// Stores the original `version` alongside the raw body bytes (`data`). This +/// is what [`CapabilitiesAdvertisePdu`] and [`CapabilitiesConfirmPdu`] hold on +/// the wire, ensuring that `m == encode(decode(m))` even when this build does +/// not recognize the advertised version. +/// +/// Use [`Self::parsed`] to obtain a typed [`CapabilitySet`] when the version +/// is known to this build. +/// +/// [2.2.1.6]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RawCapabilitySet { + pub version: CapabilityVersion, + pub data: Vec, +} + +impl RawCapabilitySet { + const NAME: &'static str = "GfxCapabilitySet"; + + const FIXED_PART_SIZE: usize = 4 /* version */ + 4 /* capsDataLength */; + + pub fn new(version: CapabilityVersion, data: Vec) -> Self { + Self { version, data } + } + + /// Parse the body into a typed [`CapabilitySet`] when the version is one + /// this build recognizes. Returns `Ok(None)` for unknown versions, leaving + /// the raw bytes available via [`Self::data`]. + pub fn parsed(&self) -> DecodeResult> { + let mut cur = ReadCursor::new(&self.data); + let cap = match self.version { + CapabilityVersion::V8 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V8 { + flags: CapabilitiesV8Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V8_1 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V8_1 { + flags: CapabilitiesV81Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10 { + flags: CapabilitiesV10Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10_1 => { + ensure_size!(in: cur, size: 16); + cur.read_u128(); + CapabilitySet::V10_1 + } + CapabilityVersion::V10_2 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10_2 { + flags: CapabilitiesV10Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10_3 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10_3 { + flags: CapabilitiesV103Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10_4 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10_4 { + flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10_5 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10_5 { + flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10_6 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10_6 { + flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10_6_ERR => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10_6Err { + flags: CapabilitiesV104Flags::from_bits_retain(cur.read_u32()), + } + } + CapabilityVersion::V10_7 => { + ensure_size!(in: cur, size: 4); + CapabilitySet::V10_7 { + flags: CapabilitiesV107Flags::from_bits_retain(cur.read_u32()), + } + } + _ => return Ok(None), + }; + + Ok(Some(cap)) + } +} + +impl Encode for RawCapabilitySet { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u32(self.version.into()); + dst.write_u32(cast_length!("dataLength", self.data.len())?); + dst.write_slice(&self.data); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.data.len() + } +} + +impl<'de> Decode<'de> for RawCapabilitySet { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let version = CapabilityVersion(src.read_u32()); + let data_length: usize = cast_length!("dataLength", src.read_u32())?; + + ensure_size!(in: src, size: data_length); + let data = src.read_slice(data_length).to_vec(); + + // Tolerate capability versions this build doesn't recognize instead of + // failing the whole PDU. A strict error here would abort decoding of + // the entire CapabilitiesAdvertise during EGFX negotiation, which can + // prevent a connection from being established at all when a client + // advertises a capset version outside the set enumerated in + // `CapabilityVersion`. Preserving the raw bytes lets + // negotiation complete so the server can still select a mutually + // supported version. Use `RawCapabilitySet::parsed` to obtain a typed + // view when needed. + Ok(Self { version, data }) + } +} + +/// 2.2.1.6 RDPGFX_CAPSET — typed view of a [`RawCapabilitySet`] body. +/// +/// Holds only versions this build knows how to interpret. Obtained from +/// [`RawCapabilitySet::parsed`], which returns `None` for unknown versions. +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CapabilitySet { + V8 { flags: CapabilitiesV8Flags }, + V8_1 { flags: CapabilitiesV81Flags }, + V10 { flags: CapabilitiesV10Flags }, + V10_1, + V10_2 { flags: CapabilitiesV10Flags }, + V10_3 { flags: CapabilitiesV103Flags }, + V10_4 { flags: CapabilitiesV104Flags }, + V10_5 { flags: CapabilitiesV104Flags }, + V10_6 { flags: CapabilitiesV104Flags }, + V10_6Err { flags: CapabilitiesV104Flags }, + V10_7 { flags: CapabilitiesV107Flags }, +} + +impl CapabilitySet { + /// Wire `version` field corresponding to this typed variant. + pub fn version(&self) -> CapabilityVersion { + match self { + CapabilitySet::V8 { .. } => CapabilityVersion::V8, + CapabilitySet::V8_1 { .. } => CapabilityVersion::V8_1, + CapabilitySet::V10 { .. } => CapabilityVersion::V10, + CapabilitySet::V10_1 => CapabilityVersion::V10_1, + CapabilitySet::V10_2 { .. } => CapabilityVersion::V10_2, + CapabilitySet::V10_3 { .. } => CapabilityVersion::V10_3, + CapabilitySet::V10_4 { .. } => CapabilityVersion::V10_4, + CapabilitySet::V10_5 { .. } => CapabilityVersion::V10_5, + CapabilitySet::V10_6 { .. } => CapabilityVersion::V10_6, + CapabilitySet::V10_6Err { .. } => CapabilityVersion::V10_6_ERR, + CapabilitySet::V10_7 { .. } => CapabilityVersion::V10_7, + } + } + + /// Size of the body bytes (no version/length header). + fn body_size(&self) -> usize { + match self { + CapabilitySet::V10_1 => 16, + CapabilitySet::V8 { .. } + | CapabilitySet::V8_1 { .. } + | CapabilitySet::V10 { .. } + | CapabilitySet::V10_2 { .. } + | CapabilitySet::V10_3 { .. } + | CapabilitySet::V10_4 { .. } + | CapabilitySet::V10_5 { .. } + | CapabilitySet::V10_6 { .. } + | CapabilitySet::V10_6Err { .. } + | CapabilitySet::V10_7 { .. } => 4, + } + } + + /// Serialize just the body bytes (no version/length header). + fn write_body(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.body_size()); + match self { + CapabilitySet::V8 { flags } => dst.write_u32(flags.bits()), + CapabilitySet::V8_1 { flags } => dst.write_u32(flags.bits()), + CapabilitySet::V10 { flags } => dst.write_u32(flags.bits()), + CapabilitySet::V10_1 => dst.write_u128(0), + CapabilitySet::V10_2 { flags } => dst.write_u32(flags.bits()), + CapabilitySet::V10_3 { flags } => dst.write_u32(flags.bits()), + CapabilitySet::V10_4 { flags } => dst.write_u32(flags.bits()), + CapabilitySet::V10_5 { flags } => dst.write_u32(flags.bits()), + CapabilitySet::V10_6 { flags } => dst.write_u32(flags.bits()), + CapabilitySet::V10_6Err { flags } => dst.write_u32(flags.bits()), + CapabilitySet::V10_7 { flags } => dst.write_u32(flags.bits()), + } + Ok(()) + } +} + +impl From<&CapabilitySet> for RawCapabilitySet { + fn from(cap: &CapabilitySet) -> Self { + let mut data = vec![0u8; cap.body_size()]; + let mut cur = WriteCursor::new(&mut data); + cap.write_body(&mut cur) + .expect("buffer is sized to body_size; write cannot fail"); + Self { + version: cap.version(), + data, + } + } +} + +/// Capability set version, as advertised in 2.2.1.6 RDPGFX_CAPSET. +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct CapabilityVersion(pub u32); + +impl CapabilityVersion { + pub const V8: Self = Self(0x8_0004); + pub const V8_1: Self = Self(0x8_0105); + pub const V10: Self = Self(0xa_0002); + pub const V10_1: Self = Self(0xa_0100); + pub const V10_2: Self = Self(0xa_0200); + pub const V10_3: Self = Self(0xa_0301); + pub const V10_4: Self = Self(0xa_0400); + pub const V10_5: Self = Self(0xa_0502); + pub const V10_6: Self = Self(0xa_0600); // [MS-RDPEGFX-errata] + pub const V10_6_ERR: Self = Self(0xa_0601); // defined similar to FreeRDP to maintain best compatibility + pub const V10_7: Self = Self(0xa_0701); + + /// Returns `true` if this version matches one of the constants defined on + /// `CapabilityVersion`, i.e. one this build knows how to decode into a + /// dedicated `CapabilitySet` variant. + #[must_use] + pub fn is_known(self) -> bool { + matches!( + self, + Self::V8 + | Self::V8_1 + | Self::V10 + | Self::V10_1 + | Self::V10_2 + | Self::V10_3 + | Self::V10_4 + | Self::V10_5 + | Self::V10_6 + | Self::V10_6_ERR + | Self::V10_7 + ) + } +} + +impl From for u32 { + fn from(value: CapabilityVersion) -> Self { + value.0 + } +} + +bitflags! { + /// 2.2.3.1 RDPGFX_CAPSET_VERSION8 + /// + /// [2.2.3.1] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/027dd8eb-a066-42e8-ad65-2e0314c4dce5 + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct CapabilitiesV8Flags: u32 { + const THIN_CLIENT = 0x1; + const SMALL_CACHE = 0x2; + + const _ = !0; + } +} + +bitflags! { + /// 2.2.3.2 RDPGFX_CAPSET_VERSION81 + /// + /// [2.2.3.2] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/487e57cc-cd16-44c4-add8-60b84bf6d9e4 + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct CapabilitiesV81Flags: u32 { + const THIN_CLIENT = 0x01; + const SMALL_CACHE = 0x02; + const AVC420_ENABLED = 0x10; + + const _ = !0; + } +} + +bitflags! { + /// 2.2.3.3 RDPGFX_CAPSET_VERSION10 + /// + /// [2.2.3.3] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/d1899912-2b84-4e0d-9e6d-da0fd25d14bc + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct CapabilitiesV10Flags: u32 { + const SMALL_CACHE = 0x02; + const AVC_DISABLED = 0x20; + + const _ = !0; + } +} + +// 2.2.3.4 RDPGFX_CAPSET_VERSION101 +// +// [2.2.3.4] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/5985e67e-4080-49a7-85e3-eb3ba0653ff6 +// reserved + +// 2.2.3.5 RDPGFX_CAPSET_VERSION102 +// +// [2.2.3.5] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/a73e87d5-10c3-4d3f-b00c-fd5579570a0b +//same as v10 + +bitflags! { + /// 2.2.3.6 RDPGFX_CAPSET_VERSION103 + /// + /// [2.2.3.6] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/a73e87d5-10c3-4d3f-b00c-fd5579570a0b + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct CapabilitiesV103Flags: u32 { + const AVC_DISABLED = 0x20; + const AVC_THIN_CLIENT = 0x40; + + const _ = !0; + } +} + +bitflags! { + /// 2.2.3.7 RDPGFX_CAPSET_VERSION104 + /// + /// [2.2.3.7] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/be5ea8da-44db-478d-b55c-d42d82f11d26 + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct CapabilitiesV104Flags: u32 { + const SMALL_CACHE = 0x02; + const AVC_DISABLED = 0x20; + const AVC_THIN_CLIENT = 0x40; + + const _ = !0; + } +} + +// 2.2.3.8 RDPGFX_CAPSET_VERSION105 +// +// [2.2.3.8] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/8fc20f1e-e63e-4b13-a546-22fba213ad83 +// same as v104 + +// 2.2.3.9 RDPGFX_CAPSET_VERSION106 +// +// [2.2.3.9] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/8d489900-e903-4778-bb83-691c5ab719d5 +// same as v104 + +bitflags! { + /// 2.2.3.10 RDPGFX_CAPSET_VERSION107 + /// + /// [2.2.3.10] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/ba94595b-04de-4fbd-8ee4-89d8ff8f5cf1 + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct CapabilitiesV107Flags: u32 { + const SMALL_CACHE = 0x02; + const AVC_DISABLED = 0x20; + const AVC_THIN_CLIENT = 0x40; + const SCALEDMAP_DISABLE = 0x80; + + const _ = !0; + } +} + +/// 2.2.2.20 RDPGFX_MAP_SURFACE_TO_WINDOW_PDU +/// +/// [2.2.2.20]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MapSurfaceToWindowPdu { + pub surface_id: u16, + pub window_id: u64, + pub mapped_width: u32, + pub mapped_height: u32, +} + +impl MapSurfaceToWindowPdu { + const NAME: &'static str = "MapSurfaceToWindowPdu"; + + const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 8 /* WindowId */ + 4 /* MappedWidth */ + 4 /* MappedHeight */; +} + +impl Encode for MapSurfaceToWindowPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u16(self.surface_id); + dst.write_u64(self.window_id); + dst.write_u32(self.mapped_width); + dst.write_u32(self.mapped_height); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> Decode<'a> for MapSurfaceToWindowPdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let surface_id = src.read_u16(); + let window_id = src.read_u64(); + let mapped_width = src.read_u32(); + let mapped_height = src.read_u32(); + + Ok(Self { + surface_id, + window_id, + mapped_width, + mapped_height, + }) + } +} + +/// 2.2.2.21 RDPGFX_QOE_FRAME_ACKNOWLEDGE_PDU +/// +/// [2.2.2.21]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QoeFrameAcknowledgePdu { + pub frame_id: u32, + pub timestamp: u32, + pub time_diff_se: u16, + pub time_diff_dr: u16, +} + +impl QoeFrameAcknowledgePdu { + const NAME: &'static str = "QoeFrameAcknowledgePdu"; + + const FIXED_PART_SIZE: usize = 4 /* FrameId */ + 4 /* timestamp */ + 2 /* diffSE */ + 2 /* diffDR */; +} + +impl Encode for QoeFrameAcknowledgePdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u32(self.frame_id); + dst.write_u32(self.timestamp); + dst.write_u16(self.time_diff_se); + dst.write_u16(self.time_diff_dr); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> Decode<'a> for QoeFrameAcknowledgePdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let frame_id = src.read_u32(); + let timestamp = src.read_u32(); + let time_diff_se = src.read_u16(); + let time_diff_dr = src.read_u16(); + + Ok(Self { + frame_id, + timestamp, + time_diff_se, + time_diff_dr, + }) + } +} + +/// 2.2.2.22 RDPGFX_MAP_SURFACE_TO_SCALED_OUTPUT_PDU +/// +/// [2.2.2.22]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MapSurfaceToScaledOutputPdu { + pub surface_id: u16, + pub output_origin_x: u32, + pub output_origin_y: u32, + pub target_width: u32, + pub target_height: u32, +} + +impl MapSurfaceToScaledOutputPdu { + const NAME: &'static str = "MapSurfaceToScaledOutputPdu"; + + const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 2 /* reserved */ + 4 /* oox */ + 4 /* ooy */ + 4 /* targetWidth */ + 4 /* targetHeight */; +} + +impl Encode for MapSurfaceToScaledOutputPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u16(self.surface_id); + dst.write_u16(0); // reserved + dst.write_u32(self.output_origin_x); + dst.write_u32(self.output_origin_y); + dst.write_u32(self.target_width); + dst.write_u32(self.target_height); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> Decode<'a> for MapSurfaceToScaledOutputPdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let surface_id = src.read_u16(); + let _reserved = src.read_u16(); + let output_origin_x = src.read_u32(); + let output_origin_y = src.read_u32(); + let target_width = src.read_u32(); + let target_height = src.read_u32(); + + Ok(Self { + surface_id, + output_origin_x, + output_origin_y, + target_width, + target_height, + }) + } +} + +/// 2.2.2.23 RDPGFX_MAP_SURFACE_TO_SCALED_WINDOW_PDU +/// +/// [2.2.2.23] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MapSurfaceToScaledWindowPdu { + pub surface_id: u16, + pub window_id: u64, + pub mapped_width: u32, + pub mapped_height: u32, + pub target_width: u32, + pub target_height: u32, +} + +impl MapSurfaceToScaledWindowPdu { + const NAME: &'static str = "MapSurfaceToScaledWindowPdu"; + + const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 8 /* WindowId */ + 4 /* MappedWidth */ + 4 /* MappedHeight */ + 4 /* TargetWidth */ + 4 /* TargetHeight */; +} + +impl Encode for MapSurfaceToScaledWindowPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u16(self.surface_id); + dst.write_u64(self.window_id); + dst.write_u32(self.mapped_width); + dst.write_u32(self.mapped_height); + dst.write_u32(self.target_width); + dst.write_u32(self.target_height); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'a> Decode<'a> for MapSurfaceToScaledWindowPdu { + fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let surface_id = src.read_u16(); + let window_id = src.read_u64(); + let mapped_width = src.read_u32(); + let mapped_height = src.read_u32(); + let target_width = src.read_u32(); + let target_height = src.read_u32(); + + Ok(Self { + surface_id, + window_id, + mapped_width, + mapped_height, + target_width, + target_height, + }) + } +} + +#[repr(u16)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum Codec1Type { + Uncompressed = 0x0, + RemoteFx = 0x3, + ClearCodec = 0x8, + Planar = 0xa, + Avc420 = 0xb, + Alpha = 0xc, + Avc444 = 0xe, + Avc444v2 = 0xf, +} + +impl TryFrom for Codec1Type { + type Error = DecodeError; + + fn try_from(value: u16) -> Result { + match value { + 0x0 => Ok(Codec1Type::Uncompressed), + 0x3 => Ok(Codec1Type::RemoteFx), + 0x8 => Ok(Codec1Type::ClearCodec), + 0xa => Ok(Codec1Type::Planar), + 0xb => Ok(Codec1Type::Avc420), + 0xc => Ok(Codec1Type::Alpha), + 0xe => Ok(Codec1Type::Avc444), + 0xf => Ok(Codec1Type::Avc444v2), + _ => Err(invalid_field_err!("Codec1Type", "invalid codec type")), + } + } +} + +impl From for u16 { + #[expect(clippy::as_conversions, reason = "repr(u16) enum discriminant")] + fn from(value: Codec1Type) -> Self { + value as u16 + } +} + +#[repr(u16)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum Codec2Type { + RemoteFxProgressive = 0x9, +} + +impl TryFrom for Codec2Type { + type Error = DecodeError; + + fn try_from(value: u16) -> Result { + match value { + 0x9 => Ok(Codec2Type::RemoteFxProgressive), + _ => Err(invalid_field_err!("Codec2Type", "invalid codec type")), + } + } +} + +impl From for u16 { + #[expect(clippy::as_conversions, reason = "repr(u16) enum discriminant")] + fn from(value: Codec2Type) -> Self { + value as u16 + } +} diff --git a/crates/ironrdp-egfx/src/pdu/common.rs b/crates/ironrdp-egfx/src/pdu/common.rs new file mode 100644 index 0000000000..ef4c475554 --- /dev/null +++ b/crates/ironrdp-egfx/src/pdu/common.rs @@ -0,0 +1,132 @@ +use ironrdp_pdu::{ + Decode, DecodeError, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, + invalid_field_err, +}; + +/// 2.2.1.1 RDPGFX_POINT16 +/// +/// [2.2.1.1]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Point { + pub x: u16, + pub y: u16, +} + +impl Point { + const NAME: &'static str = "GfxPoint"; + + const FIXED_PART_SIZE: usize = 2 /* X */ + 2 /* Y */; +} + +impl Encode for Point { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u16(self.x); + dst.write_u16(self.y); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'de> Decode<'de> for Point { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let x = src.read_u16(); + let y = src.read_u16(); + + Ok(Self { x, y }) + } +} + +/// 2.2.1.3 RDPGFX_COLOR32 +/// +/// [2.2.1.3]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Color { + pub b: u8, + pub g: u8, + pub r: u8, + pub xa: u8, +} + +impl Color { + const NAME: &'static str = "GfxColor"; + + pub const FIXED_PART_SIZE: usize = 4 /* BGRA */; +} + +impl Encode for Color { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u8(self.b); + dst.write_u8(self.g); + dst.write_u8(self.r); + dst.write_u8(self.xa); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'de> Decode<'de> for Color { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let b = src.read_u8(); + let g = src.read_u8(); + let r = src.read_u8(); + let xa = src.read_u8(); + + Ok(Self { b, g, r, xa }) + } +} + +/// 2.2.1.4 RDPGFX_PIXELFORMAT +/// +/// [2.2.1.4]: +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[repr(u8)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum PixelFormat { + XRgb = 0x20, + ARgb = 0x21, +} + +impl TryFrom for PixelFormat { + type Error = DecodeError; + + fn try_from(value: u8) -> Result { + match value { + 0x20 => Ok(PixelFormat::XRgb), + 0x21 => Ok(PixelFormat::ARgb), + _ => Err(invalid_field_err!("PixelFormat", "invalid pixel format")), + } + } +} + +impl From for u8 { + #[expect(clippy::as_conversions, reason = "repr(u8) enum discriminant")] + fn from(value: PixelFormat) -> Self { + value as u8 + } +} diff --git a/crates/ironrdp-egfx/src/pdu/mod.rs b/crates/ironrdp-egfx/src/pdu/mod.rs new file mode 100644 index 0000000000..5f0f2b785c --- /dev/null +++ b/crates/ironrdp-egfx/src/pdu/mod.rs @@ -0,0 +1,24 @@ +//! Display Pipeline Virtual Channel Extension PDUs [MS-RDPEGFX][1] implementation. +//! +//! This module provides PDU types for the Graphics Pipeline Extension, including +//! H.264/AVC420 video streaming support. +//! +//! # Server-Side Utilities +//! +//! For server implementations, the following utilities are provided: +//! +//! - [`Avc420Region`] - Region metadata for H.264 frames +//! - [`annex_b_to_avc`] - Convert H.264 Annex B to AVC format +//! - [`align_to_16`] - Align dimensions to H.264 macroblock boundaries +//! - [`encode_avc420_bitmap_stream`] - Create AVC420 bitmap streams +//! +//! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/da5c75f9-cd99-450c-98c4-014a496942b0 + +mod common; +pub use common::*; + +mod cmd; +pub use cmd::*; + +mod avc; +pub use avc::*; diff --git a/crates/ironrdp-egfx/src/server.rs b/crates/ironrdp-egfx/src/server.rs new file mode 100644 index 0000000000..dd1261b8ee --- /dev/null +++ b/crates/ironrdp-egfx/src/server.rs @@ -0,0 +1,1783 @@ +//! Server-side EGFX implementation +//! +//! This module provides complete server-side support for the Graphics Pipeline Extension +//! (MS-RDPEGFX), enabling H.264 AVC420/AVC444 video streaming to RDP clients. +//! +//! # Protocol Compliance +//! +//! This implementation follows MS-RDPEGFX specification requirements: +//! +//! - **Capability Negotiation**: Supports V8, V8.1, V10, V10.1-V10.7 +//! - **Surface Management**: Multi-surface support with proper lifecycle +//! - **Frame Flow Control**: Tracks unacknowledged frames per spec +//! - **Codec Support**: AVC420, AVC444, with extensibility for others +//! +//! # Architecture +//! +//! The server follows this message flow: +//! +//! ```text +//! Client Server +//! | | +//! |--- CapabilitiesAdvertise ------------>| +//! | | (negotiate capabilities) +//! |<----------- CapabilitiesConfirm ------| +//! |<----------- ResetGraphics ------------| +//! |<----------- CreateSurface ------------| +//! |<----------- MapSurfaceToOutput -------| +//! | | +//! | (For each frame:) | +//! |<----------- StartFrame ---------------| +//! |<----------- WireToSurface1/2 ---------| (H.264 data) +//! |<----------- EndFrame -----------------| +//! | | +//! |--- FrameAcknowledge ----------------->| (flow control) +//! |--- QoeFrameAcknowledge -------------->| (optional, V10+) +//! ``` +//! +//! # Usage +//! +//! ```ignore +//! use ironrdp_egfx::server::{GraphicsPipelineServer, GraphicsPipelineHandler}; +//! +//! struct MyHandler; +//! +//! impl GraphicsPipelineHandler for MyHandler { +//! fn capabilities_advertise(&mut self, caps: &CapabilitiesAdvertisePdu) { +//! // Client sent capabilities +//! } +//! +//! fn on_ready(&mut self, negotiated: &CapabilitySet) { +//! // Server is ready to send frames +//! } +//! } +//! +//! let server = GraphicsPipelineServer::new(Box::new(MyHandler)); +//! ``` + +use std::collections::{HashMap, VecDeque}; +use std::time::Instant; + +use ironrdp_core::{Encode, EncodeResult, WriteCursor, decode, impl_as_any}; +use ironrdp_dvc::{DvcEncode, DvcMessage, DvcProcessor, DvcServerProcessor}; +use ironrdp_graphics::zgfx::{CompressionMode, Compressor, compress_and_wrap_egfx, wrap_uncompressed}; +use ironrdp_pdu::gcc::Monitor; +use ironrdp_pdu::geometry::ExclusiveRectangle; +use ironrdp_pdu::{PduResult, decode_err}; +use tracing::{debug, trace, warn}; + +use crate::CHANNEL_NAME; +use crate::pdu::{ + Avc420BitmapStream, Avc420Region, Avc444BitmapStream, CacheImportOfferPdu, CacheImportReplyPdu, + CapabilitiesAdvertisePdu, CapabilitiesConfirmPdu, CapabilitiesV8Flags, CapabilitiesV10Flags, CapabilitiesV81Flags, + CapabilitiesV103Flags, CapabilitiesV104Flags, CapabilitiesV107Flags, CapabilitySet, Codec1Type, Codec2Type, + CreateSurfacePdu, DeleteSurfacePdu, Encoding, EndFramePdu, FrameAcknowledgePdu, GfxPdu, MapSurfaceToOutputPdu, + PixelFormat, QoeFrameAcknowledgePdu, ResetGraphicsPdu, StartFramePdu, Timestamp, WireToSurface1Pdu, + WireToSurface2Pdu, encode_avc420_bitmap_stream, +}; + +// ============================================================================ +// Constants +// ============================================================================ + +/// Default maximum frames in flight before applying backpressure +const DEFAULT_MAX_FRAMES_IN_FLIGHT: u32 = 3; + +/// Special queue depth value indicating client has disabled acknowledgments +const SUSPEND_FRAME_ACK_QUEUE_DEPTH: u32 = 0xFFFFFFFF; + +/// Pre-encoded ZGFX-wrapped bytes for DVC transmission. +/// +/// `Encode::encode()` takes `&self`, but ZGFX wrapping is done in `drain_output()` +/// where `&mut self` is available. This type holds the already-wrapped bytes. +struct ZgfxWrappedBytes { + bytes: Vec, + pdu_name: &'static str, +} + +impl Encode for ZgfxWrappedBytes { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + dst.write_slice(&self.bytes); + Ok(()) + } + + fn name(&self) -> &'static str { + self.pdu_name + } + + fn size(&self) -> usize { + self.bytes.len() + } +} + +impl DvcEncode for ZgfxWrappedBytes {} + +// ============================================================================ +// Surface Management +// ============================================================================ + +/// Surface state tracked by server +/// +/// Per MS-RDPEGFX, the server maintains an "Offscreen Surfaces ADM element" +/// which is a list of surfaces created on the client. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct Surface { + /// Surface identifier (unique per session) + pub id: u16, + /// Surface width in pixels + pub width: u16, + /// Surface height in pixels + pub height: u16, + /// Pixel format + pub pixel_format: PixelFormat, + /// Whether this surface is mapped to an output + pub is_mapped: bool, + /// Output X origin (if mapped) + pub output_origin_x: u32, + /// Output Y origin (if mapped) + pub output_origin_y: u32, +} + +impl Surface { + fn new(id: u16, width: u16, height: u16, pixel_format: PixelFormat) -> Self { + Self { + id, + width, + height, + pixel_format, + is_mapped: false, + output_origin_x: 0, + output_origin_y: 0, + } + } +} + +/// Multi-surface management +/// +/// Implements the "Offscreen Surfaces ADM element" from MS-RDPEGFX. +#[derive(Debug, Default)] +pub struct Surfaces { + surfaces: HashMap, + next_surface_id: u16, +} + +impl Surfaces { + /// Create a new surface manager + pub fn new() -> Self { + Self::default() + } + + /// Allocate a new surface ID + pub fn allocate_id(&mut self) -> u16 { + let id = self.next_surface_id; + debug_assert!(!self.surfaces.contains_key(&id), "surface ID {id} already in use"); + self.next_surface_id = self.next_surface_id.wrapping_add(1); + id + } + + /// Register a surface + pub fn insert(&mut self, surface: Surface) { + self.surfaces.insert(surface.id, surface); + } + + /// Remove a surface + pub fn remove(&mut self, surface_id: u16) -> Option { + self.surfaces.remove(&surface_id) + } + + /// Get a surface by ID + pub fn get(&self, surface_id: u16) -> Option<&Surface> { + self.surfaces.get(&surface_id) + } + + /// Get a mutable surface by ID + pub fn get_mut(&mut self, surface_id: u16) -> Option<&mut Surface> { + self.surfaces.get_mut(&surface_id) + } + + /// Check if a surface exists + pub fn contains(&self, surface_id: u16) -> bool { + self.surfaces.contains_key(&surface_id) + } + + /// Get all surface IDs + pub fn surface_ids(&self) -> impl Iterator + '_ { + self.surfaces.keys().copied() + } + + /// Clear all surfaces + pub fn clear(&mut self) { + self.surfaces.clear(); + } + + /// Number of surfaces + pub fn len(&self) -> usize { + self.surfaces.len() + } + + /// Check if empty + pub fn is_empty(&self) -> bool { + self.surfaces.is_empty() + } +} + +// ============================================================================ +// Frame Tracking +// ============================================================================ + +/// Information about a frame awaiting acknowledgment +/// +/// Per MS-RDPEGFX, the server maintains an "Unacknowledged Frames ADM element" +/// which tracks frames sent but not yet acknowledged. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct FrameInfo { + /// Frame identifier + pub frame_id: u32, + /// Frame timestamp + pub timestamp: Timestamp, + /// When the frame was sent + pub sent_at: Instant, + /// Approximate size in bytes + pub size_bytes: usize, +} + +/// Quality of Experience metrics from client +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct QoeMetrics { + /// Frame ID this relates to + pub frame_id: u32, + /// Client timestamp when decode started + pub timestamp: u32, + /// Time difference for serial encode (microseconds) + pub time_diff_se: u16, + /// Time difference for decode and render (microseconds) + pub time_diff_dr: u16, +} + +// ============================================================================ +// QoE Statistics +// ============================================================================ + +/// Accumulated Quality of Experience statistics. +/// +/// Tracks client-reported decode/render timing from [2.2.2.13] QoE Frame +/// Acknowledge PDUs and server-measured round-trip latency from frame +/// acknowledgments. Statistics are accumulated over the lifetime of the +/// EGFX channel. +/// +/// Use [`GraphicsPipelineServer::qoe_snapshot()`] to query current values. +/// +/// [2.2.2.13]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/40c1ada9-db39-407b-a760-fca4b3e9cc35 +#[derive(Debug)] +struct QoeCollector { + /// Total QoE reports received from client. + total_reports: u64, + + /// Most recent client decode+render time (microseconds). + latest_decode_render_us: u16, + + /// Exponential moving average of decode+render time (microseconds). + avg_decode_render_us: f32, + + /// Minimum decode+render time observed (microseconds). + min_decode_render_us: u16, + + /// Maximum decode+render time observed (microseconds). + max_decode_render_us: u16, + + /// Total round-trip latency samples (frame send to ack receive). + total_rtt_samples: u64, + + /// Exponential moving average of round-trip latency (milliseconds). + avg_rtt_ms: f32, + + /// Minimum round-trip latency observed (milliseconds). + min_rtt_ms: f32, + + /// Maximum round-trip latency observed (milliseconds). + max_rtt_ms: f32, + + /// Total encoded bytes across all acknowledged frames. + total_bytes_sent: u64, + + /// Total frames acknowledged (for average frame size calculation). + total_frames_acked: u64, + + /// Number of times a frame send was blocked by backpressure. + backpressure_count: u64, +} + +/// EMA smoothing factor. Balances responsiveness to recent values against +/// stability. 0.1 means ~10-sample effective window. +const QOE_EMA_ALPHA: f32 = 0.1; + +impl QoeCollector { + fn new() -> Self { + Self { + total_reports: 0, + latest_decode_render_us: 0, + avg_decode_render_us: 0.0, + min_decode_render_us: u16::MAX, + max_decode_render_us: 0, + total_rtt_samples: 0, + avg_rtt_ms: 0.0, + min_rtt_ms: f32::MAX, + max_rtt_ms: 0.0, + total_bytes_sent: 0, + total_frames_acked: 0, + backpressure_count: 0, + } + } + + /// Record a QoE report from the client. + fn record_qoe(&mut self, metrics: &QoeMetrics) { + let dr = metrics.time_diff_dr; + + self.latest_decode_render_us = dr; + self.min_decode_render_us = self.min_decode_render_us.min(dr); + self.max_decode_render_us = self.max_decode_render_us.max(dr); + + if self.total_reports == 0 { + self.avg_decode_render_us = f32::from(dr); + } else { + self.avg_decode_render_us = + self.avg_decode_render_us * (1.0 - QOE_EMA_ALPHA) + f32::from(dr) * QOE_EMA_ALPHA; + } + + self.total_reports += 1; + } + + /// Record an acknowledged frame's size for bandwidth tracking. + #[expect( + clippy::as_conversions, + reason = "usize to u64 is lossless on all supported platforms (32/64-bit)" + )] + fn record_frame_ack(&mut self, size_bytes: usize) { + self.total_bytes_sent += size_bytes as u64; + self.total_frames_acked += 1; + } + + /// Record a backpressure event (frame send blocked by full queue). + fn record_backpressure(&mut self) { + self.backpressure_count += 1; + } + + /// Record a round-trip latency measurement from a frame acknowledgment. + fn record_rtt(&mut self, rtt: core::time::Duration) { + let rtt_ms = rtt.as_secs_f32() * 1000.0; + + self.min_rtt_ms = self.min_rtt_ms.min(rtt_ms); + self.max_rtt_ms = self.max_rtt_ms.max(rtt_ms); + + if self.total_rtt_samples == 0 { + self.avg_rtt_ms = rtt_ms; + } else { + self.avg_rtt_ms = self.avg_rtt_ms * (1.0 - QOE_EMA_ALPHA) + rtt_ms * QOE_EMA_ALPHA; + } + + self.total_rtt_samples += 1; + } + + /// Produce a point-in-time snapshot of accumulated statistics. + fn snapshot(&self) -> QoeSnapshot { + QoeSnapshot { + total_qoe_reports: self.total_reports, + latest_decode_render_us: self.latest_decode_render_us, + avg_decode_render_us: self.avg_decode_render_us, + min_decode_render_us: if self.total_reports == 0 { + 0 + } else { + self.min_decode_render_us + }, + max_decode_render_us: self.max_decode_render_us, + total_rtt_samples: self.total_rtt_samples, + avg_rtt_ms: self.avg_rtt_ms, + min_rtt_ms: if self.total_rtt_samples == 0 { + 0.0 + } else { + self.min_rtt_ms + }, + max_rtt_ms: self.max_rtt_ms, + total_bytes_sent: self.total_bytes_sent, + avg_frame_size_bytes: if self.total_frames_acked == 0 { + 0 + } else { + self.total_bytes_sent / self.total_frames_acked + }, + backpressure_count: self.backpressure_count, + } + } + + /// Reset all accumulated statistics. + fn clear(&mut self) { + *self = Self::new(); + } +} + +impl Default for QoeCollector { + fn default() -> Self { + Self::new() + } +} + +/// Point-in-time snapshot of QoE statistics. +/// +/// Returned by [`GraphicsPipelineServer::qoe_snapshot()`]. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct QoeSnapshot { + /// Total QoE reports received from client. + pub total_qoe_reports: u64, + + /// Most recent client decode+render time (microseconds). + pub latest_decode_render_us: u16, + + /// Exponential moving average of decode+render time (microseconds). + pub avg_decode_render_us: f32, + + /// Minimum decode+render time observed (microseconds). + pub min_decode_render_us: u16, + + /// Maximum decode+render time observed (microseconds). + pub max_decode_render_us: u16, + + /// Total round-trip latency samples. + pub total_rtt_samples: u64, + + /// Exponential moving average of round-trip latency (milliseconds). + pub avg_rtt_ms: f32, + + /// Minimum round-trip latency observed (milliseconds). + pub min_rtt_ms: f32, + + /// Maximum round-trip latency observed (milliseconds). + pub max_rtt_ms: f32, + + /// Total encoded bytes across all acknowledged frames. + pub total_bytes_sent: u64, + + /// Average frame size in bytes (total_bytes_sent / frames acknowledged). + pub avg_frame_size_bytes: u64, + + /// Number of times a frame send was blocked by backpressure. + /// + /// High values indicate the client cannot keep up with the server's + /// frame rate. Useful for adaptive encoding decisions and thin client + /// detection. + pub backpressure_count: u64, +} + +// ============================================================================ +// Frame Tracking +// ============================================================================ + +/// Frame tracking for flow control +/// +/// Implements the "Unacknowledged Frames ADM element" from MS-RDPEGFX. +#[derive(Debug)] +pub struct FrameTracker { + /// Frames sent but not yet acknowledged + unacknowledged: HashMap, + /// Last reported client queue depth + client_queue_depth: u32, + /// Whether client has suspended acknowledgments + ack_suspended: bool, + /// Next frame ID to assign + next_frame_id: u32, + /// Maximum frames in flight before backpressure + max_in_flight: u32, + /// Total frames sent + total_sent: u64, + /// Total frames acknowledged + total_acked: u64, +} + +impl Default for FrameTracker { + fn default() -> Self { + Self::new() + } +} + +impl FrameTracker { + /// Create a new frame tracker + pub fn new() -> Self { + Self { + unacknowledged: HashMap::new(), + client_queue_depth: 0, + ack_suspended: false, + next_frame_id: 0, + max_in_flight: DEFAULT_MAX_FRAMES_IN_FLIGHT, + total_sent: 0, + total_acked: 0, + } + } + + /// Set maximum frames in flight + pub fn set_max_in_flight(&mut self, max: u32) { + self.max_in_flight = max; + } + + /// Allocate a new frame ID and track it + pub fn begin_frame(&mut self, timestamp: Timestamp) -> u32 { + let frame_id = self.next_frame_id; + self.next_frame_id = self.next_frame_id.wrapping_add(1); + + self.unacknowledged.insert( + frame_id, + FrameInfo { + frame_id, + timestamp, + sent_at: Instant::now(), + size_bytes: 0, + }, + ); + + self.total_sent += 1; + frame_id + } + + /// Update frame size after encoding + pub fn set_frame_size(&mut self, frame_id: u32, size_bytes: usize) { + if let Some(info) = self.unacknowledged.get_mut(&frame_id) { + info.size_bytes = size_bytes; + } + } + + /// Handle frame acknowledgment from client + pub fn acknowledge(&mut self, frame_id: u32, queue_depth: u32) -> Option { + if queue_depth == SUSPEND_FRAME_ACK_QUEUE_DEPTH { + self.ack_suspended = true; + self.client_queue_depth = 0; + } else { + self.ack_suspended = false; + self.client_queue_depth = queue_depth; + } + + let info = self.unacknowledged.remove(&frame_id); + if info.is_some() { + self.total_acked += 1; + } + info + } + + /// Number of frames in flight + #[expect( + clippy::cast_possible_truncation, + clippy::as_conversions, + reason = "frame count will never exceed u32::MAX" + )] + pub fn in_flight(&self) -> u32 { + self.unacknowledged.len() as u32 + } + + /// Check if backpressure should be applied + pub fn should_backpressure(&self) -> bool { + !self.ack_suspended && self.in_flight() >= self.max_in_flight + } + + /// Get client queue depth + pub fn client_queue_depth(&self) -> u32 { + self.client_queue_depth + } + + /// Check if acknowledgments are suspended + pub fn is_ack_suspended(&self) -> bool { + self.ack_suspended + } + + /// Get total frames sent + pub fn total_sent(&self) -> u64 { + self.total_sent + } + + /// Get total frames acknowledged + pub fn total_acked(&self) -> u64 { + self.total_acked + } + + /// Clear all tracking state + pub fn clear(&mut self) { + self.unacknowledged.clear(); + self.client_queue_depth = 0; + self.ack_suspended = false; + } +} + +// ============================================================================ +// Capability Negotiation +// ============================================================================ + +/// Codec capabilities determined from negotiation +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct CodecCapabilities { + /// AVC420 (H.264 4:2:0) is available + pub avc420: bool, + /// AVC444 (H.264 4:4:4) is available + pub avc444: bool, + /// Small cache mode + pub small_cache: bool, + /// Thin client mode + pub thin_client: bool, +} + +impl CodecCapabilities { + /// Extract codec capabilities from a capability set + fn from_capability_set(cap: &CapabilitySet) -> Self { + match cap { + CapabilitySet::V8 { flags } => Self { + avc420: false, + avc444: false, + small_cache: flags.contains(CapabilitiesV8Flags::SMALL_CACHE), + thin_client: flags.contains(CapabilitiesV8Flags::THIN_CLIENT), + }, + CapabilitySet::V8_1 { flags } => Self { + avc420: flags.contains(CapabilitiesV81Flags::AVC420_ENABLED), + avc444: false, + small_cache: flags.contains(CapabilitiesV81Flags::SMALL_CACHE), + thin_client: flags.contains(CapabilitiesV81Flags::THIN_CLIENT), + }, + CapabilitySet::V10 { flags } | CapabilitySet::V10_2 { flags } => Self { + avc420: !flags.contains(CapabilitiesV10Flags::AVC_DISABLED), + avc444: !flags.contains(CapabilitiesV10Flags::AVC_DISABLED), + small_cache: flags.contains(CapabilitiesV10Flags::SMALL_CACHE), + thin_client: false, + }, + CapabilitySet::V10_1 => Self { + avc420: true, + avc444: true, + small_cache: false, + thin_client: false, + }, + CapabilitySet::V10_3 { flags } => Self { + // V10.3 lacks SMALL_CACHE flag + avc420: !flags.contains(CapabilitiesV103Flags::AVC_DISABLED), + avc444: !flags.contains(CapabilitiesV103Flags::AVC_DISABLED), + small_cache: false, + thin_client: flags.contains(CapabilitiesV103Flags::AVC_THIN_CLIENT), + }, + CapabilitySet::V10_4 { flags } + | CapabilitySet::V10_5 { flags } + | CapabilitySet::V10_6 { flags } + | CapabilitySet::V10_6Err { flags } => Self { + avc420: !flags.contains(CapabilitiesV104Flags::AVC_DISABLED), + avc444: !flags.contains(CapabilitiesV104Flags::AVC_DISABLED), + small_cache: flags.contains(CapabilitiesV104Flags::SMALL_CACHE), + thin_client: flags.contains(CapabilitiesV104Flags::AVC_THIN_CLIENT), + }, + CapabilitySet::V10_7 { flags } => Self { + avc420: !flags.contains(CapabilitiesV107Flags::AVC_DISABLED), + avc444: !flags.contains(CapabilitiesV107Flags::AVC_DISABLED), + small_cache: flags.contains(CapabilitiesV107Flags::SMALL_CACHE), + thin_client: flags.contains(CapabilitiesV107Flags::AVC_THIN_CLIENT), + }, + } + } +} + +/// Priority order for capability negotiation (highest to lowest) +fn capability_priority(cap: &CapabilitySet) -> u32 { + match cap { + CapabilitySet::V10_7 { .. } => 12, + CapabilitySet::V10_6Err { .. } => 11, + CapabilitySet::V10_6 { .. } => 10, + CapabilitySet::V10_5 { .. } => 9, + CapabilitySet::V10_4 { .. } => 8, + CapabilitySet::V10_3 { .. } => 7, + CapabilitySet::V10_2 { .. } => 6, + CapabilitySet::V10_1 => 5, + CapabilitySet::V10 { .. } => 4, + CapabilitySet::V8_1 { .. } => 3, + CapabilitySet::V8 { .. } => 2, + } +} + +/// Negotiate the best capability set between client and server +fn negotiate_capabilities(client_caps: &[CapabilitySet], server_caps: &[CapabilitySet]) -> Option { + let mut server_sorted: Vec<_> = server_caps.iter().collect(); + server_sorted.sort_by_key(|cap| core::cmp::Reverse(capability_priority(cap))); + + for server_cap in server_sorted { + for client_cap in client_caps { + if core::mem::discriminant(client_cap) == core::mem::discriminant(server_cap) { + return Some(intersect_flags(client_cap, server_cap)); + } + } + } + + None +} + +/// Intersect flags for matching capability set versions +fn intersect_flags(client: &CapabilitySet, server: &CapabilitySet) -> CapabilitySet { + match (client, server) { + (CapabilitySet::V8 { flags: cf }, CapabilitySet::V8 { flags: sf }) => CapabilitySet::V8 { flags: *cf & *sf }, + (CapabilitySet::V8_1 { flags: cf }, CapabilitySet::V8_1 { flags: sf }) => { + CapabilitySet::V8_1 { flags: *cf & *sf } + } + (CapabilitySet::V10 { flags: cf }, CapabilitySet::V10 { flags: sf }) => CapabilitySet::V10 { flags: *cf & *sf }, + (CapabilitySet::V10_2 { flags: cf }, CapabilitySet::V10_2 { flags: sf }) => { + CapabilitySet::V10_2 { flags: *cf & *sf } + } + (CapabilitySet::V10_3 { flags: cf }, CapabilitySet::V10_3 { flags: sf }) => { + CapabilitySet::V10_3 { flags: *cf & *sf } + } + (CapabilitySet::V10_4 { flags: cf }, CapabilitySet::V10_4 { flags: sf }) => { + CapabilitySet::V10_4 { flags: *cf & *sf } + } + (CapabilitySet::V10_5 { flags: cf }, CapabilitySet::V10_5 { flags: sf }) => { + CapabilitySet::V10_5 { flags: *cf & *sf } + } + (CapabilitySet::V10_6 { flags: cf }, CapabilitySet::V10_6 { flags: sf }) => { + CapabilitySet::V10_6 { flags: *cf & *sf } + } + (CapabilitySet::V10_6Err { flags: cf }, CapabilitySet::V10_6Err { flags: sf }) => { + CapabilitySet::V10_6Err { flags: *cf & *sf } + } + (CapabilitySet::V10_7 { flags: cf }, CapabilitySet::V10_7 { flags: sf }) => { + CapabilitySet::V10_7 { flags: *cf & *sf } + } + // V10_1 has no flags; mismatched variants return server as-is. + _ => server.clone(), + } +} + +// ============================================================================ +// Handler Trait +// ============================================================================ + +/// Handler trait for server-side EGFX events +/// +/// Implement this trait to receive callbacks when the EGFX channel state changes +/// or when client messages are received. +pub trait GraphicsPipelineHandler: Send { + /// Called when the client advertises its capabilities + /// + /// This is informational - the server will automatically negotiate + /// based on [`preferred_capabilities()`](Self::preferred_capabilities). + fn capabilities_advertise(&mut self, pdu: &CapabilitiesAdvertisePdu); + + /// Called when the EGFX channel is ready to send frames + /// + /// At this point, capability negotiation is complete. + /// The handler should create surfaces and start sending frames. + fn on_ready(&mut self, negotiated: &CapabilitySet); + + /// Called when a frame has been acknowledged by the client + /// + /// `total_frames_decoded` is the client's running decoded-frame count + /// (MS-RDPEGFX 2.2.2.13), for decode-backlog flow control. + fn on_frame_ack(&mut self, frame_id: u32, queue_depth: u32, total_frames_decoded: u32) { + let _ = frame_id; + let _ = queue_depth; + let _ = total_frames_decoded; + } + + /// Called when QoE metrics are received from client (V10+) + fn on_qoe_metrics(&mut self, _metrics: QoeMetrics) {} + + /// Called when a surface is created + fn on_surface_created(&mut self, _surface: &Surface) {} + + /// Called when a surface is deleted + fn on_surface_deleted(&mut self, _surface_id: u16) {} + + /// Called when the EGFX channel is closed + fn on_close(&mut self) {} + + /// Returns the server's preferred capabilities + /// + /// Override this to customize codec support. The default enables + /// AVC420/AVC444 with V10.7 and V8.1 as fallback. + fn preferred_capabilities(&self) -> Vec { + vec![ + CapabilitySet::V10_7 { + flags: CapabilitiesV107Flags::SMALL_CACHE, + }, + CapabilitySet::V10 { + flags: CapabilitiesV10Flags::SMALL_CACHE, + }, + CapabilitySet::V8_1 { + flags: CapabilitiesV81Flags::AVC420_ENABLED | CapabilitiesV81Flags::SMALL_CACHE, + }, + CapabilitySet::V8 { + flags: CapabilitiesV8Flags::SMALL_CACHE, + }, + ] + } + + /// Returns the maximum frames in flight before backpressure + fn max_frames_in_flight(&self) -> u32 { + DEFAULT_MAX_FRAMES_IN_FLIGHT + } + + /// Called when client offers to import cached bitmaps + /// + /// Return the list of cache slot IDs to accept. + /// Default rejects all (returns empty). + fn on_cache_import_offer(&mut self, _offer: &CacheImportOfferPdu) -> Vec { + vec![] + } +} + +// ============================================================================ +// Server State Machine +// ============================================================================ + +/// Server state machine states +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ServerState { + /// Waiting for client CapabilitiesAdvertise + WaitingForCapabilities, + /// Channel is ready, can send frames + Ready, + /// Performing a resize operation + Resizing, + /// Channel has been closed + Closed, +} + +// ============================================================================ +// Graphics Pipeline Server +// ============================================================================ + +/// Server for the Graphics Pipeline Virtual Channel (EGFX) +/// +/// This server handles capability negotiation, surface management, +/// and H.264 frame transmission to RDP clients per MS-RDPEGFX specification. +pub struct GraphicsPipelineServer { + handler: Box, + + state: ServerState, + negotiated_caps: Option, + codec_caps: CodecCapabilities, + + surfaces: Surfaces, + frames: FrameTracker, + qoe: QoeCollector, + + output_width: u16, + output_height: u16, + /// MS-RDPEGFX requires ResetGraphics before any CreateSurface + reset_graphics_sent: bool, + output_queue: VecDeque, + + /// Stored from DvcProcessor::start() for proactive frame encoding + channel_id: Option, + + /// ZGFX compressor state (history buffer shared across frames) + zgfx_compressor: Compressor, + /// Whether to compress EGFX output with ZGFX + compression_mode: CompressionMode, +} + +/// Payload for a single tile within a mixed-codec frame. +/// +/// Each variant corresponds to a different EGFX codec. Used with +/// [`GraphicsPipelineServer::send_mixed_frame()`] to pack multiple codec +/// types into a single `StartFrame`/`EndFrame` pair. +/// +/// Marked `#[non_exhaustive]` so future EGFX codec additions (for example, +/// Avc444 or hardware-accelerated paths) can land without a SemVer break +/// for downstream consumers that pattern-match on this enum. +#[non_exhaustive] +pub enum MixedTilePayload { + /// Lossless ClearCodec tile (text, UI elements, icons). + /// `bitmap_data` is a pre-encoded ClearCodec bitmap stream. + /// `destination` uses `ExclusiveRectangle` to match the spec-defined + /// `WireToSurface1Pdu.destination_rectangle` field type (MS-RDPEGFX + /// 2.2.1.4.1: right/bottom are exclusive). + ClearCodec { + destination: ExclusiveRectangle, + bitmap_data: Vec, + }, + /// RemoteFX Progressive tile (photos, gradients). + /// `progressive_data` is a valid progressive block stream. + RemoteFxProgressive { + codec_context_id: u32, + progressive_data: Vec, + }, + /// H.264 AVC420 tile (video, high-motion content). + Avc420 { + regions: Vec, + h264_data: Vec, + }, +} + +impl GraphicsPipelineServer { + /// Create a new GraphicsPipelineServer + pub fn new(handler: Box) -> Self { + let max_frames = handler.max_frames_in_flight(); + let mut frames = FrameTracker::new(); + frames.set_max_in_flight(max_frames); + + Self { + handler, + state: ServerState::WaitingForCapabilities, + negotiated_caps: None, + codec_caps: CodecCapabilities::default(), + surfaces: Surfaces::new(), + frames, + qoe: QoeCollector::new(), + output_width: 0, + output_height: 0, + reset_graphics_sent: false, + output_queue: VecDeque::new(), + channel_id: None, + zgfx_compressor: Compressor::new(), + compression_mode: CompressionMode::Never, + } + } + + /// Create a server with ZGFX compression enabled for output. + /// + /// When `compression_mode` is `Auto` or `Always`, `drain_output()` will + /// compress PDUs before ZGFX wrapping, reducing bandwidth at the cost + /// of CPU. The compressor maintains a sliding history window across + /// frames for back-reference efficiency. + pub fn with_compression(handler: Box, compression_mode: CompressionMode) -> Self { + let mut server = Self::new(handler); + server.compression_mode = compression_mode; + server + } + + /// Set desktop output dimensions for ResetGraphics. + /// + /// Call before `create_surface()` when the desktop size differs from + /// the surface size (e.g. 16-pixel alignment padding). + pub fn set_output_dimensions(&mut self, width: u16, height: u16) { + self.output_width = width; + self.output_height = height; + } + + /// DVC channel ID assigned by DRDYNVC. + /// + /// Returns `None` before the channel has been started. + #[must_use] + pub fn channel_id(&self) -> Option { + self.channel_id + } + + // ======================================================================== + // State Queries + // ======================================================================== + + /// Check if the server is ready to send frames + #[must_use] + pub fn is_ready(&self) -> bool { + self.state == ServerState::Ready + } + + /// Get the negotiated capability set + #[must_use] + pub fn negotiated_capabilities(&self) -> Option<&CapabilitySet> { + self.negotiated_caps.as_ref() + } + + /// Get codec capabilities determined from negotiation + #[must_use] + pub fn codec_capabilities(&self) -> &CodecCapabilities { + &self.codec_caps + } + + /// Check if AVC420 (H.264 4:2:0) is available + #[must_use] + pub fn supports_avc420(&self) -> bool { + self.codec_caps.avc420 + } + + /// Check if AVC444 (H.264 4:4:4) is available + #[must_use] + pub fn supports_avc444(&self) -> bool { + self.codec_caps.avc444 + } + + /// Get the graphics output buffer dimensions + #[must_use] + pub fn output_dimensions(&self) -> (u16, u16) { + (self.output_width, self.output_height) + } + + // ======================================================================== + // Surface Management + // ======================================================================== + + /// Create a new surface + /// + /// Queues CreateSurface PDU and returns the surface ID. + /// Returns `None` if not ready. + pub fn create_surface(&mut self, width: u16, height: u16) -> Option { + self.create_surface_with_format(width, height, PixelFormat::XRgb) + } + + /// Create a new surface with specific pixel format + pub fn create_surface_with_format(&mut self, width: u16, height: u16, pixel_format: PixelFormat) -> Option { + if self.state != ServerState::Ready && self.state != ServerState::Resizing { + return None; + } + + // MS-RDPEGFX: ResetGraphics MUST precede any CreateSurface. + // Auto-send on first surface creation if not explicitly sent via resize(). + if !self.reset_graphics_sent { + let desktop_width = if self.output_width > 0 { + self.output_width + } else { + width + }; + let desktop_height = if self.output_height > 0 { + self.output_height + } else { + height + }; + + self.output_queue.push_back(GfxPdu::ResetGraphics(ResetGraphicsPdu { + width: u32::from(desktop_width), + height: u32::from(desktop_height), + monitors: Vec::new(), + })); + + self.output_width = desktop_width; + self.output_height = desktop_height; + self.reset_graphics_sent = true; + } + + let surface_id = self.surfaces.allocate_id(); + let surface = Surface::new(surface_id, width, height, pixel_format); + + self.output_queue.push_back(GfxPdu::CreateSurface(CreateSurfacePdu { + surface_id, + width, + height, + pixel_format, + })); + + self.handler.on_surface_created(&surface); + self.surfaces.insert(surface); + + debug!(surface_id, width, height, ?pixel_format, "Created surface"); + Some(surface_id) + } + + /// Delete a surface + /// + /// Queues DeleteSurface PDU. Returns `false` if surface doesn't exist. + pub fn delete_surface(&mut self, surface_id: u16) -> bool { + if self.surfaces.remove(surface_id).is_none() { + return false; + } + + self.output_queue + .push_back(GfxPdu::DeleteSurface(DeleteSurfacePdu { surface_id })); + + self.handler.on_surface_deleted(surface_id); + debug!(surface_id, "Deleted surface"); + true + } + + /// Map a surface to the graphics output buffer + pub fn map_surface_to_output(&mut self, surface_id: u16, origin_x: u32, origin_y: u32) -> bool { + let Some(surface) = self.surfaces.get_mut(surface_id) else { + return false; + }; + + surface.is_mapped = true; + surface.output_origin_x = origin_x; + surface.output_origin_y = origin_y; + + self.output_queue + .push_back(GfxPdu::MapSurfaceToOutput(MapSurfaceToOutputPdu { + surface_id, + output_origin_x: origin_x, + output_origin_y: origin_y, + })); + + debug!(surface_id, origin_x, origin_y, "Mapped surface to output"); + true + } + + /// Get a surface by ID + #[must_use] + pub fn get_surface(&self, surface_id: u16) -> Option<&Surface> { + self.surfaces.get(surface_id) + } + + /// Get all surface IDs + pub fn surface_ids(&self) -> impl Iterator + '_ { + self.surfaces.surface_ids() + } + + // ======================================================================== + // Resize Handling + // ======================================================================== + + /// Resize the graphics output buffer + /// + /// This initiates a resize sequence: + /// 1. Sends ResetGraphics with new dimensions + /// 2. Deletes existing surfaces + /// 3. Transitions to Ready state + /// + /// After calling this, create new surfaces for the new dimensions. + pub fn resize(&mut self, width: u16, height: u16) { + self.resize_with_monitors(width, height, Vec::new()); + } + + /// Resize with explicit monitor configuration + pub fn resize_with_monitors(&mut self, width: u16, height: u16, monitors: Vec) { + if self.state != ServerState::Ready { + debug!("Cannot resize: not in Ready state"); + return; + } + + // RDPGFX_RESET_GRAPHICS_PDU is fixed at 340 bytes, limiting to 16 monitors. + if monitors.len() > 16 { + warn!( + count = monitors.len(), + "Too many monitors for ResetGraphicsPdu (max 16)" + ); + return; + } + + debug!(width, height, monitors = monitors.len(), "Initiating resize"); + + self.state = ServerState::Resizing; + self.output_width = width; + self.output_height = height; + + let surface_ids: Vec<_> = self.surfaces.surface_ids().collect(); + for id in surface_ids { + self.delete_surface(id); + } + + self.frames.clear(); + + self.output_queue.push_back(GfxPdu::ResetGraphics(ResetGraphicsPdu { + width: u32::from(width), + height: u32::from(height), + monitors, + })); + + self.reset_graphics_sent = true; + self.state = ServerState::Ready; + } + + // ======================================================================== + // Flow Control + // ======================================================================== + + /// Check if backpressure should be applied + /// + /// Returns `true` if too many frames are in flight and the caller + /// should drop or delay new frames. + #[must_use] + pub fn should_backpressure(&self) -> bool { + self.frames.should_backpressure() + } + + /// Get the number of frames currently in flight (awaiting ACK) + #[must_use] + pub fn frames_in_flight(&self) -> u32 { + self.frames.in_flight() + } + + /// Get the last reported client queue depth + #[must_use] + pub fn client_queue_depth(&self) -> u32 { + self.frames.client_queue_depth() + } + + /// Set the maximum frames in flight before backpressure + pub fn set_max_frames_in_flight(&mut self, max: u32) { + self.frames.set_max_in_flight(max); + } + + // ======================================================================== + // QoE Statistics + // ======================================================================== + + /// Get a snapshot of accumulated Quality of Experience statistics. + /// + /// Returns `None` if no QoE reports have been received and no + /// round-trip latency samples have been measured. + /// + /// QoE reports are sent by clients that support [2.2.2.13] QoE Frame + /// Acknowledge PDUs (V10.4+). Round-trip latency is measured for all + /// EGFX versions from frame send to acknowledgment. + /// + /// [2.2.2.13]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/40c1ada9-db39-407b-a760-fca4b3e9cc35 + #[must_use] + pub fn qoe_snapshot(&self) -> Option { + if self.qoe.total_reports == 0 && self.qoe.total_rtt_samples == 0 { + return None; + } + Some(self.qoe.snapshot()) + } + + /// Reset accumulated QoE statistics. + /// + /// Useful when starting a new measurement window (e.g., after a resize). + pub fn reset_qoe(&mut self) { + self.qoe.clear(); + } + + // ======================================================================== + // Frame Sending + // ======================================================================== + + /// Convert timestamp in milliseconds to Timestamp struct + #[expect( + clippy::as_conversions, + reason = "arithmetic results bounded and fit in target types" + )] + fn make_timestamp(timestamp_ms: u32) -> Timestamp { + Timestamp { + milliseconds: (timestamp_ms % 1000) as u16, + seconds: ((timestamp_ms / 1000) % 60) as u8, + minutes: ((timestamp_ms / 60000) % 60) as u8, + hours: ((timestamp_ms / 3600000) % 24) as u16, + } + } + + /// Compute bounding rectangle from regions. + /// + /// Avc420Region uses inclusive bounds; the wire format (RDPGFX_RECT16) is + /// exclusive, so the returned ExclusiveRectangle adds 1 to the max right + /// and bottom of the inclusive bounding box. + fn compute_dest_rect(regions: &[Avc420Region], default_width: u16, default_height: u16) -> ExclusiveRectangle { + if let Some(first) = regions.first() { + let mut left = first.left; + let mut top = first.top; + let mut right = first.right; + let mut bottom = first.bottom; + + for r in regions.iter().skip(1) { + left = left.min(r.left); + top = top.min(r.top); + right = right.max(r.right); + bottom = bottom.max(r.bottom); + } + + ExclusiveRectangle { + left, + top, + right: right.saturating_add(1), + bottom: bottom.saturating_add(1), + } + } else { + ExclusiveRectangle { + left: 0, + top: 0, + right: default_width, + bottom: default_height, + } + } + } + + /// Queue an H.264 AVC420 frame for transmission + /// + /// Returns `Some(frame_id)` if queued, `None` if backpressure is active, + /// server not ready, or AVC420 not supported. + pub fn send_avc420_frame( + &mut self, + surface_id: u16, + h264_data: &[u8], + regions: &[Avc420Region], + timestamp_ms: u32, + ) -> Option { + if !self.is_ready() { + return None; + } + if !self.supports_avc420() { + return None; + } + if self.should_backpressure() { + self.qoe.record_backpressure(); + return None; + } + + let surface = self.surfaces.get(surface_id)?; + + let timestamp = Self::make_timestamp(timestamp_ms); + let frame_id = self.frames.begin_frame(timestamp); + + let encoded_stream = encode_avc420_bitmap_stream(regions, h264_data); + let target_rect = Self::compute_dest_rect(regions, surface.width, surface.height); + + // MS-RDPEGFX requires three-PDU sequence per frame + self.output_queue + .push_back(GfxPdu::StartFrame(StartFramePdu { timestamp, frame_id })); + + self.output_queue.push_back(GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id, + codec_id: Codec1Type::Avc420, + pixel_format: surface.pixel_format, + destination_rectangle: target_rect, + bitmap_data: encoded_stream, + })); + + self.output_queue.push_back(GfxPdu::EndFrame(EndFramePdu { frame_id })); + + Some(frame_id) + } + + /// Queue an H.264 AVC444 frame for transmission + /// + /// AVC444 uses two streams: luma (Y) and chroma (UV). Set `chroma_data` to + /// `None` for luma-only transmission. + /// + /// Returns `Some(frame_id)` if queued, `None` if not supported or backpressured. + pub fn send_avc444_frame( + &mut self, + surface_id: u16, + luma_data: &[u8], + luma_regions: &[Avc420Region], + chroma_data: Option<&[u8]>, + chroma_regions: Option<&[Avc420Region]>, + timestamp_ms: u32, + ) -> Option { + if !self.is_ready() { + return None; + } + if !self.supports_avc444() { + return None; + } + if self.should_backpressure() { + self.qoe.record_backpressure(); + return None; + } + + let surface = self.surfaces.get(surface_id)?; + + let timestamp = Self::make_timestamp(timestamp_ms); + let frame_id = self.frames.begin_frame(timestamp); + + let luma_rectangles: Vec<_> = luma_regions.iter().map(Avc420Region::to_rectangle).collect(); + let luma_quant_vals: Vec<_> = luma_regions.iter().map(Avc420Region::to_quant_quality).collect(); + + let stream1 = Avc420BitmapStream { + rectangles: luma_rectangles, + quant_qual_vals: luma_quant_vals, + data: luma_data, + }; + + let (encoding, stream2) = if let (Some(chroma), Some(chroma_regs)) = (chroma_data, chroma_regions) { + let chroma_rectangles: Vec<_> = chroma_regs.iter().map(Avc420Region::to_rectangle).collect(); + let chroma_quant_vals: Vec<_> = chroma_regs.iter().map(Avc420Region::to_quant_quality).collect(); + + ( + Encoding::LUMA_AND_CHROMA, + Some(Avc420BitmapStream { + rectangles: chroma_rectangles, + quant_qual_vals: chroma_quant_vals, + data: chroma, + }), + ) + } else { + (Encoding::LUMA, None) + }; + + let avc444_stream = Avc444BitmapStream { + encoding, + stream1, + stream2, + }; + + let encoded_stream = encode_avc444_bitmap_stream(&avc444_stream); + let target_rect = Self::compute_dest_rect(luma_regions, surface.width, surface.height); + + self.output_queue + .push_back(GfxPdu::StartFrame(StartFramePdu { timestamp, frame_id })); + + self.output_queue.push_back(GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id, + codec_id: Codec1Type::Avc444, + pixel_format: surface.pixel_format, + destination_rectangle: target_rect, + bitmap_data: encoded_stream, + })); + + self.output_queue.push_back(GfxPdu::EndFrame(EndFramePdu { frame_id })); + + Some(frame_id) + } + + /// Queue an uncompressed bitmap frame for transmission via EGFX + /// + /// Sends raw pixel data through `WireToSurface1` with `Codec1Type::Uncompressed`. + /// Used for V8 clients that support EGFX but not H.264 (AVC420/AVC444). + /// + /// The `bitmap_data` should be in the surface's pixel format (typically XRGB). + /// + /// Returns `Some(frame_id)` if queued, `None` if not ready or backpressured. + pub fn send_uncompressed_frame( + &mut self, + surface_id: u16, + bitmap_data: &[u8], + dest_width: u16, + dest_height: u16, + timestamp_ms: u32, + ) -> Option { + if !self.is_ready() { + return None; + } + if self.should_backpressure() { + self.qoe.record_backpressure(); + return None; + } + + let surface = self.surfaces.get(surface_id)?; + + let timestamp = Self::make_timestamp(timestamp_ms); + let frame_id = self.frames.begin_frame(timestamp); + + let dest_rect = ExclusiveRectangle { + left: 0, + top: 0, + right: dest_width, + bottom: dest_height, + }; + + self.output_queue + .push_back(GfxPdu::StartFrame(StartFramePdu { timestamp, frame_id })); + + self.output_queue.push_back(GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id, + codec_id: Codec1Type::Uncompressed, + pixel_format: surface.pixel_format, + destination_rectangle: dest_rect, + bitmap_data: bitmap_data.to_vec(), + })); + + self.output_queue.push_back(GfxPdu::EndFrame(EndFramePdu { frame_id })); + + Some(frame_id) + } + + /// Queue a RemoteFX Progressive frame for transmission. + /// + /// Progressive frames use `WireToSurface2Pdu` with a pre-encoded progressive + /// block stream as the bitmap payload. The `codec_context_id` associates + /// this data with persistent tile state on the client. + /// + /// The `progressive_data` must be a valid progressive block stream + /// (SYNC + CONTEXT + FRAME_BEGIN + REGION + FRAME_END) as produced by + /// `ironrdp_pdu::codecs::rfx::progressive::encode_progressive_stream()`. + /// + /// Returns `Some(frame_id)` if queued, `None` if not ready or backpressured. + pub fn send_remotefx_progressive_frame( + &mut self, + surface_id: u16, + codec_context_id: u32, + progressive_data: Vec, + timestamp_ms: u32, + ) -> Option { + if !self.is_ready() { + return None; + } + if self.should_backpressure() { + return None; + } + + let surface = self.surfaces.get(surface_id)?; + + let timestamp = Self::make_timestamp(timestamp_ms); + let frame_id = self.frames.begin_frame(timestamp); + + self.output_queue + .push_back(GfxPdu::StartFrame(StartFramePdu { timestamp, frame_id })); + + self.output_queue.push_back(GfxPdu::WireToSurface2(WireToSurface2Pdu { + surface_id, + codec_id: Codec2Type::RemoteFxProgressive, + codec_context_id, + pixel_format: surface.pixel_format, + bitmap_data: progressive_data, + })); + + self.output_queue.push_back(GfxPdu::EndFrame(EndFramePdu { frame_id })); + + Some(frame_id) + } + + // ======================================================================== + // Mixed-Codec Frame Support + // ======================================================================== + + /// Queue a mixed-codec frame containing tiles encoded with different codecs. + /// + /// This is the core of multi-codec EGFX: a single frame update can contain + /// ClearCodec tiles (lossless text), Progressive tiles (photos), and H.264 + /// tiles (video), all sent between one `StartFrame`/`EndFrame` pair. + /// + /// This matches how Azure VDI achieves its visual quality — each tile uses + /// the codec best suited to its content type. + /// + /// Returns `Some(frame_id)` if queued, `None` if not ready or backpressured. + pub fn send_mixed_frame( + &mut self, + surface_id: u16, + tiles: Vec, + timestamp_ms: u32, + ) -> Option { + if !self.is_ready() { + return None; + } + if self.should_backpressure() { + return None; + } + if tiles.is_empty() { + return None; + } + + let surface = self.surfaces.get(surface_id)?; + let pixel_format = surface.pixel_format; + + let timestamp = Self::make_timestamp(timestamp_ms); + let frame_id = self.frames.begin_frame(timestamp); + + self.output_queue + .push_back(GfxPdu::StartFrame(StartFramePdu { timestamp, frame_id })); + + for tile in tiles { + match tile { + MixedTilePayload::ClearCodec { + destination, + bitmap_data, + } => { + self.output_queue.push_back(GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id, + codec_id: Codec1Type::ClearCodec, + pixel_format, + destination_rectangle: destination, + bitmap_data, + })); + } + MixedTilePayload::RemoteFxProgressive { + codec_context_id, + progressive_data, + } => { + self.output_queue.push_back(GfxPdu::WireToSurface2(WireToSurface2Pdu { + surface_id, + codec_id: Codec2Type::RemoteFxProgressive, + codec_context_id, + pixel_format, + bitmap_data: progressive_data, + })); + } + MixedTilePayload::Avc420 { regions, h264_data } => { + let encoded_stream = encode_avc420_bitmap_stream(®ions, &h264_data); + let target_rect = Self::compute_dest_rect(®ions, surface.width, surface.height); + + self.output_queue.push_back(GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id, + codec_id: Codec1Type::Avc420, + pixel_format, + destination_rectangle: target_rect, + bitmap_data: encoded_stream, + })); + } + } + } + + self.output_queue.push_back(GfxPdu::EndFrame(EndFramePdu { frame_id })); + + Some(frame_id) + } + + // ======================================================================== + // Output Management + // ======================================================================== + + /// Drain the output queue, ZGFX-wrapping each PDU for DVC transmission. + /// + /// Each `GfxPdu` is encoded to bytes then wrapped in uncompressed ZGFX + /// segment format. Windows clients expect this wrapping on the EGFX DVC. + /// + /// # Panics + /// + /// Panics if a `GfxPdu` fails to encode. This indicates a bug in the PDU + /// encoding logic, not a runtime condition. + #[expect(clippy::as_conversions, reason = "Box to Box coercion")] + pub fn drain_output(&mut self) -> Vec { + let mode = self.compression_mode; + let pdus: Vec<_> = self.output_queue.drain(..).collect(); + + pdus.into_iter() + .map(|pdu| { + let pdu_name = pdu.name(); + let pdu_size = pdu.size(); + let mut pdu_bytes = vec![0u8; pdu_size]; + let mut cursor = WriteCursor::new(&mut pdu_bytes); + pdu.encode(&mut cursor).expect("GfxPdu encoding should not fail"); + + let wrapped = match mode { + CompressionMode::Never => wrap_uncompressed(&pdu_bytes), + _ => compress_and_wrap_egfx(&pdu_bytes, &mut self.zgfx_compressor, mode) + .unwrap_or_else(|_| wrap_uncompressed(&pdu_bytes)), + }; + trace!(pdu_name, pdu_size, wrapped = wrapped.len(), mode = ?mode, "ZGFX output"); + + Box::new(ZgfxWrappedBytes { + bytes: wrapped, + pdu_name, + }) as DvcMessage + }) + .collect() + } + + /// Check if there are pending PDUs to send + #[must_use] + pub fn has_pending_output(&self) -> bool { + !self.output_queue.is_empty() + } + + // ======================================================================== + // Internal Message Handlers + // ======================================================================== + + fn handle_capabilities_advertise(&mut self, pdu: CapabilitiesAdvertisePdu) { + self.handler.capabilities_advertise(&pdu); + let server_caps = self.handler.preferred_capabilities(); + + // Parse client raw caps into typed. Silently skip unknown versions for + // negotiation purposes (the raw form is still observable in `pdu`), but + // treat parse failures for known versions as malformed input instead of + // negotiating as if the client never advertised them. + let mut client_caps = Vec::with_capacity(pdu.0.len()); + for raw in &pdu.0 { + match raw.parsed() { + Ok(Some(cap)) => client_caps.push(cap), + Ok(None) => {} + Err(e) => { + warn!(error = ?e, "Received malformed client capability set; aborting capability negotiation"); + return; + } + } + } + + // When no version overlaps with server preferences, confirm the client's + // highest-priority capability to avoid confirming a version the client + // did not advertise. + let negotiated = negotiate_capabilities(&client_caps, &server_caps).unwrap_or_else(|| { + warn!("No capability match with server preferences, selecting client's highest version"); + let mut client_sorted = client_caps.clone(); + client_sorted.sort_by_key(|cap| core::cmp::Reverse(capability_priority(cap))); + client_sorted.into_iter().next().unwrap_or(CapabilitySet::V8 { + flags: CapabilitiesV8Flags::empty(), + }) + }); + + self.codec_caps = CodecCapabilities::from_capability_set(&negotiated); + self.state = ServerState::Ready; + let negotiated = self.negotiated_caps.insert(negotiated); + + self.output_queue + .push_back(GfxPdu::CapabilitiesConfirm(CapabilitiesConfirmPdu::from_typed( + negotiated, + ))); + + self.handler.on_ready(negotiated); + } + + fn handle_frame_acknowledge(&mut self, pdu: FrameAcknowledgePdu) { + let queue_depth = pdu.queue_depth.to_u32(); + + if let Some(info) = self.frames.acknowledge(pdu.frame_id, queue_depth) { + let rtt = info.sent_at.elapsed(); + self.qoe.record_rtt(rtt); + self.qoe.record_frame_ack(info.size_bytes); + trace!(frame_id = pdu.frame_id, latency = ?rtt); + } + + self.handler + .on_frame_ack(pdu.frame_id, queue_depth, pdu.total_frames_decoded); + } + + fn handle_qoe_frame_acknowledge(&mut self, pdu: QoeFrameAcknowledgePdu) { + let metrics = QoeMetrics { + frame_id: pdu.frame_id, + timestamp: pdu.timestamp, + time_diff_se: pdu.time_diff_se, + time_diff_dr: pdu.time_diff_dr, + }; + + self.qoe.record_qoe(&metrics); + self.handler.on_qoe_metrics(metrics); + } + + fn handle_cache_import_offer(&mut self, pdu: CacheImportOfferPdu) { + let accepted = self.handler.on_cache_import_offer(&pdu); + + self.output_queue + .push_back(GfxPdu::CacheImportReply(CacheImportReplyPdu { cache_slots: accepted })); + } +} + +impl_as_any!(GraphicsPipelineServer); + +impl DvcProcessor for GraphicsPipelineServer { + fn channel_name(&self) -> &str { + CHANNEL_NAME + } + + fn start(&mut self, channel_id: u32) -> PduResult> { + self.channel_id = Some(channel_id); + debug!(channel_id, "EGFX channel started"); + Ok(vec![]) + } + + fn close(&mut self, _channel_id: u32) { + debug!("EGFX channel closed"); + self.state = ServerState::Closed; + self.reset_graphics_sent = false; + self.handler.on_close(); + } + + fn process(&mut self, _channel_id: u32, payload: &[u8]) -> PduResult> { + let pdu = decode(payload).map_err(|e| decode_err!(e))?; + + match pdu { + GfxPdu::CapabilitiesAdvertise(pdu) => { + self.handle_capabilities_advertise(pdu); + } + GfxPdu::FrameAcknowledge(pdu) => { + self.handle_frame_acknowledge(pdu); + } + GfxPdu::QoeFrameAcknowledge(pdu) => { + self.handle_qoe_frame_acknowledge(pdu); + } + GfxPdu::CacheImportOffer(pdu) => { + self.handle_cache_import_offer(pdu); + } + _ => { + warn!(?pdu, "Unhandled client GFX PDU"); + } + } + + Ok(self.drain_output()) + } +} + +impl DvcServerProcessor for GraphicsPipelineServer {} + +// ============================================================================ +// AVC444 Encoding Helper +// ============================================================================ + +/// Encode an AVC444 bitmap stream to bytes +fn encode_avc444_bitmap_stream(stream: &Avc444BitmapStream<'_>) -> Vec { + use ironrdp_pdu::Encode as _; + + let size = stream.size(); + let mut buf = vec![0u8; size]; + let mut cursor = WriteCursor::new(&mut buf); + + stream + .encode(&mut cursor) + .expect("encode_avc444_bitmap_stream: encoding failed"); + + buf +} diff --git a/crates/ironrdp-error/CHANGELOG.md b/crates/ironrdp-error/CHANGELOG.md index 87e73d30f5..4614e94933 100644 --- a/crates/ironrdp-error/CHANGELOG.md +++ b/crates/ironrdp-error/CHANGELOG.md @@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-error-v0.1.3...ironrdp-error-v0.2.0)] - 2026-05-27 + +### Features + +- Capture core::panic::Location automatically in Error ([#1262](https://github.com/Devolutions/IronRDP/issues/1262)) ([2e2b5edfd7](https://github.com/Devolutions/IronRDP/commit/2e2b5edfd750df35bd8c8dba777ddd45c1a5bc7a)) + + Capture caller location with `#[track_caller]` + `core::panic::Location::caller()` + and include it in `Display` output while keeping `Debug` stable for snapshots. + +- Add bail! and ensure! macros ([#1263](https://github.com/Devolutions/IronRDP/issues/1263)) ([68b86f2b06](https://github.com/Devolutions/IronRDP/commit/68b86f2b06ba9b09a2f9e007dd5f1783b6979cca)) + +### Bug Fixes + +- [**breaking**] Make fields of Error private ([#1074](https://github.com/Devolutions/IronRDP/issues/1074)) ([e51ed236ce](https://github.com/Devolutions/IronRDP/commit/e51ed236ce5d55dc1a4bc5f5809fd106bdd2e834)) + +- Box diagnostic metadata to shrink Error size ([#1269](https://github.com/Devolutions/IronRDP/issues/1269)) ([2e2699d2dc](https://github.com/Devolutions/IronRDP/commit/2e2699d2dc6644d5bbc87f41a987a2db90d281a8)) + + Move context, location, and source into a heap-allocated `ErrorMeta` so + `Error` keeps only kind on the stack, reducing large downstream + error sizes. Since error construction is already `#[cold]`, one `Box` + allocation per error is acceptable. + +- [**breaking**] Remove Error::into_other_kind ([#1278](https://github.com/Devolutions/IronRDP/issues/1278)) ([ac7ad50a50](https://github.com/Devolutions/IronRDP/commit/ac7ad50a501935fdf2ce0e12b6dd737dcb9aa9c9)) + ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-error-v0.1.1...ironrdp-error-v0.1.2)] - 2025-01-28 ### Documentation @@ -13,7 +37,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-error-v0.1.0...ironrdp-error-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-error/Cargo.toml b/crates/ironrdp-error/Cargo.toml index 46775fa9bd..b8b56cbfc2 100644 --- a/crates/ironrdp-error/Cargo.toml +++ b/crates/ironrdp-error/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-error" -version = "0.1.3" +version = "0.2.0" readme = "README.md" description = "IronPDU generic error definition" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true diff --git a/crates/ironrdp-error/src/lib.rs b/crates/ironrdp-error/src/lib.rs index 4687bcff28..7aae0f79fd 100644 --- a/crates/ironrdp-error/src/lib.rs +++ b/crates/ironrdp-error/src/lib.rs @@ -9,37 +9,79 @@ extern crate alloc; use alloc::boxed::Box; use core::fmt; -#[cfg(feature = "std")] -pub trait Source: core::error::Error + Sync + Send + 'static {} - -#[cfg(feature = "std")] -impl Source for T where T: core::error::Error + Sync + Send + 'static {} +pub trait Source: core::error::Error + Send + Sync + 'static {} -#[cfg(not(feature = "std"))] -pub trait Source: fmt::Display + fmt::Debug + Send + Sync + 'static {} +impl Source for T where T: core::error::Error + Send + Sync + 'static {} -#[cfg(not(feature = "std"))] -impl Source for T where T: fmt::Display + fmt::Debug + Send + Sync + 'static {} +/// Diagnostic metadata stored behind a [`Box`] so that `Error` stays small. +/// +/// All fields here are purely for display and error-chain traversal; none are +/// needed for matching on the error kind. The allocation only occurs when an +/// error is *constructed* — a cold path — so the per-error heap cost is +/// acceptable. +#[cfg(feature = "alloc")] +struct ErrorMeta { + context: &'static str, + location: &'static core::panic::Location<'static>, + source: Option>, +} -#[derive(Debug)] +/// A typed error wrapper carrying a `Kind` discriminant plus diagnostic metadata. +/// +/// # `no_alloc` platforms +/// +/// When compiled without the `alloc` feature, `Error` retains `kind`, +/// `context`, and `location` inline. The error source chain is unavailable. +/// `no_alloc` targets are supported on a best-effort basis and are not a +/// primary target of this crate. Do not add more inline fields here: the +/// struct should stay lean for stack-constrained environments. pub struct Error { - pub context: &'static str, - pub kind: Kind, - #[cfg(feature = "std")] - source: Option>, - #[cfg(all(not(feature = "std"), feature = "alloc"))] - source: Option>, + kind: Kind, + /// Diagnostic metadata. Present only when `alloc` is available. + #[cfg(feature = "alloc")] + meta: Box, + /// Minimal context kept for `no_alloc` targets (no source chain). + #[cfg(not(feature = "alloc"))] + context: &'static str, + #[cfg(not(feature = "alloc"))] + location: &'static core::panic::Location<'static>, +} + +// Manual `Debug` impl that excludes the `location` field. The location is +// captured via `core::panic::Location::caller()` and rendered in `Display`, +// but its `file()` returns platform-native paths (`/` on Unix, `\` on +// Windows). Including it in `Debug` would break cross-platform snapshot +// tests. Consumers needing programmatic access can use `Error::location()`. +impl fmt::Debug for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut dbg = f.debug_struct("Error"); + #[cfg(feature = "alloc")] + dbg.field("context", &self.meta.context) + .field("kind", &self.kind) + .field("source", &self.meta.source); + #[cfg(not(feature = "alloc"))] + dbg.field("context", &self.context).field("kind", &self.kind); + dbg.finish() + } } impl Error { #[cold] #[must_use] + #[track_caller] pub fn new(context: &'static str, kind: Kind) -> Self { Self { - context, kind, #[cfg(feature = "alloc")] - source: None, + meta: Box::new(ErrorMeta { + context, + location: core::panic::Location::caller(), + source: None, + }), + #[cfg(not(feature = "alloc"))] + context, + #[cfg(not(feature = "alloc"))] + location: core::panic::Location::caller(), } } @@ -52,11 +94,11 @@ impl Error { #[cfg(feature = "alloc")] { let mut this = self; - this.source = Some(Box::new(source)); + this.meta.source = Some(Box::new(source)); this } - // No source when no std and no alloc crates + // No source when no alloc #[cfg(not(feature = "alloc"))] { let _ = source; @@ -64,20 +106,35 @@ impl Error { } } - pub fn into_other_kind(self) -> Error - where - Kind: Into, - { - Error { - context: self.context, - kind: self.kind.into(), - #[cfg(any(feature = "std", feature = "alloc"))] - source: self.source, + pub fn kind(&self) -> &Kind { + &self.kind + } + + /// Returns the source code location at which this error was constructed. + /// + /// Captured automatically by [`Error::new`] via [`core::panic::Location::caller`] + /// and `#[track_caller]`. Useful for diagnostic logging and error reporting + /// when the variant alone does not narrow down the call site enough. + pub fn location(&self) -> &'static core::panic::Location<'static> { + #[cfg(feature = "alloc")] + { + self.meta.location + } + #[cfg(not(feature = "alloc"))] + { + self.location } } - pub fn kind(&self) -> &Kind { - &self.kind + pub fn set_context(&mut self, context: &'static str) { + #[cfg(feature = "alloc")] + { + self.meta.context = context; + } + #[cfg(not(feature = "alloc"))] + { + self.context = context; + } } pub fn report(&self) -> ErrorReport<'_, Kind> { @@ -90,7 +147,28 @@ where Kind: fmt::Display, { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "[{}] {}", self.context, self.kind) + #[cfg(feature = "alloc")] + { + write!( + f, + "[{} @ {}:{}] {}", + self.meta.context, + self.meta.location.file(), + self.meta.location.line(), + self.kind + ) + } + #[cfg(not(feature = "alloc"))] + { + write!( + f, + "[{} @ {}:{}] {}", + self.context, + self.location.file(), + self.location.line(), + self.kind + ) + } } } @@ -103,8 +181,8 @@ where if let Some(source) = self.kind.source() { Some(source) } else { - // NOTE: we can’t use Option::as_ref here because of type inference - if let Some(e) = &self.source { + // NOTE: we can't use Option::as_ref here because of type inference + if let Some(e) = &self.meta.source { Some(e.as_ref()) } else { None @@ -155,10 +233,66 @@ where write!(f, "{}", self.0)?; #[cfg(feature = "alloc")] - if let Some(source) = &self.0.source { + if let Some(source) = &self.0.meta.source { write!(f, ", caused by: {source}")?; } Ok(()) } } + +/// Returns from the enclosing function with an [`Error`] built from a kind variant. +/// +/// Three forms are supported: +/// +/// - `bail!(kind)` — empty context. +/// - `bail!(context, kind)` — explicit `&'static str` context. +/// - `bail!(context, kind, source: source)` — explicit context plus a chained source error. +/// +/// The kind type is inferred from the enclosing function's return type, which must be +/// `Result<_, Error>` (or any type alias resolving to it). +/// +/// Mirrors the call-site shape of [`anyhow::bail!`] but produces a typed +/// `Error` rather than a type-erased `anyhow::Error`. +/// +/// [`anyhow::bail!`]: https://docs.rs/anyhow/latest/anyhow/macro.bail.html +#[macro_export] +macro_rules! bail { + ($kind:expr $(,)?) => { + return ::core::result::Result::Err($crate::Error::new("", $kind)) + }; + ($context:expr, $kind:expr $(,)?) => { + return ::core::result::Result::Err($crate::Error::new($context, $kind)) + }; + ($context:expr, $kind:expr, source: $source:expr $(,)?) => { + return ::core::result::Result::Err($crate::Error::new($context, $kind).with_source($source)) + }; +} + +/// Returns from the enclosing function with an [`Error`] if the given condition is false. +/// +/// Two forms are supported: +/// +/// - `ensure!(condition, kind)` — empty context. +/// - `ensure!(condition, context, kind)` — explicit `&'static str` context. +/// +/// The kind type is inferred from the enclosing function's return type, which must be +/// `Result<_, Error>` (or any type alias resolving to it). +/// +/// Mirrors the call-site shape of [`anyhow::ensure!`] but produces a typed +/// `Error` rather than a type-erased `anyhow::Error`. +/// +/// [`anyhow::ensure!`]: https://docs.rs/anyhow/latest/anyhow/macro.ensure.html +#[macro_export] +macro_rules! ensure { + ($condition:expr, $kind:expr $(,)?) => { + if !($condition) { + return ::core::result::Result::Err($crate::Error::new("", $kind)); + } + }; + ($condition:expr, $context:expr, $kind:expr $(,)?) => { + if !($condition) { + return ::core::result::Result::Err($crate::Error::new($context, $kind)); + } + }; +} diff --git a/crates/ironrdp-futures/CHANGELOG.md b/crates/ironrdp-futures/CHANGELOG.md index d0173f76a2..1ffd5aa90b 100644 --- a/crates/ironrdp-futures/CHANGELOG.md +++ b/crates/ironrdp-futures/CHANGELOG.md @@ -6,13 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-futures-v0.7.0...ironrdp-futures-v0.8.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-async` public dependency to 0.10 + + + +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-futures-v0.6.0...ironrdp-futures-v0.7.0)] - 2026-05-27 + +### Build + +- [**breaking**] Update `ironrdp-async` public dependency to 0.9 + +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-futures-v0.5.0...ironrdp-futures-v0.6.0)] - 2025-12-18 + + ## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-futures-v0.1.2...ironrdp-futures-v0.1.3)] - 2025-03-12 ### Build - Update dependencies (#695) ([c21fa44fd6](https://github.com/Devolutions/IronRDP/commit/c21fa44fd6f3c6a6b74788ff68e83133c1314caa)) - ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-futures-v0.1.1...ironrdp-futures-v0.1.2)] - 2025-01-28 ### Documentation diff --git a/crates/ironrdp-futures/Cargo.toml b/crates/ironrdp-futures/Cargo.toml index 2752bb0a34..aec911baf0 100644 --- a/crates/ironrdp-futures/Cargo.toml +++ b/crates/ironrdp-futures/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-futures" -version = "0.4.0" +version = "0.8.0" readme = "README.md" description = "`Framed*` traits implementation above futures’s traits" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -17,8 +18,7 @@ test = false [dependencies] futures-util = { version = "0.3", features = ["io"] } # public -ironrdp-async = { path = "../ironrdp-async", version = "0.6" } # public -bytes = "1" # public +ironrdp-async = { path = "../ironrdp-async", version = "0.10" } # public [lints] workspace = true diff --git a/crates/ironrdp-futures/src/lib.rs b/crates/ironrdp-futures/src/lib.rs index b9b76da490..673950794d 100644 --- a/crates/ironrdp-futures/src/lib.rs +++ b/crates/ironrdp-futures/src/lib.rs @@ -41,7 +41,7 @@ where S: Send + Sync + Unpin + AsyncRead, { type ReadFut<'read> - = Pin> + Send + Sync + 'read>> + = Pin> + Send + Sync + 'read>> where Self: 'read; @@ -64,7 +64,7 @@ where S: Send + Sync + Unpin + AsyncWrite, { type WriteAllFut<'write> - = Pin> + Send + Sync + 'write>> + = Pin> + Send + Sync + 'write>> where Self: 'write; @@ -111,7 +111,7 @@ where S: Unpin + AsyncRead, { type ReadFut<'read> - = Pin> + 'read>> + = Pin> + 'read>> where Self: 'read; @@ -134,7 +134,7 @@ where S: Unpin + AsyncWrite, { type WriteAllFut<'write> - = Pin> + 'write>> + = Pin> + 'write>> where Self: 'write; diff --git a/crates/ironrdp-fuzzing/Cargo.toml b/crates/ironrdp-fuzzing/Cargo.toml index 4d482f6d7a..4372335170 100644 --- a/crates/ironrdp-fuzzing/Cargo.toml +++ b/crates/ironrdp-fuzzing/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ironrdp-fuzzing" version = "0.0.0" -edition = "2021" +edition = "2024" description = "Provides test case generators and oracles for use with IronRDP fuzzing" publish = false @@ -11,6 +11,7 @@ test = false [dependencies] arbitrary = { version = "1", features = ["derive"] } +ironrdp-bulk.path = "../ironrdp-bulk" ironrdp-core.path = "../ironrdp-core" ironrdp-graphics.path = "../ironrdp-graphics" ironrdp-pdu.path = "../ironrdp-pdu" @@ -19,6 +20,7 @@ ironrdp-rdpdr.path = "../ironrdp-rdpdr" ironrdp-rdpsnd.path = "../ironrdp-rdpsnd" ironrdp-cliprdr-format.path = "../ironrdp-cliprdr-format" ironrdp-displaycontrol.path = "../ironrdp-displaycontrol" +ironrdp-egfx.path = "../ironrdp-egfx" ironrdp-svc.path = "../ironrdp-svc" [lints] diff --git a/crates/ironrdp-fuzzing/src/oracles/mod.rs b/crates/ironrdp-fuzzing/src/oracles/mod.rs index b04a6788c6..35891c895f 100644 --- a/crates/ironrdp-fuzzing/src/oracles/mod.rs +++ b/crates/ironrdp-fuzzing/src/oracles/mod.rs @@ -12,11 +12,113 @@ use crate::generators::BitmapInput; +// Bulk decompression oracles. Each target is algorithm-pinned so libFuzzer +// can build a per-algorithm corpus. The `flags` byte uses the bit layout +// from `ironrdp-bulk::flags`: low nibble selects the algorithm (per +// `CompressionType::from_flags`), `PACKET_COMPRESSED (0x20)` gates whether +// the decompressor will actually run (otherwise it returns the source slice +// unchanged). + +pub fn bulk_decompress_mppc(data: &[u8]) { + use ironrdp_bulk::{BulkCompressor, CompressionType, flags}; + + // First byte selects RDP4 (low bit clear) vs RDP5 (low bit set) so a + // single corpus exercises both MPPC modes via libFuzzer mutation across + // the byte boundary. + let Some((mode_byte, payload)) = data.split_first() else { + return; + }; + let (comp_type, algo_bits) = if mode_byte & 0x01 == 0 { + (CompressionType::Rdp4, 0x00) + } else { + (CompressionType::Rdp5, 0x01) + }; + let Ok(mut bulk) = BulkCompressor::new(comp_type) else { + return; + }; + let _ = bulk.decompress(payload, flags::PACKET_COMPRESSED | algo_bits); +} + +pub fn bulk_decompress_ncrush(data: &[u8]) { + use ironrdp_bulk::{BulkCompressor, CompressionType, flags}; + + let Ok(mut bulk) = BulkCompressor::new(CompressionType::Rdp6) else { + return; + }; + let _ = bulk.decompress(data, flags::PACKET_COMPRESSED | 0x02); +} + +pub fn bulk_decompress_xcrush(data: &[u8]) { + use ironrdp_bulk::{BulkCompressor, CompressionType, flags}; + + let Ok(mut bulk) = BulkCompressor::new(CompressionType::Rdp61) else { + return; + }; + let _ = bulk.decompress(data, flags::PACKET_COMPRESSED | 0x03); +} + +/// Round-trip oracle: compress uncompressed input then decompress the result, +/// assert byte-equality with the original. `BulkCompressor` holds both halves; +/// a fresh compressor and decompressor are constructed per call to avoid +/// sliding-window state leaking between fuzz iterations. +/// +/// # Panics +/// +/// Panics (reporting the bug to libFuzzer) when: +/// - `decompress` returns `Err` on input that `compress` just produced +/// (asymmetric compress/decompress bug), or +/// - the decompressed output does not equal the original input +/// (silent corruption bug in either half). +#[expect(clippy::panic, reason = "panic is the libFuzzer bug-reporting mechanism")] +pub fn bulk_round_trip(data: &[u8]) { + use ironrdp_bulk::{BulkCompressor, CompressionType, flags}; + + // First byte selects algorithm; remaining bytes are the uncompressed input. + let Some((algo_byte, src)) = data.split_first() else { + return; + }; + let algo = match algo_byte & 0x03 { + 0x00 => CompressionType::Rdp4, + 0x01 => CompressionType::Rdp5, + 0x02 => CompressionType::Rdp6, + _ => CompressionType::Rdp61, + }; + + let Ok(mut sender) = BulkCompressor::new(algo) else { + return; + }; + let Ok((compressed_size, compress_flags)) = sender.compress(src) else { + return; + }; + // Per `BulkCompressor::compress`'s contract, when `PACKET_COMPRESSED` is + // cleared the caller transmits `src` unchanged; the output buffer holds + // no meaningful data in that case. Selecting the wire payload here + // exercises both the real compressed path and the decompressor's + // pass-through branch on incompressible inputs. + let payload = if compress_flags & flags::PACKET_COMPRESSED == 0 { + src + } else { + sender.compressed_data(compressed_size) + }; + + let Ok(mut receiver) = BulkCompressor::new(algo) else { + return; + }; + let decompressed = receiver + .decompress(payload, compress_flags) + .unwrap_or_else(|e| panic!("bulk round-trip decompress failed for {algo:?}: {e:?}")); + assert_eq!(decompressed, src, "bulk round-trip byte-equality failed for {algo:?}",); +} + pub fn pdu_decode(data: &[u8]) { use ironrdp_core::decode; + use ironrdp_egfx::pdu::{ + Avc420BitmapStream, Avc444BitmapStream, CacheToSurfacePdu, Color, GfxPdu, Point, QuantQuality, + RawCapabilitySet as EgfxRawCapabilitySet, + }; use ironrdp_pdu::mcs::{ConnectInitial, ConnectResponse, McsMessage}; use ironrdp_pdu::nego::{ConnectionConfirm, ConnectionRequest}; - use ironrdp_pdu::rdp::{capability_sets, headers, server_error_info, server_license, vc, ClientInfoPdu}; + use ironrdp_pdu::rdp::{ClientInfoPdu, capability_sets, headers, server_error_info, server_license, vc}; use ironrdp_pdu::x224::X224; use ironrdp_pdu::{bitmap, codecs, fast_path, gcc, input, pcb, surface_commands}; @@ -70,6 +172,9 @@ pub fn pdu_decode(data: &[u8]) { let _ = decode::>(data); let _ = decode::>(data); + let _ = decode::(data); + let _ = decode::(data); + let _ = decode::>(data); let _ = decode::(data); @@ -77,6 +182,179 @@ pub fn pdu_decode(data: &[u8]) { let _ = decode::>(data); let _ = decode::(data); + + let _ = decode::(data); + let _ = decode::(data); + let _ = decode::(data); + let _ = decode::>(data); + let _ = decode::>(data); + let _ = decode::(data); + let _ = decode::(data); + let _ = decode::(data); +} + +/// Helper for [`pdu_round_trip`]. +/// +/// Exercises `decode` → `encode_vec` → re-`decode`, silently dropping `Err` +/// results from any stage. The oracle's value is in detecting INTERNAL +/// panics from inside the encoder/decoder (e.g., `unreachable!()` reached +/// on a valid decoded state), not in asserting Err-result symmetry. Many +/// `ironrdp-pdu` types have known asymmetric `Encode` impls that return +/// `"Encoding not implemented"` for variants the decoder still accepts; +/// those are tracked separately and not in scope for this oracle. +macro_rules! pdu_round_trip_one { + ($data:expr, $ty:ty) => {{ + if let Ok(pdu) = ironrdp_core::decode::<$ty>($data) { + if let Ok(encoded) = ironrdp_core::encode_vec(&pdu) { + let _ = ironrdp_core::decode::<$ty>(&encoded); + } + } + }}; +} + +/// Round-trip oracle: for each PDU type, exercise the +/// `decode` → `encode_vec` → re-`decode` pipeline. +/// +/// The property tested is *no internal panic from inside the encoder or +/// decoder when fed a decoder-accepted input through both directions of the +/// round-trip*. Asymmetric `Err` returns (decoder accepts something the +/// encoder reports as `"Encoding not implemented"`, or vice-versa) are not +/// in scope: those are tolerated incomplete-impl cases tracked separately. +/// +/// What this catches: +/// +/// - `unreachable!()` reached during encoding of a valid decoded state (i.e. +/// the encoder's match arms are missing a variant the decoder produces). +/// - Integer overflow / index-out-of-bounds inside the encoder on +/// decoder-accepted inputs. +/// - Panics in the decoder when fed encoder-produced bytes (re-decode path). +/// +/// What this does NOT catch: +/// +/// - Encode returning `Err`. Many PDU types intentionally return errors for +/// partially-implemented variants; exercising them is the encoder +/// developer's responsibility, not this oracle's. +/// - Re-decode returning `Err`. Surfaces an asymmetry but not a memory-safety +/// bug; tracked via filed follow-up issues, not this oracle. +/// +/// Initial type coverage mirrors `pdu_decode` so the same corpus feeds both +/// oracles. As new PDU types gain `Encode` impls, they auto-extend coverage +/// here when added to the macro list below. +pub fn pdu_round_trip(data: &[u8]) { + use ironrdp_pdu::mcs::{ConnectInitial, ConnectResponse, McsMessage}; + use ironrdp_pdu::nego::{ConnectionConfirm, ConnectionRequest}; + use ironrdp_pdu::rdp::capability_sets::CapabilitySet; + use ironrdp_pdu::rdp::headers::ShareControlHeader; + use ironrdp_pdu::rdp::{ClientInfoPdu, server_error_info, server_license, vc}; + use ironrdp_pdu::x224::X224; + use ironrdp_pdu::{bitmap, codecs, fast_path, gcc, input, pcb, surface_commands}; + + // Connection-time PDUs + pdu_round_trip_one!(data, X224); + pdu_round_trip_one!(data, X224); + pdu_round_trip_one!(data, X224>); + pdu_round_trip_one!(data, ConnectInitial); + pdu_round_trip_one!(data, ConnectResponse); + pdu_round_trip_one!(data, ClientInfoPdu); + pdu_round_trip_one!(data, pcb::PreconnectionBlob); + pdu_round_trip_one!(data, server_error_info::ServerSetErrorInfoPdu); + + // Capability sharing + pdu_round_trip_one!(data, CapabilitySet); + pdu_round_trip_one!(data, ShareControlHeader); + + // GCC blocks and conference creation + pdu_round_trip_one!(data, gcc::ClientGccBlocks); + pdu_round_trip_one!(data, gcc::ServerGccBlocks); + pdu_round_trip_one!(data, gcc::ClientClusterData); + pdu_round_trip_one!(data, gcc::ConferenceCreateRequest); + pdu_round_trip_one!(data, gcc::ConferenceCreateResponse); + + // Licensing + pdu_round_trip_one!(data, server_license::LicensePdu); + + // Virtual channel header + pdu_round_trip_one!(data, vc::ChannelPduHeader); + + // Fast-path framing + pdu_round_trip_one!(data, fast_path::FastPathHeader); + pdu_round_trip_one!(data, fast_path::FastPathUpdatePdu<'_>); + + // Surface commands + pdu_round_trip_one!(data, surface_commands::SurfaceCommand<'_>); + pdu_round_trip_one!(data, surface_commands::SurfaceBitsPdu<'_>); + pdu_round_trip_one!(data, surface_commands::FrameMarkerPdu); + pdu_round_trip_one!(data, surface_commands::ExtendedBitmapDataPdu<'_>); + pdu_round_trip_one!(data, surface_commands::BitmapDataHeader); + + // Codecs + pdu_round_trip_one!(data, codecs::rfx::Block<'_>); + + // Input + pdu_round_trip_one!(data, input::InputEventPdu); + pdu_round_trip_one!(data, input::InputEvent); + + // Bitmap RDP6 + pdu_round_trip_one!(data, bitmap::rdp6::BitmapStream<'_>); + + // Clipboard + pdu_round_trip_one!(data, ironrdp_cliprdr::pdu::ClipboardPdu<'_>); + pdu_round_trip_one!(data, ironrdp_cliprdr::pdu::PackedFileList); + pdu_round_trip_one!(data, ironrdp_cliprdr::pdu::FileContentsRequest); + pdu_round_trip_one!(data, ironrdp_cliprdr::pdu::FileContentsResponse<'_>); + + // RDPDR + pdu_round_trip_one!(data, ironrdp_rdpdr::pdu::RdpdrPdu); + + // Display control + pdu_round_trip_one!(data, ironrdp_displaycontrol::pdu::DisplayControlPdu); + + // RDPSND + pdu_round_trip_one!(data, ironrdp_rdpsnd::pdu::ServerAudioOutputPdu<'_>); + pdu_round_trip_one!(data, ironrdp_rdpsnd::pdu::ClientAudioOutputPdu); +} + +/// Round-trip oracle for `ironrdp-egfx` PDU types: `decode` → `encode_vec` → re-`decode`. +/// +/// Same shape and property as [`pdu_round_trip`] but scoped to `ironrdp-egfx`'s +/// own encoder surface. This is the egfx-scoped sibling of the `pdu_round_trip` +/// oracle and the first target under the egfx fuzz-coverage umbrella tracked at +/// the egfx-fuzz issue. +/// +/// Coverage: +/// +/// - `GfxPdu` is the top-level egfx command dispatch and transitively covers +/// `WireToSurface1Pdu`, `WireToSurface2Pdu`, `SolidFillPdu`, +/// `SurfaceToSurfacePdu`, `SurfaceToCachePdu`, `CacheToSurfacePdu`, +/// `EvictCacheEntryPdu`, `CreateSurfacePdu`, `DeleteSurfacePdu`, +/// `StartFramePdu`, `EndFramePdu`, `ResetGraphicsPdu`, +/// `MapSurfaceToOutputPdu`, `MapSurfaceToWindowPdu`, +/// `MapSurfaceToScaledOutputPdu`, `MapSurfaceToScaledWindowPdu`, +/// `FrameAcknowledgePdu`, `QoeFrameAcknowledgePdu`, +/// `DeleteEncodingContextPdu`, `CacheImportOfferPdu`, `CacheImportReplyPdu`. +/// - `CapabilitiesAdvertisePdu` and `CapabilitiesConfirmPdu` exercise the +/// capability-negotiation encoder surface (with `RawCapabilitySet` payloads +/// post-#1305's wire/typed split). +/// - `Avc420BitmapStream` and `Avc444BitmapStream` exercise the H.264 wire +/// container encoder. +/// +/// What this catches: same as `pdu_round_trip` — `unreachable!()` reached on +/// decoder-accepted inputs, integer overflow / OOB in egfx encoders, panics +/// in the decoder when fed encoder-produced bytes. +/// +/// What this does NOT catch: the OpenH264 input-construction wrapper, ZGFX +/// decompression, multi-frame H.264 state. Those are sibling targets in the +/// egfx fuzz-coverage umbrella. +pub fn egfx_round_trip(data: &[u8]) { + use ironrdp_egfx::pdu::{ + Avc420BitmapStream, Avc444BitmapStream, CapabilitiesAdvertisePdu, CapabilitiesConfirmPdu, GfxPdu, + }; + + pdu_round_trip_one!(data, GfxPdu); + pdu_round_trip_one!(data, CapabilitiesAdvertisePdu); + pdu_round_trip_one!(data, CapabilitiesConfirmPdu); + pdu_round_trip_one!(data, Avc420BitmapStream<'_>); + pdu_round_trip_one!(data, Avc444BitmapStream<'_>); } pub fn rle_decompress_bitmap(input: BitmapInput<'_>) { @@ -114,8 +392,8 @@ pub fn rdp6_decode_bitmap_stream_to_rgb24(input: &BitmapInput<'_>) { let _ = BitmapStreamDecoder::default().decode_bitmap_stream_to_rgb24( input.src, &mut out, - input.width as usize, - input.height as usize, + usize::from(input.width), + usize::from(input.height), ); } @@ -145,3 +423,47 @@ pub fn channel_process(input: &[u8]) { let _ = rdpdr.process(input); } + +pub fn cliprdr_channel_process(input: &[u8]) { + use ironrdp_svc::SvcProcessor as _; + + let mut cliprdr = ironrdp_cliprdr::Cliprdr::::new(Box::new(NoopCliprdrFuzzBackend)); + let _ = cliprdr.process(input); +} + +/// Minimal backend for fuzzing that enables file transfer capabilities +/// so the fuzzer can exercise lock, file list, and file contents paths. +#[derive(Debug)] +struct NoopCliprdrFuzzBackend; + +ironrdp_core::impl_as_any!(NoopCliprdrFuzzBackend); + +impl ironrdp_cliprdr::backend::CliprdrBackend for NoopCliprdrFuzzBackend { + fn temporary_directory(&self) -> &str { + "/tmp" + } + + fn client_capabilities(&self) -> ironrdp_cliprdr::pdu::ClipboardGeneralCapabilityFlags { + use ironrdp_cliprdr::pdu::ClipboardGeneralCapabilityFlags; + ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + | ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA + | ClipboardGeneralCapabilityFlags::FILECLIP_NO_FILE_PATHS + | ClipboardGeneralCapabilityFlags::HUGE_FILE_SUPPORT_ENABLED + } + + fn on_ready(&mut self) {} + fn on_request_format_list(&mut self) {} + fn on_process_negotiated_capabilities(&mut self, _: ironrdp_cliprdr::pdu::ClipboardGeneralCapabilityFlags) {} + fn on_remote_copy(&mut self, _: &[ironrdp_cliprdr::pdu::ClipboardFormat]) {} + fn on_format_data_request(&mut self, _: ironrdp_cliprdr::pdu::FormatDataRequest) {} + fn on_format_data_response(&mut self, _: ironrdp_cliprdr::pdu::FormatDataResponse<'_>) {} + fn on_file_contents_request(&mut self, _: ironrdp_cliprdr::pdu::FileContentsRequest) {} + fn on_file_contents_response(&mut self, _: ironrdp_cliprdr::pdu::FileContentsResponse<'_>) {} + fn on_lock(&mut self, _: ironrdp_cliprdr::pdu::LockDataId) {} + fn on_unlock(&mut self, _: ironrdp_cliprdr::pdu::LockDataId) {} + + // Fixed clock so fuzz runs are reproducible regardless of wall-clock timing + fn now_ms(&self) -> u64 { + 0 + } +} diff --git a/crates/ironrdp-glutin-renderer/Cargo.toml b/crates/ironrdp-glutin-renderer/Cargo.toml index d418a34230..750b5a7d2b 100644 --- a/crates/ironrdp-glutin-renderer/Cargo.toml +++ b/crates/ironrdp-glutin-renderer/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" readme = "README.md" description = "`glutin` primitives for OpenGL rendering" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -11,13 +12,17 @@ authors.workspace = true keywords.workspace = true categories.workspace = true +[features] +default = ["openh264"] +openh264 = ["dep:openh264"] + [dependencies] ironrdp.workspace = true tracing.workspace = true thiserror.workspace = true glow = "0.12" glutin = { version = "0.29" } -openh264 = { version = "0.4" } +openh264 = { version = "0.6", optional = true, default-features = false, features = ["libloading"] } [lints] workspace = true diff --git a/crates/ironrdp-glutin-renderer/src/renderer.rs b/crates/ironrdp-glutin-renderer/src/renderer.rs index cadda4e9ad..b08e28b3ce 100644 --- a/crates/ironrdp-glutin-renderer/src/renderer.rs +++ b/crates/ironrdp-glutin-renderer/src/renderer.rs @@ -12,7 +12,9 @@ use ironrdp::pdu::dvc::gfx::{Codec1Type, ServerPdu}; use ironrdp::pdu::geometry::Rectangle; use thiserror::Error; -use crate::surface::{DataBuffer, SurfaceDecoders, Surfaces}; +#[cfg(feature = "openh264")] +use crate::surface::SurfaceDecoders; +use crate::surface::{DataBuffer, Surfaces}; #[derive(Debug)] enum RenderEvent { @@ -36,14 +38,17 @@ impl Debug for DataRegion { } } -/// Runs the decode loop to decode any graphics PDU +/// Runs the decode loop for graphics PDUs, using Cisco's prebuilt OpenH264 +/// binary (loaded at runtime via `libloading`) for H.264 decoding. +#[cfg(feature = "openh264")] fn handle_gfx_pdu( graphic_receiver: Receiver, gfx_dump_file: Option, + openh264_path: PathBuf, tx: Sender, ) -> Result<(), RendererError> { let mut file = gfx_dump_file.map(|file| File::create(file).unwrap()); - let mut decoders = SurfaceDecoders::new(); + let mut decoders = SurfaceDecoders::new(openh264_path); loop { let message = graphic_receiver .recv() @@ -124,25 +129,28 @@ fn handle_draw( } } -/// The renderer launches two threads to handle graphics messages. -/// The first thread takes any graphics PDU and decodes the messages. -/// The second thread paints the messages onto the canvas +/// Launches two threads for graphics handling: one decodes EGFX PDUs (using +/// OpenH264 for H.264 when the `openh264` feature is enabled), and one paints +/// decoded frames onto the OpenGL canvas. +#[cfg(feature = "openh264")] pub struct Renderer { render_proxy: Sender, _decode_thread: JoinHandle>, _draw_thread: JoinHandle>, } +#[cfg(feature = "openh264")] impl Renderer { pub fn new( window: glutin::ContextWrapper, graphic_receiver: Receiver, gfx_dump_file: Option, + openh264_path: PathBuf, ) -> Renderer { let (tx, rx) = mpsc::channel::(); let tx2 = tx.clone(); let decode_thread = thread::spawn(move || { - let result = handle_gfx_pdu(graphic_receiver, gfx_dump_file, tx2); + let result = handle_gfx_pdu(graphic_receiver, gfx_dump_file, openh264_path, tx2); info!("Graphics handler result: {:?}", result); result }); @@ -171,6 +179,7 @@ pub enum RendererError { SendError(String), #[error("unable to receive message on channel {0}")] ReceiveError(String), + #[cfg(feature = "openh264")] #[error("failed to decode OpenH264 stream {0}")] OpenH264Error(#[from] openh264::Error), #[error("graphics pipeline protocol error: {0}")] diff --git a/crates/ironrdp-glutin-renderer/src/surface.rs b/crates/ironrdp-glutin-renderer/src/surface.rs index aabc967b13..974da0514e 100644 --- a/crates/ironrdp-glutin-renderer/src/surface.rs +++ b/crates/ironrdp-glutin-renderer/src/surface.rs @@ -7,12 +7,14 @@ use ironrdp::pdu::dvc::gfx::{ Avc420BitmapStream, Avc444BitmapStream, Codec1Type, CreateSurfacePdu, Encoding, GraphicsPipelineError, PixelFormat, WireToSurface1Pdu, }; -use ironrdp::pdu::geometry::{ - Rectangle as _, - InclusiveRectangle, -}; +use ironrdp::pdu::geometry::{InclusiveRectangle, Rectangle as _}; use ironrdp::pdu::PduBufferParsing; -use openh264::decoder::{DecodedYUV, Decoder}; +#[cfg(feature = "openh264")] +use openh264::decoder::Decoder; +#[cfg(feature = "openh264")] +use openh264::formats::YUVSource; +#[cfg(feature = "openh264")] +use openh264::OpenH264API; use crate::draw::DrawingContext; use crate::renderer::RendererError; @@ -34,18 +36,25 @@ impl Debug for DataRegion { } } +#[cfg(feature = "openh264")] pub struct SurfaceDecoders { + library_path: std::path::PathBuf, decoders: HashMap, } +#[cfg(feature = "openh264")] impl SurfaceDecoders { - pub fn new() -> Self { + pub fn new(library_path: std::path::PathBuf) -> Self { SurfaceDecoders { + library_path, decoders: HashMap::new(), } } + pub fn add(&mut self, id: u16) -> Result<()> { - self.decoders.insert(id, Decoder::new()?); + let api = OpenH264API::from_blob_path(&self.library_path)?; + let decoder = Decoder::with_api_config(api, Default::default())?; + self.decoders.insert(id, decoder); Ok(()) } @@ -64,10 +73,10 @@ impl SurfaceDecoders { let packet = Avc420BitmapStream::from_buffer_consume(&mut pdu.bitmap_data.as_slice()) .map_err(GraphicsPipelineError::from)?; let yuv = decoder.decode(packet.data)?.ok_or(RendererError::DecodeError)?; - let dimensions = yuv.dimension_rgb(); - let strides = yuv.strides_yuv(); + let dimensions = yuv.dimensions(); + let strides = yuv.strides(); let regions = packet.rectangles; - let data = convert_to_buffer(yuv); + let data = convert_yuv_to_buffer(&yuv); let data1 = DataRegion { data, regions }; Ok(DataBuffer { main: Some(data1), @@ -83,16 +92,16 @@ impl SurfaceDecoders { let packet = Avc444BitmapStream::from_buffer_consume(&mut pdu.bitmap_data.as_slice()) .map_err(GraphicsPipelineError::from)?; let yuv = decoder.decode(packet.stream1.data)?.ok_or(RendererError::DecodeError)?; - let dimensions = yuv.dimension_rgb(); - let strides = yuv.strides_yuv(); + let dimensions = yuv.dimensions(); + let strides = yuv.strides(); let regions = packet.stream1.rectangles; - let data = convert_to_buffer(yuv); + let data = convert_yuv_to_buffer(&yuv); let data1 = DataRegion { data, regions }; let data2 = if packet.encoding == Encoding::LUMA_AND_CHROMA { let aux = packet.stream2.unwrap(); let yuv = decoder.decode(aux.data)?.ok_or(RendererError::DecodeError)?; - let data = convert_to_buffer(yuv); + let data = convert_yuv_to_buffer(&yuv); let regions = aux.rectangles; Some(DataRegion { data, regions }) } else { @@ -319,17 +328,17 @@ impl Surfaces { } } -/// Convert the decoded data to a buffer. OpenH264 documentation says that if -/// the data is not immediately used it should be copied out. -fn convert_to_buffer(yuv: DecodedYUV) -> Vec { - let y = yuv.y_with_stride(); - let u = yuv.u_with_stride(); - let v = yuv.v_with_stride(); +/// Copy YUV planes into a contiguous buffer. OpenH264 documentation says that +/// decoded data must be copied out if not used immediately. +#[cfg(feature = "openh264")] +fn convert_yuv_to_buffer(yuv: &impl YUVSource) -> Vec { + let y = yuv.y(); + let u = yuv.u(); + let v = yuv.v(); let total_len = y.len() + u.len() + v.len(); let mut data = vec![0; total_len]; - let data_slice = data.as_mut_slice(); - data_slice[0..y.len()].copy_from_slice(&y[0..]); - data_slice[y.len()..y.len() + u.len()].copy_from_slice(&u[0..]); - data_slice[y.len() + u.len()..y.len() + u.len() + v.len()].copy_from_slice(&v[0..]); + data[..y.len()].copy_from_slice(y); + data[y.len()..y.len() + u.len()].copy_from_slice(u); + data[y.len() + u.len()..].copy_from_slice(v); data } diff --git a/crates/ironrdp-graphics/CHANGELOG.md b/crates/ironrdp-graphics/CHANGELOG.md index 6758ed83a7..8dba87b85b 100644 --- a/crates/ironrdp-graphics/CHANGELOG.md +++ b/crates/ironrdp-graphics/CHANGELOG.md @@ -6,6 +6,99 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-graphics-v0.8.1...ironrdp-graphics-v0.9.0)] - 2026-07-10 + +### Bug Fixes + +- Don't require CONTEXT block on every progressive frame ([#1395](https://github.com/Devolutions/IronRDP/issues/1395)) ([368fe8e68b](https://github.com/Devolutions/IronRDP/commit/368fe8e68b2d5d72da2e15dcf99469b98e965a2b)) + + Fixes progressive RemoteFX (MS-RDPEGFX) decoding by no longer requiring a CONTEXT block on every WireToSurface2 progressive frame once a codec context has already been established (keyed by codec_context_id). This aligns the decoder with real-world server behavior and the spec’s “establish once, then reference” model for progressive contexts. + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + +## [[0.8.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-graphics-v0.8.0...ironrdp-graphics-v0.8.1)] - 2026-06-05 + +### Bug Fixes + +- Bound ZGFX compressor hash table size ([#1344](https://github.com/Devolutions/IronRDP/issues/1344)) ([4e11a17617](https://github.com/Devolutions/IronRDP/commit/4e11a1761750bb706f5c3cef370589d0eb63fc45)) + + Bounds the ZGFX compressor's hash table to prevent O(n·table_size) per-frame compaction on incompressible payloads (e.g., already-encoded H.264). Previously, `compact_hash_table` only halved per-prefix position lists without reducing prefix count, so high-entropy input kept the table above the cap and triggered compaction on every literal byte. The fix evicts whole least-recently-seen prefixes down to a low watermark (half the cap), amortizing compaction to O(1) per byte while preserving reachable matches (distance is already capped at `MAX_MATCH_DISTANCE`). + + + +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-graphics-v0.7.0...ironrdp-graphics-v0.8.0)] - 2026-05-27 + +### Features + +- Add segment wrapping utilities ([#1076](https://github.com/Devolutions/IronRDP/issues/1076)) ([5fa4964807](https://github.com/Devolutions/IronRDP/commit/5fa4964807fa15bbf1a5e3c23b365344758961aa)) + + Adds ZGFX segment wrapping utilities for encoding data in RDP8 format. + +- Add LZ77 compression support ([#1097](https://github.com/Devolutions/IronRDP/issues/1097)) ([48715483a3](https://github.com/Devolutions/IronRDP/commit/48715483a36c824af034a51f4db0580c34825d63)) + + Adds ZGFX (RDP8) LZ77 compression to complement the existing + decompressor, plus a high-level API for EGFX PDU preparation with + auto/always/never mode selection. + + The compressor uses a hash table mapping 3-byte prefixes to history + positions for O(1) match candidate lookup against the 2.5 MB sliding + window. + +- Complete pixel format support for bitmap updates ([#1134](https://github.com/Devolutions/IronRDP/issues/1134)) ([a6b41093ce](https://github.com/Devolutions/IronRDP/commit/a6b41093ce4ece081d2538c157f6bc547c3b2607)) + + Wires missing bitmap pixel formats (8/15/24bpp) into the session rendering + pipeline so bitmap updates at those depths are rendered instead of being + dropped, and adds fast-path palette update parsing to support 8bpp indexed + color sessions. + +- Add RemoteFX Progressive codec primitives ([#1196](https://github.com/Devolutions/IronRDP/issues/1196)) ([49099f0c31](https://github.com/Devolutions/IronRDP/commit/49099f0c3136c25b67801fb1b07f78542dc796de)) + + Add wire-format types for RemoteFX Progressive Codec (MS-RDPRFX + Progressive Extension) and the computational primitives required for progressive refinement. + +- Add progressive RFX decode and EGFX integration ([#1197](https://github.com/Devolutions/IronRDP/issues/1197)) ([a142799d1d](https://github.com/Devolutions/IronRDP/commit/a142799d1dcbdcd6546ec6e75173fbfe66f0ea67)) + +- Add progressive RFX server encode and mixed-codec frames ([#1198](https://github.com/Devolutions/IronRDP/issues/1198)) ([6d43d2692d](https://github.com/Devolutions/IronRDP/commit/6d43d2692d206b7557f722f294d3e51d7eac8ab1)) + +- Add ClearCodec bitmap compression codec ([#1174](https://github.com/Devolutions/IronRDP/issues/1174)) ([059ca902a5](https://github.com/Devolutions/IronRDP/commit/059ca902a5518113163042225bc5d2088869933a)) + +### Bug Fixes + +- Fix pixel format handling in bitmap decoders ([#1101](https://github.com/Devolutions/IronRDP/issues/1101)) ([75863245ab](https://github.com/Devolutions/IronRDP/commit/75863245ab376f15e35c00df434860c93b123633)) + +- Replace all from_bits_truncate with from_bits_retain ([#1144](https://github.com/Devolutions/IronRDP/issues/1144)) ([353e30ddfd](https://github.com/Devolutions/IronRDP/commit/353e30ddfdaafc897db10b8663e364ef7775a7fd)) + + from_bits_truncate silently discards unknown bits, which breaks the + encode/decode round-trip property. This matters for fuzzing because a + PDU that decodes and re-encodes should produce identical bytes. + from_bits_retain preserves all bits, including those not yet defined in + our bitflags types, so the round-trip property holds. + +### Build + +- Bump the patch group across 1 directory with 2 updates ([#1222](https://github.com/Devolutions/IronRDP/issues/1222)) ([3fe6d157e0](https://github.com/Devolutions/IronRDP/commit/3fe6d157e0b55bddfdac20af290a6cfa6e550576)) + + +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-graphics-v0.6.0...ironrdp-graphics-v0.7.0)] - 2025-12-18 + +### Added + +- [**breaking**] `InvalidIntegralConversion` variant in `RlgrError` and `ZgfxError` + +### Build + +- Bump bytemuck from 1.23.2 to 1.24.0 ([#1008](https://github.com/Devolutions/IronRDP/issues/1008)) ([a24a1fa9e8](https://github.com/Devolutions/IronRDP/commit/a24a1fa9e8f1898b2fcdd41d87660ab9e38f89ed)) + +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-graphics-v0.5.0...ironrdp-graphics-v0.6.0)] - 2025-06-27 + +### Bug Fixes + +- `to_64x64_ycbcr_tile` now returns a `Result` + ## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-graphics-v0.4.0...ironrdp-graphics-v0.4.1)] - 2025-06-27 ### Build @@ -20,15 +113,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Add some helper to find "damaged" regions, as 64x64 tiles. - ## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-graphics-v0.2.0...ironrdp-graphics-v0.3.0)] - 2025-03-12 ### Build - Bump ironrdp-pdu - - ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-graphics-v0.1.2...ironrdp-graphics-v0.2.0)] - 2025-03-07 ### Performance @@ -46,8 +136,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-graphics-v0.1.0...ironrdp-graphics-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-graphics/Cargo.toml b/crates/ironrdp-graphics/Cargo.toml index ab5cc81d4b..c9ba81c255 100644 --- a/crates/ironrdp-graphics/Cargo.toml +++ b/crates/ironrdp-graphics/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-graphics" -version = "0.4.1" +version = "0.9.0" readme = "README.md" description = "RDP image processing primitives" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -17,19 +18,18 @@ doctest = false [dependencies] bit_field = "0.10" -bitflags = "2.9" +bitflags = "2.11" bitvec = "1.0" -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5", features = ["std"] } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["std"] } # public byteorder = "1.5" # TODO: remove -lazy_static.workspace = true # Legacy crate; prefer std::sync::LazyLock or LazyCell num-derive.workspace = true # TODO: remove num-traits.workspace = true # TODO: remove yuv = { version = "0.8", features = ["rdp"] } [dev-dependencies] bmp = "0.5" -bytemuck = "1.23" +bytemuck = "1.24" expect-test.workspace = true [lints] diff --git a/crates/ironrdp-graphics/src/clearcodec/glyph_cache.rs b/crates/ironrdp-graphics/src/clearcodec/glyph_cache.rs new file mode 100644 index 0000000000..519d6b8108 --- /dev/null +++ b/crates/ironrdp-graphics/src/clearcodec/glyph_cache.rs @@ -0,0 +1,98 @@ +//! Glyph cache for ClearCodec (MS-RDPEGFX 2.2.4.1). +//! +//! When a bitmap area is <= 1024 pixels, ClearCodec can index it in a +//! 4,000-entry glyph cache. On a cache hit (FLAG_GLYPH_HIT), the previously +//! cached pixel data is reused without retransmission. + +/// Maximum number of glyph cache entries. +pub const GLYPH_CACHE_SIZE: usize = 4_000; + +/// A cached glyph entry: BGRA pixel data with dimensions. +#[derive(Debug, Clone)] +pub struct GlyphEntry { + pub width: u16, + pub height: u16, + /// BGRA pixel data (4 bytes per pixel). + pub pixels: Vec, +} + +/// Glyph cache for ClearCodec bitmap deduplication. +pub struct GlyphCache { + entries: Vec>, +} + +impl GlyphCache { + pub fn new() -> Self { + let mut entries = Vec::with_capacity(GLYPH_CACHE_SIZE); + entries.resize_with(GLYPH_CACHE_SIZE, || None); + Self { entries } + } + + /// Look up a glyph by its cache index. + pub fn get(&self, index: u16) -> Option<&GlyphEntry> { + self.entries.get(usize::from(index)).and_then(|slot| slot.as_ref()) + } + + /// Store a glyph at the given index. + /// + /// Returns `true` if the index was valid and the entry was stored. + pub fn store(&mut self, index: u16, entry: GlyphEntry) -> bool { + let idx = usize::from(index); + if idx < GLYPH_CACHE_SIZE { + self.entries[idx] = Some(entry); + true + } else { + false + } + } + + /// Reset the entire glyph cache, removing all entries. + pub fn reset(&mut self) { + for slot in &mut self.entries { + *slot = None; + } + } +} + +impl Default for GlyphCache { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn store_and_retrieve() { + let mut cache = GlyphCache::new(); + let entry = GlyphEntry { + width: 8, + height: 16, + pixels: vec![0xFF; 8 * 16 * 4], + }; + assert!(cache.store(42, entry)); + let retrieved = cache.get(42).unwrap(); + assert_eq!(retrieved.width, 8); + assert_eq!(retrieved.height, 16); + } + + #[test] + fn get_empty_returns_none() { + let cache = GlyphCache::new(); + assert!(cache.get(0).is_none()); + assert!(cache.get(3999).is_none()); + } + + #[test] + fn reject_out_of_range() { + let mut cache = GlyphCache::new(); + let entry = GlyphEntry { + width: 1, + height: 1, + pixels: vec![0; 4], + }; + assert!(!cache.store(4000, entry)); + } +} diff --git a/crates/ironrdp-graphics/src/clearcodec/mod.rs b/crates/ironrdp-graphics/src/clearcodec/mod.rs new file mode 100644 index 0000000000..ab6e653c5f --- /dev/null +++ b/crates/ironrdp-graphics/src/clearcodec/mod.rs @@ -0,0 +1,764 @@ +//! ClearCodec bitmap decoder and encoder (MS-RDPEGFX 2.2.4.1). +//! +//! ClearCodec is a mandatory lossless codec for EGFX that uses three-layer +//! compositing (residual BGR RLE, bands with V-bar caching, subcodecs) to +//! efficiently encode text, UI elements, and icons. + +mod glyph_cache; +mod vbar_cache; + +pub use self::glyph_cache::{GLYPH_CACHE_SIZE, GlyphCache, GlyphEntry}; +pub use self::vbar_cache::{FullVBar, ShortVBar, VBarCache}; + +/// Glyph cache size as u16 for index arithmetic. GLYPH_CACHE_SIZE=4000 fits in u16. +const GLYPH_CACHE_WRAP: u16 = 4_000; + +use ironrdp_core::{DecodeResult, ReadCursor, invalid_field_err}; +use ironrdp_pdu::codecs::clearcodec::{ + ClearCodecBitmapStream, CompositePayload, FLAG_GLYPH_INDEX, RgbRunSegment, SubcodecId, VBar, decode_bands_layer, + decode_residual_layer, decode_subcodec_layer, encode_residual_layer, +}; + +/// ClearCodec decoder maintaining persistent cache state across frames. +pub struct ClearCodecDecoder { + vbar_cache: VBarCache, + glyph_cache: GlyphCache, +} + +impl ClearCodecDecoder { + pub fn new() -> Self { + Self { + vbar_cache: VBarCache::new(), + glyph_cache: GlyphCache::new(), + } + } + + /// Decode a ClearCodec bitmap stream into BGRA pixel data. + /// + /// The output buffer is `width * height * 4` bytes in BGRA format. + /// The caller is responsible for compositing the result onto the target + /// surface at the destination rectangle. + /// + /// **Alpha contract:** ClearCodec is lossless on the three color channels + /// (B, G, R) per MS-RDPEGFX 2.2.4.1. The wire format does not transmit + /// alpha; this decoder fills the alpha byte of every output pixel with + /// `0xFF` unconditionally. Callers that need to preserve alpha across the + /// network must transport it separately. + pub fn decode(&mut self, data: &[u8], width: u16, height: u16) -> DecodeResult> { + let mut src = ReadCursor::new(data); + let stream = ClearCodecBitmapStream::decode(&mut src)?; + + // Handle cache reset + if stream.is_cache_reset() { + self.vbar_cache.reset(); + } + + // Validate glyph index range per spec: 0..3999 inclusive + if let Some(idx) = stream.glyph_index { + if idx >= GLYPH_CACHE_WRAP { + return Err(invalid_field_err!("glyphIndex", "glyph index out of range 0-3999")); + } + } + + let w = usize::from(width); + let h = usize::from(height); + let pixel_count = w + .checked_mul(h) + .ok_or_else(|| invalid_field_err!("dimensions", "width * height overflow"))?; + + // Handle glyph hit: return cached pixel data + if stream.is_glyph_hit() { + let glyph_index = stream + .glyph_index + .ok_or_else(|| invalid_field_err!("flags", "GLYPH_HIT without GLYPH_INDEX"))?; + let entry = self + .glyph_cache + .get(glyph_index) + .ok_or_else(|| invalid_field_err!("glyphIndex", "glyph cache miss on hit"))?; + if entry.width != width || entry.height != height { + return Err(invalid_field_err!("glyphIndex", "cached glyph dimensions mismatch")); + } + return Ok(entry.pixels.clone()); + } + + // Cap allocation to prevent OOM from adversarial dimensions. + // MS-RDPEGFX caps surfaces at 32767x32767; the spec does not + // mandate a separate tile cap. We cap each tile dimension at + // 8192 (supports 8K displays at 7680x4320 plus headroom). The + // per-dimension form rather than a per-pixel-count form is + // important because the original pixel-count cap (8192*8192 + // = 67M) accepted degenerate aspect ratios like 63961x771 + // (49M pixels, under cap) that allocate ~197MB from a few + // attacker-controlled bytes. Capping each axis directly + // rejects implausible tile shapes regardless of total area. + const MAX_DECODE_DIM: u16 = 8192; + if width > MAX_DECODE_DIM || height > MAX_DECODE_DIM { + return Err(invalid_field_err!( + "dimensions", + "width or height exceeds 8192-pixel decoder limit" + )); + } + + // Decode composite payload + let mut output = vec![0u8; pixel_count * 4]; + + if let Some(ref composite) = stream.composite { + self.decode_composite(composite, &mut output, width, height)?; + } + + // Store in glyph cache if applicable (area <= 1024 pixels) + if stream.flags & FLAG_GLYPH_INDEX != 0 { + if let Some(glyph_index) = stream.glyph_index { + if pixel_count <= 1024 { + self.glyph_cache.store( + glyph_index, + GlyphEntry { + width, + height, + pixels: output.clone(), + }, + ); + } + } + } + + Ok(output) + } + + fn decode_composite( + &mut self, + composite: &CompositePayload<'_>, + output: &mut [u8], + width: u16, + _height: u16, + ) -> DecodeResult<()> { + let w = usize::from(width); + + // Layer 1: Residual (BGR RLE) - fills the entire output. + // Cap pixel writes to the output buffer size to prevent CPU-spin DoS + // from adversarial run_length values (FreeRDP CVE GHSA-32q9-m5qr-9j2v). + if !composite.residual_data.is_empty() { + let segments = decode_residual_layer(composite.residual_data)?; + let max_offset = output.len(); + let mut offset = 0; + for seg in &segments { + let pixels_remaining = (max_offset.saturating_sub(offset)) / 4; + let effective_run = u32::try_from(pixels_remaining).unwrap_or(u32::MAX).min(seg.run_length); + for _ in 0..effective_run { + output[offset] = seg.blue; + output[offset + 1] = seg.green; + output[offset + 2] = seg.red; + output[offset + 3] = 0xFF; // Alpha + offset += 4; + } + if offset >= max_offset { + break; + } + } + } + + // Layer 2: Bands (V-bar cached columns) - composite on top + if !composite.bands_data.is_empty() { + let bands = decode_bands_layer(composite.bands_data)?; + for band in &bands { + let band_height = band.y_end - band.y_start + 1; + for (col_offset, vbar) in band.vbars.iter().enumerate() { + let x = usize::from(band.x_start) + col_offset; + if x >= w { + continue; + } + + let full_vbar = + self.resolve_vbar(vbar, band_height, band.blue_bkg, band.green_bkg, band.red_bkg)?; + + // Blit the full V-bar column into the output + let pixel_rows = full_vbar.pixels.len() / 3; + for row in 0..pixel_rows { + let y = usize::from(band.y_start) + row; + let dst_offset = (y * w + x) * 4; + let src_offset = row * 3; + if dst_offset + 3 < output.len() && src_offset + 2 < full_vbar.pixels.len() { + output[dst_offset] = full_vbar.pixels[src_offset]; + output[dst_offset + 1] = full_vbar.pixels[src_offset + 1]; + output[dst_offset + 2] = full_vbar.pixels[src_offset + 2]; + output[dst_offset + 3] = 0xFF; + } + } + } + } + } + + // Layer 3: Subcodecs - composite on top + if !composite.subcodec_data.is_empty() { + let subcodecs = decode_subcodec_layer(composite.subcodec_data)?; + for sub in &subcodecs { + self.decode_subcodec_region(sub, output, width)?; + } + } + + Ok(()) + } + + fn resolve_vbar( + &mut self, + vbar: &VBar<'_>, + band_height: u16, + bg_blue: u8, + bg_green: u8, + bg_red: u8, + ) -> DecodeResult { + match vbar { + VBar::CacheHit { index } => { + let cached = self + .vbar_cache + .get_vbar(*index) + .ok_or_else(|| invalid_field_err!("vbarIndex", "V-bar cache miss on hit"))?; + Ok(cached.clone()) + } + VBar::ShortCacheHit { index, y_on } => { + let cached_short = self + .vbar_cache + .get_short_vbar(*index) + .ok_or_else(|| invalid_field_err!("shortVbarIndex", "short V-bar cache miss on hit"))?; + // Create a modified short vbar with the y_on from this reference + let modified = ShortVBar { + y_on: *y_on, + pixel_count: cached_short.pixel_count, + pixels: cached_short.pixels.clone(), + }; + let full = VBarCache::reconstruct_full_vbar(&modified, band_height, bg_blue, bg_green, bg_red); + // Store reconstructed full V-bar in cache + self.vbar_cache.store_vbar(full.clone()); + Ok(full) + } + VBar::ShortCacheMiss(miss) => { + let short = ShortVBar { + y_on: miss.y_on, + pixel_count: miss.y_off_delta, + pixels: miss.pixel_data.to_vec(), + }; + // Store in short V-bar cache + self.vbar_cache.store_short_vbar(short.clone()); + // Reconstruct and store full V-bar + let full = VBarCache::reconstruct_full_vbar(&short, band_height, bg_blue, bg_green, bg_red); + self.vbar_cache.store_vbar(full.clone()); + Ok(full) + } + } + } + + // NsCodec variant will use decoder state in Phase A7 + #[expect(clippy::unused_self)] + fn decode_subcodec_region( + &self, + sub: &ironrdp_pdu::codecs::clearcodec::Subcodec<'_>, + output: &mut [u8], + surface_width: u16, + ) -> DecodeResult<()> { + let sw = usize::from(surface_width); + let sh = output.len() / (sw * 4).max(1); + + let x_end = usize::from(sub.x_start) + usize::from(sub.width); + let y_end = usize::from(sub.y_start) + usize::from(sub.height); + if x_end > sw || y_end > sh { + return Err(invalid_field_err!("subcodec", "region exceeds surface bounds")); + } + + match sub.codec_id { + SubcodecId::Raw => { + let w = usize::from(sub.width); + let h = usize::from(sub.height); + let expected = w + .checked_mul(h) + .and_then(|v| v.checked_mul(3)) + .ok_or_else(|| invalid_field_err!("bitmapData", "raw subcodec dimensions overflow"))?; + if sub.bitmap_data.len() < expected { + return Err(invalid_field_err!("bitmapData", "raw subcodec data too short")); + } + for row in 0..h { + for col in 0..w { + let x = usize::from(sub.x_start) + col; + let y = usize::from(sub.y_start) + row; + let src_idx = (row * w + col) * 3; + let dst_idx = (y * sw + x) * 4; + output[dst_idx] = sub.bitmap_data[src_idx]; + output[dst_idx + 1] = sub.bitmap_data[src_idx + 1]; + output[dst_idx + 2] = sub.bitmap_data[src_idx + 2]; + output[dst_idx + 3] = 0xFF; + } + } + } + SubcodecId::Rlex => { + let rlex = ironrdp_pdu::codecs::clearcodec::decode_rlex(sub.bitmap_data)?; + let w = usize::from(sub.width); + let region_pixels = usize::from(sub.width) * usize::from(sub.height); + let palette_len = rlex.palette.len(); + let mut px = 0usize; + + for seg in &rlex.segments { + if usize::from(seg.start_index) >= palette_len { + return Err(invalid_field_err!("rlex", "start_index exceeds palette size")); + } + if usize::from(seg.stop_index) >= palette_len { + return Err(invalid_field_err!("rlex", "stop_index exceeds palette size")); + } + + let color = &rlex.palette[usize::from(seg.start_index)]; + for _ in 0..seg.run_length { + if px >= region_pixels { + return Err(invalid_field_err!("rlex", "run exceeds region pixel count")); + } + let x = usize::from(sub.x_start) + px % w; + let y = usize::from(sub.y_start) + px / w; + let dst_idx = (y * sw + x) * 4; + output[dst_idx] = color[0]; + output[dst_idx + 1] = color[1]; + output[dst_idx + 2] = color[2]; + output[dst_idx + 3] = 0xFF; + px += 1; + } + + for palette_idx in seg.start_index..=seg.stop_index { + if px >= region_pixels { + return Err(invalid_field_err!("rlex", "suite exceeds region pixel count")); + } + let color = &rlex.palette[usize::from(palette_idx)]; + let x = usize::from(sub.x_start) + px % w; + let y = usize::from(sub.y_start) + px / w; + let dst_idx = (y * sw + x) * 4; + output[dst_idx] = color[0]; + output[dst_idx + 1] = color[1]; + output[dst_idx + 2] = color[2]; + output[dst_idx + 3] = 0xFF; + px += 1; + } + } + } + SubcodecId::NsCodec => { + // Not yet implemented; encoder avoids generating NSCodec tiles. + } + } + + Ok(()) + } +} + +impl Default for ClearCodecDecoder { + fn default() -> Self { + Self::new() + } +} + +/// ClearCodec encoder for server-side bitmap compression. +/// +/// Encodes BGRA pixel data into ClearCodec bitmap streams using the residual +/// (BGR RLE) layer. The residual-only strategy gives good compression for +/// solid regions and text without requiring V-bar cache synchronization. +pub struct ClearCodecEncoder { + seq_number: u8, + glyph_cache: GlyphCache, + next_glyph_index: u16, +} + +impl ClearCodecEncoder { + pub fn new() -> Self { + Self { + seq_number: 0, + glyph_cache: GlyphCache::new(), + next_glyph_index: 0, + } + } + + /// Encode BGRA pixel data into a ClearCodec bitmap stream. + /// + /// Input: BGRA pixels in row-major order, `width * height * 4` bytes. + /// Returns the wire-format ClearCodec bitmap stream ready for + /// `WireToSurface1Pdu.bitmap_data`. + /// + /// **Alpha contract:** ClearCodec is lossless on the three color channels + /// (B, G, R) per MS-RDPEGFX 2.2.4.1. The wire format does not transmit + /// alpha; this encoder reads only B, G, R from each input pixel and + /// discards the alpha byte. Callers that need to preserve alpha across + /// the network must transport it separately. + pub fn encode(&mut self, bgra: &[u8], width: u16, height: u16) -> Vec { + let w = usize::from(width); + let h = usize::from(height); + let pixel_count = w.saturating_mul(h); + let use_glyph = pixel_count <= 1024; + + // Check glyph cache for exact match + if use_glyph { + if let Some((hit_index, _)) = self.find_glyph_match(bgra, width, height) { + return self.encode_glyph_hit(hit_index); + } + } + + // Convert BGRA to BGR run segments + let segments = bgra_to_run_segments(bgra, pixel_count); + let residual_data = encode_residual_layer(&segments); + + let mut flags = 0u8; + let glyph_index = if use_glyph { + flags |= FLAG_GLYPH_INDEX; + let idx = self.next_glyph_index; + self.glyph_cache.store( + idx, + GlyphEntry { + width, + height, + pixels: bgra.to_vec(), + }, + ); + self.next_glyph_index = (idx + 1) % GLYPH_CACHE_WRAP; + Some(idx) + } else { + None + }; + + let seq = self.seq_number; + self.seq_number = seq.wrapping_add(1); + + // Build the wire-format bitmap stream + let mut out = Vec::with_capacity(2 + 2 + 12 + residual_data.len()); + out.push(flags); + out.push(seq); + + if let Some(idx) = glyph_index { + out.extend_from_slice(&idx.to_le_bytes()); + } + + // Composite payload: residual only (bands=0, subcodec=0) + let residual_len = u32::try_from(residual_data.len()).unwrap_or(u32::MAX); + out.extend_from_slice(&residual_len.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); // bandsByteCount + out.extend_from_slice(&0u32.to_le_bytes()); // subcodecByteCount + out.extend_from_slice(&residual_data); + + out + } + + /// Encode a cache reset message (FLAG_CACHE_RESET). + pub fn encode_cache_reset(&mut self) -> Vec { + let seq = self.seq_number; + self.seq_number = seq.wrapping_add(1); + vec![ironrdp_pdu::codecs::clearcodec::FLAG_CACHE_RESET, seq] + } + + fn find_glyph_match(&self, bgra: &[u8], width: u16, height: u16) -> Option<(u16, &GlyphEntry)> { + // Linear scan of recently used glyph indices. + // For small cache usage this is fine; a hash index could be added later. + let search_range = GLYPH_CACHE_WRAP; + for idx in 0..search_range { + if let Some(entry) = self.glyph_cache.get(idx) { + if entry.width == width && entry.height == height && entry.pixels == bgra { + return Some((idx, entry)); + } + } + } + None + } + + fn encode_glyph_hit(&mut self, index: u16) -> Vec { + let seq = self.seq_number; + self.seq_number = seq.wrapping_add(1); + + let flags = FLAG_GLYPH_INDEX | ironrdp_pdu::codecs::clearcodec::FLAG_GLYPH_HIT; + let mut out = Vec::with_capacity(4); + out.push(flags); + out.push(seq); + out.extend_from_slice(&index.to_le_bytes()); + out + } +} + +impl Default for ClearCodecEncoder { + fn default() -> Self { + Self::new() + } +} + +/// Convert BGRA pixels to BGR run-length segments. +fn bgra_to_run_segments(bgra: &[u8], pixel_count: usize) -> Vec { + if pixel_count == 0 { + return Vec::new(); + } + + // Cap to the number of complete pixels actually present in the input + let available_pixels = bgra.len() / 4; + let pixel_count = pixel_count.min(available_pixels); + + let mut segments = Vec::new(); + let mut i = 0; + + while i < pixel_count { + let offset = i * 4; + if offset + 2 >= bgra.len() { + break; + } + + let blue = bgra[offset]; + let green = bgra[offset + 1]; + let red = bgra[offset + 2]; + // Alpha channel is discarded (ClearCodec is always opaque BGR) + + let mut run_length = 1u32; + let mut j = i + 1; + while j < pixel_count { + let jo = j * 4; + if jo + 2 >= bgra.len() { + break; + } + if bgra[jo] == blue && bgra[jo + 1] == green && bgra[jo + 2] == red { + run_length += 1; + j += 1; + } else { + break; + } + } + + segments.push(RgbRunSegment { + blue, + green, + red, + run_length, + }); + i = j; + } + + segments +} + +#[cfg(test)] +mod tests { + use ironrdp_pdu::codecs::clearcodec::{FLAG_CACHE_RESET, FLAG_GLYPH_HIT}; + + use super::*; + + fn make_residual_only_stream(width: u16, height: u16, blue: u8, green: u8, red: u8) -> Vec { + let pixel_count = u32::from(width) * u32::from(height); + let mut data = Vec::new(); + + // Flags=0x00 (no glyph, no cache reset), seq=0x00 + data.push(0x00); + data.push(0x00); + + // Composite payload header + // Residual: 4 bytes (1 run segment: BGR + short run) + let run_length = pixel_count; + let residual = if run_length < 0xFF { + vec![blue, green, red, u8::try_from(run_length).unwrap()] + } else if run_length < 0xFFFF { + let mut v = vec![blue, green, red, 0xFF]; + v.extend_from_slice(&u16::try_from(run_length).unwrap().to_le_bytes()); + v + } else { + let mut v = vec![blue, green, red, 0xFF, 0xFF, 0xFF]; + v.extend_from_slice(&run_length.to_le_bytes()); + v + }; + let residual_len = u32::try_from(residual.len()).unwrap(); + + data.extend_from_slice(&residual_len.to_le_bytes()); // residualByteCount + data.extend_from_slice(&0u32.to_le_bytes()); // bandsByteCount + data.extend_from_slice(&0u32.to_le_bytes()); // subcodecByteCount + data.extend_from_slice(&residual); + + data + } + + #[test] + fn decode_solid_red_4x4() { + let mut decoder = ClearCodecDecoder::new(); + let stream = make_residual_only_stream(4, 4, 0x00, 0x00, 0xFF); // red in BGR + let pixels = decoder.decode(&stream, 4, 4).unwrap(); + assert_eq!(pixels.len(), 4 * 4 * 4); + // Check first pixel: BGRA + assert_eq!(pixels[0], 0x00); // B + assert_eq!(pixels[1], 0x00); // G + assert_eq!(pixels[2], 0xFF); // R + assert_eq!(pixels[3], 0xFF); // A + } + + #[test] + fn glyph_cache_round_trip() { + let mut decoder = ClearCodecDecoder::new(); + + // First decode: GLYPH_INDEX set, stores in glyph cache + let mut stream = Vec::new(); + stream.push(FLAG_GLYPH_INDEX); // flags + stream.push(0x00); // seq + stream.extend_from_slice(&42u16.to_le_bytes()); // glyph_index = 42 + // Composite with 1-pixel residual (white) + let residual = [0xFF, 0xFF, 0xFF, 0x01]; // BGR white, run=1 + stream.extend_from_slice(&4u32.to_le_bytes()); // residual bytes + stream.extend_from_slice(&0u32.to_le_bytes()); // bands bytes + stream.extend_from_slice(&0u32.to_le_bytes()); // subcodec bytes + stream.extend_from_slice(&residual); + + let pixels1 = decoder.decode(&stream, 1, 1).unwrap(); + assert_eq!(pixels1.len(), 4); + + // Second decode: GLYPH_HIT - should return cached data + let mut hit_stream = Vec::new(); + hit_stream.push(FLAG_GLYPH_INDEX | FLAG_GLYPH_HIT); // flags + hit_stream.push(0x01); // seq = 1 + hit_stream.extend_from_slice(&42u16.to_le_bytes()); // glyph_index = 42 + + let pixels2 = decoder.decode(&hit_stream, 1, 1).unwrap(); + assert_eq!(pixels1, pixels2); + } + + #[test] + fn raw_subcodec_decode() { + let mut decoder = ClearCodecDecoder::new(); + let mut stream = Vec::new(); + stream.push(0x00); // flags + stream.push(0x00); // seq + + // Composite: no residual, no bands, 1 raw subcodec region + let mut subcodec_data = Vec::new(); + subcodec_data.extend_from_slice(&0u16.to_le_bytes()); // x_start + subcodec_data.extend_from_slice(&0u16.to_le_bytes()); // y_start + subcodec_data.extend_from_slice(&2u16.to_le_bytes()); // width + subcodec_data.extend_from_slice(&1u16.to_le_bytes()); // height + subcodec_data.extend_from_slice(&6u32.to_le_bytes()); // 2 pixels * 3 bytes + subcodec_data.push(0x00); // SubcodecId::Raw + subcodec_data.extend_from_slice(&[0x00, 0x00, 0xFF]); // pixel 0: red + subcodec_data.extend_from_slice(&[0xFF, 0x00, 0x00]); // pixel 1: blue + + let subcodec_len = u32::try_from(subcodec_data.len()).unwrap(); + stream.extend_from_slice(&0u32.to_le_bytes()); // residual + stream.extend_from_slice(&0u32.to_le_bytes()); // bands + stream.extend_from_slice(&subcodec_len.to_le_bytes()); // subcodec + stream.extend_from_slice(&subcodec_data); + + let pixels = decoder.decode(&stream, 2, 1).unwrap(); + assert_eq!(pixels.len(), 2 * 4); // 2 pixels * BGRA + // Pixel 0: red (BGR: 0x00, 0x00, 0xFF) + assert_eq!(&pixels[0..4], &[0x00, 0x00, 0xFF, 0xFF]); + // Pixel 1: blue (BGR: 0xFF, 0x00, 0x00) + assert_eq!(&pixels[4..8], &[0xFF, 0x00, 0x00, 0xFF]); + } + + #[test] + fn cache_reset_clears_vbar_cursors() { + let mut decoder = ClearCodecDecoder::new(); + // Decode something to advance cursors, then reset + let stream = make_residual_only_stream(1, 1, 0, 0, 0); + decoder.decode(&stream, 1, 1).unwrap(); + + // Cache reset message + let reset_data = [FLAG_CACHE_RESET, 0x01]; // flags=CACHE_RESET, seq=1 + let _ = decoder.decode(&reset_data, 0, 0); // zero dimensions, but cache reset still processed + } + + // --- Encoder tests --- + + #[test] + fn encode_solid_color_round_trip() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + + // 4x4 solid red (BGRA: 0,0,255,255) + let bgra: Vec = (0..16).flat_map(|_| [0x00, 0x00, 0xFF, 0xFF]).collect(); + + let wire = enc.encode(&bgra, 4, 4); + let result = dec.decode(&wire, 4, 4).unwrap(); + + assert_eq!(result, bgra); + } + + #[test] + fn encode_two_color_stripe_round_trip() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + + // 4x1: 2 red + 2 blue pixels + let mut bgra = Vec::new(); + bgra.extend_from_slice(&[0x00, 0x00, 0xFF, 0xFF]); // red + bgra.extend_from_slice(&[0x00, 0x00, 0xFF, 0xFF]); // red + bgra.extend_from_slice(&[0xFF, 0x00, 0x00, 0xFF]); // blue + bgra.extend_from_slice(&[0xFF, 0x00, 0x00, 0xFF]); // blue + + let wire = enc.encode(&bgra, 4, 1); + let result = dec.decode(&wire, 4, 1).unwrap(); + + assert_eq!(result, bgra); + } + + #[test] + fn encode_glyph_cache_hit() { + let mut encoder = ClearCodecEncoder::new(); + + // Small 1x1 pixel (fits glyph cache: area=1 <= 1024) + let bgra = vec![0xFF, 0x00, 0x00, 0xFF]; // blue + + let first = encoder.encode(&bgra, 1, 1); + let second = encoder.encode(&bgra, 1, 1); + + // Second encode should be a glyph hit (shorter) + assert!( + second.len() < first.len(), + "glyph hit should be shorter than full encode" + ); + + // Both should decode to the same pixels + let mut decoder = ClearCodecDecoder::new(); + let p1 = decoder.decode(&first, 1, 1).unwrap(); + let p2 = decoder.decode(&second, 1, 1).unwrap(); + assert_eq!(p1, p2); + assert_eq!(p1, bgra); + } + + #[test] + fn encode_sequence_numbers_increment() { + let mut encoder = ClearCodecEncoder::new(); + let bgra = vec![0x00, 0x00, 0x00, 0xFF]; // 1x1 black + + let e1 = encoder.encode(&bgra, 1, 1); + let e2 = encoder.encode(&bgra, 1, 1); + + // Seq numbers are at byte offset 1 + // First frame starts with glyph_index flag + seq=0 + assert_eq!(e1[1], 0x00); + // Second is glyph hit: seq=1 + assert_eq!(e2[1], 0x01); + } + + #[test] + fn encode_cache_reset() { + let mut encoder = ClearCodecEncoder::new(); + let reset = encoder.encode_cache_reset(); + + let mut decoder = ClearCodecDecoder::new(); + let _ = decoder.decode(&reset, 0, 0); + // Just verifies it doesn't error + } + + #[test] + fn bgra_to_run_segments_compresses_runs() { + // 8 identical pixels should produce 1 segment with run_length=8 + let bgra: Vec = (0..8).flat_map(|_| [0xAA, 0xBB, 0xCC, 0xFF]).collect(); + let segments = bgra_to_run_segments(&bgra, 8); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].run_length, 8); + assert_eq!(segments[0].blue, 0xAA); + assert_eq!(segments[0].green, 0xBB); + assert_eq!(segments[0].red, 0xCC); + } + + #[test] + fn bgra_to_run_segments_unique_pixels() { + // 3 different pixels produce 3 segments + let bgra = vec![ + 0x01, 0x02, 0x03, 0xFF, // pixel 1 + 0x04, 0x05, 0x06, 0xFF, // pixel 2 + 0x07, 0x08, 0x09, 0xFF, // pixel 3 + ]; + let segments = bgra_to_run_segments(&bgra, 3); + assert_eq!(segments.len(), 3); + for seg in &segments { + assert_eq!(seg.run_length, 1); + } + } +} diff --git a/crates/ironrdp-graphics/src/clearcodec/vbar_cache.rs b/crates/ironrdp-graphics/src/clearcodec/vbar_cache.rs new file mode 100644 index 0000000000..37a51667a6 --- /dev/null +++ b/crates/ironrdp-graphics/src/clearcodec/vbar_cache.rs @@ -0,0 +1,206 @@ +//! V-Bar caching for ClearCodec bands layer. +//! +//! The V-bar cache uses two ring buffers: +//! - **V-Bar Storage**: 32,768 full V-bars (complete column pixel data for a band height) +//! - **Short V-Bar Storage**: 16,384 short V-bars (only the non-background portion) +//! +//! Cache cursors advance linearly and wrap around, implementing LRU eviction +//! as specified in MS-RDPEGFX 3.3.8.1. + +use ironrdp_pdu::codecs::clearcodec::{SHORT_VBAR_CACHE_SIZE, VBAR_CACHE_SIZE}; + +// VBAR_CACHE_SIZE (32,768) and SHORT_VBAR_CACHE_SIZE (16,384) as u16 for cursor wrapping. +const VBAR_WRAP: u16 = 32_768; +const SHORT_VBAR_WRAP: u16 = 16_384; + +/// A full V-bar: column of BGR pixels for the full band height. +#[derive(Debug, Clone)] +pub struct FullVBar { + /// BGR pixel data, length = band_height * 3. + pub pixels: Vec, +} + +/// A short V-bar: only the non-background pixels within a column. +#[derive(Debug, Clone)] +pub struct ShortVBar { + /// First row index where pixel data starts. + pub y_on: u8, + /// Number of pixel rows with color data. + pub pixel_count: u8, + /// BGR pixel data, length = pixel_count * 3. + pub pixels: Vec, +} + +/// Combined V-bar cache state. +pub struct VBarCache { + /// Full V-bar storage (32,768 entries, ring buffer). + vbar_storage: Vec>, + /// Short V-bar storage (16,384 entries, ring buffer). + short_vbar_storage: Vec>, + /// Current write cursor for V-bar storage (wraps at 32767). + vbar_cursor: u16, + /// Current write cursor for short V-bar storage (wraps at 16383). + short_vbar_cursor: u16, +} + +impl VBarCache { + pub fn new() -> Self { + let mut vbar_storage = Vec::with_capacity(VBAR_CACHE_SIZE); + vbar_storage.resize_with(VBAR_CACHE_SIZE, || None); + + let mut short_vbar_storage = Vec::with_capacity(SHORT_VBAR_CACHE_SIZE); + short_vbar_storage.resize_with(SHORT_VBAR_CACHE_SIZE, || None); + + Self { + vbar_storage, + short_vbar_storage, + vbar_cursor: 0, + short_vbar_cursor: 0, + } + } + + /// Reset both caches (when FLAG_CACHE_RESET is received). + pub fn reset(&mut self) { + self.vbar_cursor = 0; + self.short_vbar_cursor = 0; + // Per spec, only cursors reset. Existing entries become stale + // but the cursor reset means new entries overwrite from index 0. + } + + /// Get a full V-bar from cache by index. + pub fn get_vbar(&self, index: u16) -> Option<&FullVBar> { + self.vbar_storage.get(usize::from(index)).and_then(|slot| slot.as_ref()) + } + + /// Get a short V-bar from cache by index. + pub fn get_short_vbar(&self, index: u16) -> Option<&ShortVBar> { + self.short_vbar_storage + .get(usize::from(index)) + .and_then(|slot| slot.as_ref()) + } + + /// Store a short V-bar and return its cache index. + pub fn store_short_vbar(&mut self, short_vbar: ShortVBar) -> u16 { + let index = self.short_vbar_cursor; + self.short_vbar_storage[usize::from(index)] = Some(short_vbar); + self.short_vbar_cursor = (index + 1) % SHORT_VBAR_WRAP; + index + } + + /// Store a full V-bar and return its cache index. + pub fn store_vbar(&mut self, vbar: FullVBar) -> u16 { + let index = self.vbar_cursor; + self.vbar_storage[usize::from(index)] = Some(vbar); + self.vbar_cursor = (index + 1) % VBAR_WRAP; + index + } + + /// Reconstruct a full V-bar from a short V-bar and background color. + /// + /// The full V-bar has: + /// - Background color above y_on + /// - Short V-bar pixel data from y_on to y_on + pixel_count + /// - Background color below y_on + pixel_count + pub fn reconstruct_full_vbar( + short_vbar: &ShortVBar, + band_height: u16, + bg_blue: u8, + bg_green: u8, + bg_red: u8, + ) -> FullVBar { + let height = usize::from(band_height); + let mut pixels = Vec::with_capacity(height * 3); + + // Background above y_on + for _ in 0..usize::from(short_vbar.y_on) { + pixels.push(bg_blue); + pixels.push(bg_green); + pixels.push(bg_red); + } + + // Pixel data from short V-bar + pixels.extend_from_slice(&short_vbar.pixels); + + // Background below y_on + pixel_count + let bottom_start = usize::from(short_vbar.y_on) + usize::from(short_vbar.pixel_count); + for _ in bottom_start..height { + pixels.push(bg_blue); + pixels.push(bg_green); + pixels.push(bg_red); + } + + FullVBar { pixels } + } +} + +impl Default for VBarCache { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn store_and_retrieve_vbar() { + let mut cache = VBarCache::new(); + let vbar = FullVBar { + pixels: vec![0xFF, 0x00, 0x00], + }; + let idx = cache.store_vbar(vbar); + assert_eq!(idx, 0); + let retrieved = cache.get_vbar(0).unwrap(); + assert_eq!(retrieved.pixels, vec![0xFF, 0x00, 0x00]); + } + + #[test] + fn cursor_wraps() { + let mut cache = VBarCache::new(); + // Store VBAR_CACHE_SIZE entries, cursor should wrap to 0 + for i in 0..VBAR_CACHE_SIZE { + let idx = cache.store_vbar(FullVBar { + pixels: vec![u8::try_from(i & 0xFF).unwrap()], + }); + assert_eq!(idx, u16::try_from(i).unwrap()); + } + // Next store should be at index 0 (wrapped) + let idx = cache.store_vbar(FullVBar { pixels: vec![0xAA] }); + assert_eq!(idx, 0); + } + + #[test] + fn reconstruct_full_vbar() { + let short = ShortVBar { + y_on: 1, + pixel_count: 2, + pixels: vec![0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00], // 2 pixels BGR + }; + let full = VBarCache::reconstruct_full_vbar(&short, 4, 0xAA, 0xBB, 0xCC); + // Height=4: 1 bg row, 2 data rows, 1 bg row + assert_eq!(full.pixels.len(), 12); // 4 * 3 + // Row 0: background + assert_eq!(&full.pixels[0..3], &[0xAA, 0xBB, 0xCC]); + // Row 1-2: pixel data + assert_eq!(&full.pixels[3..9], &[0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00]); + // Row 3: background + assert_eq!(&full.pixels[9..12], &[0xAA, 0xBB, 0xCC]); + } + + #[test] + fn reset_resets_cursors() { + let mut cache = VBarCache::new(); + cache.store_vbar(FullVBar { pixels: vec![0x01] }); + cache.store_short_vbar(ShortVBar { + y_on: 0, + pixel_count: 0, + pixels: vec![], + }); + assert_eq!(cache.vbar_cursor, 1); + assert_eq!(cache.short_vbar_cursor, 1); + cache.reset(); + assert_eq!(cache.vbar_cursor, 0); + assert_eq!(cache.short_vbar_cursor, 0); + } +} diff --git a/crates/ironrdp-graphics/src/color_conversion.rs b/crates/ironrdp-graphics/src/color_conversion.rs index 7ce4c82956..167edd9181 100644 --- a/crates/ironrdp-graphics/src/color_conversion.rs +++ b/crates/ironrdp-graphics/src/color_conversion.rs @@ -1,8 +1,8 @@ use std::io; use yuv::{ - rdp_abgr_to_yuv444, rdp_argb_to_yuv444, rdp_bgra_to_yuv444, rdp_rgba_to_yuv444, rdp_yuv444_to_argb, - rdp_yuv444_to_rgba, BufferStoreMut, YuvPlanarImage, YuvPlanarImageMut, + BufferStoreMut, YuvError, YuvPlanarImage, YuvPlanarImageMut, rdp_abgr_to_yuv444, rdp_argb_to_yuv444, + rdp_bgra_to_yuv444, rdp_rgba_to_yuv444, rdp_yuv444_to_argb, rdp_yuv444_to_rgba, }; use crate::image_processing::PixelFormat; @@ -40,17 +40,21 @@ pub fn ycbcr_to_rgba(input: YCbCrBuffer<'_>, output: &mut [u8]) -> io::Result<() rdp_yuv444_to_rgba(&planar, output, len).map_err(io::Error::other) } +/// # Panics +/// +/// - Panics if `width` > 64. +/// - Panics if `height` > 64. #[expect(clippy::too_many_arguments)] pub fn to_64x64_ycbcr_tile( input: &[u8], - width: usize, - height: usize, - stride: usize, + width: u32, + height: u32, + stride: u32, format: PixelFormat, y: &mut [i16; 64 * 64], cb: &mut [i16; 64 * 64], cr: &mut [i16; 64 * 64], -) { +) -> Result<(), YuvError> { assert!(width <= 64); assert!(height <= 64); @@ -64,26 +68,46 @@ pub fn to_64x64_ycbcr_tile( u_stride: 64, v_plane, v_stride: 64, - width: width.try_into().unwrap(), - height: height.try_into().unwrap(), + width, + height, }; - let res = match format { - PixelFormat::RgbA32 | PixelFormat::RgbX32 => rdp_rgba_to_yuv444(&mut plane, input, stride.try_into().unwrap()), - PixelFormat::ARgb32 | PixelFormat::XRgb32 => rdp_argb_to_yuv444(&mut plane, input, stride.try_into().unwrap()), - PixelFormat::BgrA32 | PixelFormat::BgrX32 => rdp_bgra_to_yuv444(&mut plane, input, stride.try_into().unwrap()), - PixelFormat::ABgr32 | PixelFormat::XBgr32 => rdp_abgr_to_yuv444(&mut plane, input, stride.try_into().unwrap()), + match format { + PixelFormat::RgbA32 | PixelFormat::RgbX32 => rdp_rgba_to_yuv444(&mut plane, input, stride), + PixelFormat::ARgb32 | PixelFormat::XRgb32 => rdp_argb_to_yuv444(&mut plane, input, stride), + PixelFormat::BgrA32 | PixelFormat::BgrX32 => rdp_bgra_to_yuv444(&mut plane, input, stride), + PixelFormat::ABgr32 | PixelFormat::XBgr32 => rdp_abgr_to_yuv444(&mut plane, input, stride), + } +} + +/// Convert a 15-bit RDP color (RGB555) to RGB. Input value should be represented in +/// little-endian format. +/// +/// Layout: `[0, R4:R0, G4:G0, B4:B0]` -- MSB unused, 5 bits per channel. +pub fn rdp_15bit_to_rgb(color: u16) -> [u8; 3] { + #[expect(clippy::missing_panics_doc, reason = "unreachable panic (checked integer underflow)")] + let out = { + let r = u8::try_from(((((color >> 10) & 0x1f) * 527) + 23) >> 6).expect("max possible value is 255"); + let g = u8::try_from(((((color >> 5) & 0x1f) * 527) + 23) >> 6).expect("max possible value is 255"); + let b = u8::try_from((((color & 0x1f) * 527) + 23) >> 6).expect("max possible value is 255"); + [r, g, b] }; - res.unwrap(); + + out } -/// Convert a 16-bit RDP color to RGB representation. Input value should be represented in +/// Convert a 16-bit RDP color (RGB565) to RGB. Input value should be represented in /// little-endian format. pub fn rdp_16bit_to_rgb(color: u16) -> [u8; 3] { - let r = (((((color >> 11) & 0x1f) * 527) + 23) >> 6) as u8; - let g = (((((color >> 5) & 0x3f) * 259) + 33) >> 6) as u8; - let b = ((((color & 0x1f) * 527) + 23) >> 6) as u8; - [r, g, b] + #[expect(clippy::missing_panics_doc, reason = "unreachable panic (checked integer underflow)")] + let out = { + let r = u8::try_from(((((color >> 11) & 0x1f) * 527) + 23) >> 6).expect("max possible value is 255"); + let g = u8::try_from(((((color >> 5) & 0x3f) * 259) + 33) >> 6).expect("max possible value is 255"); + let b = u8::try_from((((color & 0x1f) * 527) + 23) >> 6).expect("max possible value is 255"); + [r, g, b] + }; + + out } #[derive(Debug)] diff --git a/crates/ironrdp-graphics/src/dwt.rs b/crates/ironrdp-graphics/src/dwt.rs index 6381cb542f..5e13b8df65 100644 --- a/crates/ironrdp-graphics/src/dwt.rs +++ b/crates/ironrdp-graphics/src/dwt.rs @@ -22,17 +22,21 @@ fn dwt_vertical(buffer: &[i16], dwt: &mut [i16]) { let h_index = l_index + SUBBAND_WIDTH * total_width; let src_index = y * total_width + x; - dwt[h_index] = ((i32::from(buffer[src_index + total_width]) - - ((i32::from(buffer[src_index]) - + i32::from(buffer[src_index + if n < SUBBAND_WIDTH - 1 { 2 * total_width } else { 0 }])) - >> 1)) - >> 1) as i16; - dwt[l_index] = (i32::from(buffer[src_index]) - + if n == 0 { - i32::from(dwt[h_index]) - } else { - (i32::from(dwt[h_index - total_width]) + i32::from(dwt[h_index])) >> 1 - }) as i16; + dwt[h_index] = i32_to_i16_possible_truncation( + (i32::from(buffer[src_index + total_width]) + - ((i32::from(buffer[src_index]) + + i32::from(buffer[src_index + if n < SUBBAND_WIDTH - 1 { 2 * total_width } else { 0 }])) + >> 1)) + >> 1, + ); + dwt[l_index] = i32_to_i16_possible_truncation( + i32::from(buffer[src_index]) + + if n == 0 { + i32::from(dwt[h_index]) + } else { + (i32::from(dwt[h_index - total_width]) + i32::from(dwt[h_index])) >> 1 + }, + ); } } } @@ -57,16 +61,20 @@ fn dwt_horizontal(mut buffer: &mut [i16], dwt: &[i16 let x = n * 2; // HL - hl[n] = ((i32::from(l_src[x + 1]) - - ((i32::from(l_src[x]) + i32::from(l_src[if n < SUBBAND_WIDTH - 1 { x + 2 } else { x }])) >> 1)) - >> 1) as i16; + hl[n] = i32_to_i16_possible_truncation( + (i32::from(l_src[x + 1]) + - ((i32::from(l_src[x]) + i32::from(l_src[if n < SUBBAND_WIDTH - 1 { x + 2 } else { x }])) >> 1)) + >> 1, + ); // LL - ll[n] = (i32::from(l_src[x]) - + if n == 0 { - i32::from(hl[n]) - } else { - (i32::from(hl[n - 1]) + i32::from(hl[n])) >> 1 - }) as i16; + ll[n] = i32_to_i16_possible_truncation( + i32::from(l_src[x]) + + if n == 0 { + i32::from(hl[n]) + } else { + (i32::from(hl[n - 1]) + i32::from(hl[n])) >> 1 + }, + ); } // H @@ -74,16 +82,20 @@ fn dwt_horizontal(mut buffer: &mut [i16], dwt: &[i16 let x = n * 2; // HH - hh[n] = ((i32::from(h_src[x + 1]) - - ((i32::from(h_src[x]) + i32::from(h_src[if n < SUBBAND_WIDTH - 1 { x + 2 } else { x }])) >> 1)) - >> 1) as i16; + hh[n] = i32_to_i16_possible_truncation( + (i32::from(h_src[x + 1]) + - ((i32::from(h_src[x]) + i32::from(h_src[if n < SUBBAND_WIDTH - 1 { x + 2 } else { x }])) >> 1)) + >> 1, + ); // LH - lh[n] = (i32::from(h_src[x]) - + if n == 0 { - i32::from(hh[n]) - } else { - (i32::from(hh[n - 1]) + i32::from(hh[n])) >> 1 - }) as i16; + lh[n] = i32_to_i16_possible_truncation( + i32::from(h_src[x]) + + if n == 0 { + i32::from(hh[n]) + } else { + (i32::from(hh[n - 1]) + i32::from(hh[n])) >> 1 + }, + ); } hl = &mut hl[SUBBAND_WIDTH..]; @@ -124,24 +136,30 @@ fn inverse_horizontal(mut buffer: &[i16], temp_buffer: &mut [i16], subband_width for _ in 0..subband_width { // Even coefficients - l_dst[0] = (i32::from(ll[0]) - ((i32::from(hl[0]) + i32::from(hl[0]) + 1) >> 1)) as i16; - h_dst[0] = (i32::from(lh[0]) - ((i32::from(hh[0]) + i32::from(hh[0]) + 1) >> 1)) as i16; + l_dst[0] = i32_to_i16_possible_truncation(i32::from(ll[0]) - ((i32::from(hl[0]) + i32::from(hl[0]) + 1) >> 1)); + h_dst[0] = i32_to_i16_possible_truncation(i32::from(lh[0]) - ((i32::from(hh[0]) + i32::from(hh[0]) + 1) >> 1)); for n in 1..subband_width { let x = n * 2; - l_dst[x] = (i32::from(ll[n]) - ((i32::from(hl[n - 1]) + i32::from(hl[n]) + 1) >> 1)) as i16; - h_dst[x] = (i32::from(lh[n]) - ((i32::from(hh[n - 1]) + i32::from(hh[n]) + 1) >> 1)) as i16; + l_dst[x] = + i32_to_i16_possible_truncation(i32::from(ll[n]) - ((i32::from(hl[n - 1]) + i32::from(hl[n]) + 1) >> 1)); + h_dst[x] = + i32_to_i16_possible_truncation(i32::from(lh[n]) - ((i32::from(hh[n - 1]) + i32::from(hh[n]) + 1) >> 1)); } // Odd coefficients for n in 0..subband_width - 1 { let x = n * 2; - l_dst[x + 1] = (i32::from(hl[n] << 1) + ((i32::from(l_dst[x]) + i32::from(l_dst[x + 2])) >> 1)) as i16; - h_dst[x + 1] = (i32::from(hh[n] << 1) + ((i32::from(h_dst[x]) + i32::from(h_dst[x + 2])) >> 1)) as i16; + l_dst[x + 1] = i32_to_i16_possible_truncation( + i32::from(hl[n] << 1) + ((i32::from(l_dst[x]) + i32::from(l_dst[x + 2])) >> 1), + ); + h_dst[x + 1] = i32_to_i16_possible_truncation( + i32::from(hh[n] << 1) + ((i32::from(h_dst[x]) + i32::from(h_dst[x + 2])) >> 1), + ); } let n = subband_width - 1; let x = n * 2; - l_dst[x + 1] = (i32::from(hl[n] << 1) + i32::from(l_dst[x])) as i16; - h_dst[x + 1] = (i32::from(hh[n] << 1) + i32::from(h_dst[x])) as i16; + l_dst[x + 1] = i32_to_i16_possible_truncation(i32::from(hl[n] << 1) + i32::from(l_dst[x])); + h_dst[x + 1] = i32_to_i16_possible_truncation(i32::from(hh[n] << 1) + i32::from(h_dst[x])); hl = &hl[subband_width..]; lh = &lh[subband_width..]; @@ -157,8 +175,9 @@ fn inverse_vertical(mut buffer: &mut [i16], mut temp_buffer: &[i16], subband_wid let total_width = subband_width * 2; for _ in 0..total_width { - buffer[0] = - (i32::from(temp_buffer[0]) - ((i32::from(temp_buffer[subband_width * total_width]) * 2 + 1) >> 1)) as i16; + buffer[0] = i32_to_i16_possible_truncation( + i32::from(temp_buffer[0]) - ((i32::from(temp_buffer[subband_width * total_width]) * 2 + 1) >> 1), + ); let mut l = temp_buffer; let mut lh = &temp_buffer[(subband_width - 1) * total_width..]; @@ -171,18 +190,28 @@ fn inverse_vertical(mut buffer: &mut [i16], mut temp_buffer: &[i16], subband_wid h = &h[total_width..]; // Even coefficients - dst[2 * total_width] = (i32::from(l[0]) - ((i32::from(lh[0]) + i32::from(h[0]) + 1) >> 1)) as i16; + dst[2 * total_width] = + i32_to_i16_possible_truncation(i32::from(l[0]) - ((i32::from(lh[0]) + i32::from(h[0]) + 1) >> 1)); // Odd coefficients - dst[total_width] = - (i32::from(lh[0] << 1) + ((i32::from(dst[0]) + i32::from(dst[2 * total_width])) >> 1)) as i16; + dst[total_width] = i32_to_i16_possible_truncation( + i32::from(lh[0] << 1) + ((i32::from(dst[0]) + i32::from(dst[2 * total_width])) >> 1), + ); dst = &mut dst[2 * total_width..]; } - dst[total_width] = (i32::from(lh[total_width] << 1) + ((i32::from(dst[0]) + i32::from(dst[0])) >> 1)) as i16; + dst[total_width] = i32_to_i16_possible_truncation( + i32::from(lh[total_width] << 1) + ((i32::from(dst[0]) + i32::from(dst[0])) >> 1), + ); temp_buffer = &temp_buffer[1..]; buffer = &mut buffer[1..]; } } + +#[expect(clippy::as_conversions)] +#[expect(clippy::cast_possible_truncation)] +fn i32_to_i16_possible_truncation(value: i32) -> i16 { + value as i16 +} diff --git a/crates/ironrdp-graphics/src/dwt_extrapolate.rs b/crates/ironrdp-graphics/src/dwt_extrapolate.rs new file mode 100644 index 0000000000..437548d8bf --- /dev/null +++ b/crates/ironrdp-graphics/src/dwt_extrapolate.rs @@ -0,0 +1,590 @@ +//! Reduce-extrapolate variant of the LeGall 5/3 DWT for progressive RFX. +//! +//! When `RFX_DWT_REDUCE_EXTRAPOLATE` (0x01) is set in the progressive context, +//! the DWT uses boundary extrapolation instead of symmetric extension. This +//! produces asymmetric subbands that avoid wraparound artifacts at tile edges. +//! +//! For a 64x64 tile with 3-level decomposition: +//! +//! Level 1: HL1(31x33), LH1(33x31), HH1(31x31) — LL1(33x33) feeds level 2 +//! Level 2: HL2(16x17), LH2(17x16), HH2(16x16) — LL2(17x17) feeds level 3 +//! Level 3: HL3(8x9), LH3(9x8), HH3(8x8), LL3(9x9) +//! +//! Buffer layout (4096 coefficients total): +//! [HL1:1023][LH1:1023][HH1:961][HL2:272][LH2:272][HH2:256][HL3:72][LH3:72][HH3:64][LL3:81] + +/// Subband position and dimensions within the coefficient buffer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BandInfo { + /// Width (columns) of the subband. + pub width: usize, + /// Height (rows) of the subband. + pub height: usize, + /// Starting index in the linearized coefficient array. + pub offset: usize, +} + +impl BandInfo { + /// Total number of coefficients in this subband. + pub const fn count(&self) -> usize { + self.width * self.height + } +} + +/// Low-pass output count for the reduce-extrapolate split. +/// +/// For even N: `N/2 + 1` (extrapolated sample). +/// For odd N: `(N + 1) / 2` (standard ceiling division). +fn low_count(n: usize) -> usize { + // Even: (64+2)/2=33. Odd: (33+2)/2=17, (17+2)/2=9. + (n + 2) / 2 +} + +/// High-pass output count: remainder after low-pass. +fn high_count(n: usize) -> usize { + n - low_count(n) +} + +/// Band layout for the reduce-extrapolate 3-level DWT on a 64x64 tile. +/// +/// Returns 10 subbands in buffer order: +/// `[HL1, LH1, HH1, HL2, LH2, HH2, HL3, LH3, HH3, LL3]` +/// +/// Band indices match `ComponentCodecQuant::for_band()`: +/// 0=HL1, 1=LH1, 2=HH1, 3=HL2, 4=LH2, 5=HH2, 6=HL3, 7=LH3, 8=HH3, 9=LL3 +#[expect(clippy::similar_names, reason = "lw/hw/lh/hh are standard DWT band dimensions")] +pub fn band_layout() -> [BandInfo; 10] { + let (lw1, hw1) = (low_count(64), high_count(64)); // (33, 31) + let (lw2, hw2) = (low_count(lw1), high_count(lw1)); // (17, 16) + let (lw3, hw3) = (low_count(lw2), high_count(lw2)); // (9, 8) + + // Vertical dimensions are the same (square tile) + let (lh1, hh1) = (lw1, hw1); + let (lh2, hh2) = (lw2, hw2); + let (lh3, hh3) = (lw3, hw3); + + let mut off = 0; + let mut b = |w: usize, h: usize| { + let info = BandInfo { + width: w, + height: h, + offset: off, + }; + off += w * h; + info + }; + + [ + b(hw1, lh1), // HL1: 31x33 = 1023 + b(lw1, hh1), // LH1: 33x31 = 1023 + b(hw1, hh1), // HH1: 31x31 = 961 + b(hw2, lh2), // HL2: 16x17 = 272 + b(lw2, hh2), // LH2: 17x16 = 272 + b(hw2, hh2), // HH2: 16x16 = 256 + b(hw3, lh3), // HL3: 8x9 = 72 + b(lw3, hh3), // LH3: 9x8 = 72 + b(hw3, hh3), // HH3: 8x8 = 64 + b(lw3, lh3), // LL3: 9x9 = 81 + ] +} + +/// Inverse 3-level reduce-extrapolate DWT (coefficients to 64x64 tile). +/// +/// Reconstructs the tile in-place in `buffer`. Both slices must have at least +/// 4096 elements. +/// +/// # Panics +/// +/// Panics if either slice has fewer than 4096 elements. +pub fn decode(buffer: &mut [i16], temp: &mut [i16]) { + assert!(buffer.len() >= 4096, "buffer must hold 4096 coefficients"); + assert!(temp.len() >= 4096, "temp must hold 4096 elements"); + + // Inner-to-outer: level 3 first (smallest subbands), then 2, then 1 + decode_block(&mut buffer[3807..], temp, 9, 8); + decode_block(&mut buffer[3007..], temp, 17, 16); + decode_block(buffer, temp, 33, 31); +} + +/// Forward 3-level reduce-extrapolate DWT (64x64 tile to coefficients). +/// +/// Transforms the tile in-place in `buffer`. Both slices must have at least +/// 4096 elements. +/// +/// # Panics +/// +/// Panics if either slice has fewer than 4096 elements. +pub fn encode(buffer: &mut [i16], temp: &mut [i16]) { + assert!(buffer.len() >= 4096, "buffer must hold 4096 coefficients"); + assert!(temp.len() >= 4096, "temp must hold 4096 elements"); + + // Outer-to-inner: level 1 first (full tile), then 2, then 3 + encode_block(buffer, temp, 33, 31); + encode_block(&mut buffer[3007..], temp, 17, 16); + encode_block(&mut buffer[3807..], temp, 9, 8); +} + +// --------------------------------------------------------------------------- +// Inverse (decode) implementation +// --------------------------------------------------------------------------- + +/// Inverse DWT for one decomposition level. +/// +/// `n_l` and `n_h` are the low-pass and high-pass band counts. +/// Buffer contains `[HL, LH, HH, LL]` contiguously. +#[expect(clippy::similar_names, reason = "hl/lh/hh/ll are standard DWT subband names")] +fn decode_block(buffer: &mut [i16], temp: &mut [i16], n_l: usize, n_h: usize) { + let dst_w = n_l + n_h; + + // Subband offsets within this block's buffer region + let hl_off = 0; + let lh_off = n_h * n_l; + let hh_off = lh_off + n_l * n_h; + let ll_off = hh_off + n_h * n_h; + + // Temp: L half (n_l rows x dst_w cols) then H half (n_h rows x dst_w cols) + let l_off = 0; + let h_off = n_l * dst_w; + + // Horizontal inverse: (LL + HL) -> L rows + for row in 0..n_l { + let ll_start = ll_off + row * n_l; + let hl_start = hl_off + row * n_h; + let l_start = l_off + row * dst_w; + idwt_row( + &buffer[ll_start..ll_start + n_l], + &buffer[hl_start..hl_start + n_h], + &mut temp[l_start..l_start + dst_w], + ); + } + + // Horizontal inverse: (LH + HH) -> H rows + for row in 0..n_h { + let lh_start = lh_off + row * n_l; + let hh_start = hh_off + row * n_h; + let h_start = h_off + row * dst_w; + idwt_row( + &buffer[lh_start..lh_start + n_l], + &buffer[hh_start..hh_start + n_h], + &mut temp[h_start..h_start + dst_w], + ); + } + + // Vertical inverse: (L + H) -> reconstructed columns in buffer + for col in 0..dst_w { + idwt_col(temp, l_off + col, h_off + col, dst_w, buffer, col, dst_w, n_l, n_h); + } +} + +/// 1D inverse DWT on a contiguous row. +/// +/// Reconstructs `n_l + n_h` output samples from `n_l` low-pass and `n_h` +/// high-pass coefficients. +fn idwt_row(low: &[i16], high: &[i16], dst: &mut [i16]) { + let n_l = low.len(); + let n_h = high.len(); + + let mut h0 = i32::from(high[0]); + let mut x0 = t(i32::from(low[0]) - h0); + let mut x2 = x0; + + let mut di = 0; + + for j in 0..n_h - 1 { + let h1 = i32::from(high[j + 1]); + let l_val = i32::from(low[j + 1]); + x2 = t(l_val - (h0 + h1) / 2); + let x1 = t((i32::from(x0) + i32::from(x2)) / 2 + 2 * h0); + dst[di] = x0; + dst[di + 1] = x1; + di += 2; + x0 = x2; + h0 = h1; + } + + if n_l <= n_h + 1 { + if n_l <= n_h { + dst[di] = x2; + dst[di + 1] = t(i32::from(x2) + 2 * h0); + } else { + let x_new = t(i32::from(low[n_h]) - h0); + dst[di] = x2; + dst[di + 1] = t((i32::from(x_new) + i32::from(x2)) / 2 + 2 * h0); + dst[di + 2] = x_new; + } + } else { + let x_new = t(i32::from(low[n_h]) - h0 / 2); + dst[di] = x2; + dst[di + 1] = t((i32::from(x_new) + i32::from(x2)) / 2 + 2 * h0); + dst[di + 2] = x_new; + dst[di + 3] = t((i32::from(x_new) + i32::from(low[n_h + 1])) / 2); + } +} + +/// 1D inverse DWT on a strided column. +/// +/// Reads from `src` with stride, writes to `dst` with stride. +#[expect(clippy::too_many_arguments)] +fn idwt_col( + src: &[i16], + l_start: usize, + h_start: usize, + src_stride: usize, + dst: &mut [i16], + d_start: usize, + dst_stride: usize, + n_l: usize, + n_h: usize, +) { + let l = |i: usize| i32::from(src[l_start + i * src_stride]); + let h = |i: usize| i32::from(src[h_start + i * src_stride]); + + let mut h0 = h(0); + let mut x0 = t(l(0) - h0); + let mut x2 = x0; + + let mut d = d_start; + + for j in 0..n_h - 1 { + let h1 = h(j + 1); + x2 = t(l(j + 1) - (h0 + h1) / 2); + let x1 = t((i32::from(x0) + i32::from(x2)) / 2 + 2 * h0); + dst[d] = x0; + d += dst_stride; + dst[d] = x1; + d += dst_stride; + x0 = x2; + h0 = h1; + } + + if n_l <= n_h + 1 { + if n_l <= n_h { + dst[d] = x2; + d += dst_stride; + dst[d] = t(i32::from(x2) + 2 * h0); + } else { + let x_new = t(l(n_h) - h0); + dst[d] = x2; + d += dst_stride; + dst[d] = t((i32::from(x_new) + i32::from(x2)) / 2 + 2 * h0); + d += dst_stride; + dst[d] = x_new; + } + } else { + let x_new = t(l(n_h) - h0 / 2); + dst[d] = x2; + d += dst_stride; + dst[d] = t((i32::from(x_new) + i32::from(x2)) / 2 + 2 * h0); + d += dst_stride; + dst[d] = x_new; + d += dst_stride; + dst[d] = t((i32::from(x_new) + l(n_h + 1)) / 2); + } +} + +// --------------------------------------------------------------------------- +// Forward (encode) implementation +// --------------------------------------------------------------------------- + +/// Forward DWT for one decomposition level. +#[expect(clippy::similar_names, reason = "hl/lh/hh/ll are standard DWT subband names")] +fn encode_block(buffer: &mut [i16], temp: &mut [i16], n_l: usize, n_h: usize) { + let src_w = n_l + n_h; + + let hl_off = 0; + let lh_off = n_h * n_l; + let hh_off = lh_off + n_l * n_h; + let ll_off = hh_off + n_h * n_h; + + let l_off = 0; + let h_off = n_l * src_w; + + // Forward vertical: split columns into L (n_l rows) and H (n_h rows) in temp + for col in 0..src_w { + dwt_col(buffer, col, src_w, temp, l_off + col, h_off + col, src_w, n_l, n_h); + } + + // Forward horizontal on L rows: produce LL and HL subbands. + // Read from temp (vertical output), write directly to scattered buffer locations. + for row in 0..n_l { + let l_start = l_off + row * src_w; + dwt_row_scattered( + &temp[l_start..l_start + src_w], + buffer, + ll_off + row * n_l, + hl_off + row * n_h, + n_l, + n_h, + ); + } + + // Forward horizontal on H rows: produce LH and HH subbands + for row in 0..n_h { + let h_start = h_off + row * src_w; + dwt_row_scattered( + &temp[h_start..h_start + src_w], + buffer, + lh_off + row * n_l, + hh_off + row * n_h, + n_l, + n_h, + ); + } +} + +/// 1D forward DWT: reads from `input`, writes low-pass to `out[low_off..]` and +/// high-pass to `out[high_off..]`. This avoids needing two separate mutable +/// slice borrows. +fn dwt_row_scattered(input: &[i16], out: &mut [i16], low_off: usize, high_off: usize, n_l: usize, n_h: usize) { + // Predict step: compute high-pass from odd samples + for i in 0..n_h { + let x0 = i32::from(input[2 * i]); + let x1 = i32::from(input[2 * i + 1]); + let x2 = i32::from(input[2 * i + 2]); + out[high_off + i] = t((x1 - (x0 + x2) / 2) / 2); + } + + // Update step: compute low-pass from even samples + high-pass + out[low_off] = t(i32::from(input[0]) + i32::from(out[high_off])); + for i in 1..n_h { + let h_prev = i32::from(out[high_off + i - 1]); + let h_curr = i32::from(out[high_off + i]); + out[low_off + i] = t(i32::from(input[2 * i]) + (h_prev + h_curr) / 2); + } + + if n_l <= n_h + 1 { + let h_last = i32::from(out[high_off + n_h - 1]); + out[low_off + n_h] = t(i32::from(input[2 * n_h]) + h_last); + } else { + let h_last = i32::from(out[high_off + n_h - 1]); + out[low_off + n_h] = t(i32::from(input[2 * n_h]) + h_last / 2); + out[low_off + n_h + 1] = t(2 * i32::from(input[n_l + n_h - 1]) - i32::from(input[n_l + n_h - 2])); + } +} + +/// 1D forward DWT on a strided column. +/// +/// Reads from `src` at stride `s_stride`, writes low-pass to `dst[l_start..]` +/// and high-pass to `dst[h_start..]` at stride `d_stride`. +#[expect(clippy::too_many_arguments)] +fn dwt_col( + src: &[i16], + s_start: usize, + s_stride: usize, + dst: &mut [i16], + l_start: usize, + h_start: usize, + d_stride: usize, + n_l: usize, + n_h: usize, +) { + let x = |i: usize| i32::from(src[s_start + i * s_stride]); + + // Predict: compute high-pass + for i in 0..n_h { + dst[h_start + i * d_stride] = t((x(2 * i + 1) - (x(2 * i) + x(2 * i + 2)) / 2) / 2); + } + + // Update: compute low-pass (reads high-pass values we just wrote) + dst[l_start] = t(x(0) + i32::from(dst[h_start])); + + for i in 1..n_h { + let h_prev = i32::from(dst[h_start + (i - 1) * d_stride]); + let h_curr = i32::from(dst[h_start + i * d_stride]); + dst[l_start + i * d_stride] = t(x(2 * i) + (h_prev + h_curr) / 2); + } + + if n_l <= n_h + 1 { + let h_last = i32::from(dst[h_start + (n_h - 1) * d_stride]); + dst[l_start + n_h * d_stride] = t(x(2 * n_h) + h_last); + } else { + let n = n_l + n_h; + let h_last = i32::from(dst[h_start + (n_h - 1) * d_stride]); + dst[l_start + n_h * d_stride] = t(x(2 * n_h) + h_last / 2); + dst[l_start + (n_h + 1) * d_stride] = t(2 * x(n - 1) - x(n - 2)); + } +} + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- + +/// Truncate i32 to i16 (matches the `i32_to_i16_possible_truncation` pattern +/// in the existing `dwt.rs`). DWT coefficients stay within i16 range for +/// typical image data; truncation handles rare overflow gracefully. +#[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "intentional truncation matching existing DWT convention" +)] +fn t(value: i32) -> i16 { + value as i16 +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[expect(clippy::as_conversions, clippy::cast_possible_truncation, clippy::cast_possible_wrap)] +mod tests { + use super::*; + + #[test] + fn band_dimensions_match_spec() { + let bands = band_layout(); + + // Verify dimensions from MS-RDPEGFX spec + assert_eq!((bands[0].width, bands[0].height), (31, 33), "HL1"); + assert_eq!((bands[1].width, bands[1].height), (33, 31), "LH1"); + assert_eq!((bands[2].width, bands[2].height), (31, 31), "HH1"); + assert_eq!((bands[3].width, bands[3].height), (16, 17), "HL2"); + assert_eq!((bands[4].width, bands[4].height), (17, 16), "LH2"); + assert_eq!((bands[5].width, bands[5].height), (16, 16), "HH2"); + assert_eq!((bands[6].width, bands[6].height), (8, 9), "HL3"); + assert_eq!((bands[7].width, bands[7].height), (9, 8), "LH3"); + assert_eq!((bands[8].width, bands[8].height), (8, 8), "HH3"); + assert_eq!((bands[9].width, bands[9].height), (9, 9), "LL3"); + } + + #[test] + fn band_offsets_match_freerdp() { + let bands = band_layout(); + + assert_eq!(bands[0].offset, 0, "HL1"); + assert_eq!(bands[1].offset, 1023, "LH1"); + assert_eq!(bands[2].offset, 2046, "HH1"); + assert_eq!(bands[3].offset, 3007, "HL2"); + assert_eq!(bands[4].offset, 3279, "LH2"); + assert_eq!(bands[5].offset, 3551, "HH2"); + assert_eq!(bands[6].offset, 3807, "HL3"); + assert_eq!(bands[7].offset, 3879, "LH3"); + assert_eq!(bands[8].offset, 3951, "HH3"); + assert_eq!(bands[9].offset, 4015, "LL3"); + } + + #[test] + fn band_total_is_4096() { + let bands = band_layout(); + let total: usize = bands.iter().map(|b| b.count()).sum(); + assert_eq!(total, 4096); + } + + #[test] + fn low_high_counts() { + assert_eq!(low_count(64), 33); + assert_eq!(high_count(64), 31); + assert_eq!(low_count(33), 17); + assert_eq!(high_count(33), 16); + assert_eq!(low_count(17), 9); + assert_eq!(high_count(17), 8); + } + + #[test] + fn decode_all_zeros() { + let mut buffer = vec![0i16; 4096]; + let mut temp = vec![0i16; 4096]; + decode(&mut buffer, &mut temp); + // All-zero coefficients should produce all-zero output + assert!(buffer.iter().all(|&v| v == 0)); + } + + #[test] + fn encode_all_zeros() { + let mut buffer = vec![0i16; 4096]; + let mut temp = vec![0i16; 4096]; + encode(&mut buffer, &mut temp); + assert!(buffer.iter().all(|&v| v == 0)); + } + + #[test] + fn round_trip_identity() { + // A simple signal: DC component only (flat tile) + let mut buffer = vec![100i16; 4096]; + let original = buffer.clone(); + let mut temp = vec![0i16; 4096]; + + encode(&mut buffer, &mut temp); + decode(&mut buffer, &mut temp); + + // Due to integer truncation, allow small per-sample error + let max_err: i16 = buffer + .iter() + .zip(original.iter()) + .map(|(&a, &b)| (a - b).abs()) + .max() + .unwrap_or(0); + assert!(max_err <= 2, "max round-trip error {max_err} exceeds tolerance"); + } + + #[test] + fn round_trip_gradient() { + // Horizontal gradient: tests non-trivial frequency content + let mut buffer = vec![0i16; 4096]; + for row in 0..64 { + for col in 0..64 { + buffer[row * 64 + col] = col as i16 * 4; + } + } + let original = buffer.clone(); + let mut temp = vec![0i16; 4096]; + + encode(&mut buffer, &mut temp); + decode(&mut buffer, &mut temp); + + let max_err: i16 = buffer + .iter() + .zip(original.iter()) + .map(|(&a, &b)| (a - b).abs()) + .max() + .unwrap_or(0); + assert!(max_err <= 4, "max round-trip error {max_err} exceeds tolerance"); + } + + #[test] + fn round_trip_random_like() { + // Pseudo-random signal using a simple LCG to test general case + let mut buffer = vec![0i16; 4096]; + let mut seed: u32 = 12345; + for val in buffer.iter_mut() { + seed = seed.wrapping_mul(1103515245).wrapping_add(12345); + *val = ((seed >> 16) as i16) >> 4; // range roughly -2048..2047 + } + let original = buffer.clone(); + let mut temp = vec![0i16; 4096]; + + encode(&mut buffer, &mut temp); + decode(&mut buffer, &mut temp); + + let max_err: i16 = buffer + .iter() + .zip(original.iter()) + .map(|(&a, &b)| (a - b).abs()) + .max() + .unwrap_or(0); + // Reduce-extrapolate DWT has slightly more rounding error at + // boundaries than standard DWT due to asymmetric sample counts + assert!(max_err <= 6, "max round-trip error {max_err} exceeds tolerance"); + } + + #[test] + fn idwt_row_even_input() { + // Test the 1D inverse on a small even-length case (n_l=3, n_h=1) + let low = [10i16, 20, 30]; + let high = [5i16]; + let mut dst = [0i16; 4]; + idwt_row(&low, &high, &mut dst); + // Just verify it doesn't panic and produces 4 values + assert_eq!(dst.len(), 4); + } + + #[test] + fn idwt_row_odd_input() { + // Test the 1D inverse on a small odd-length case (n_l=2, n_h=1) + let low = [10i16, 20]; + let high = [5i16]; + let mut dst = [0i16; 3]; + idwt_row(&low, &high, &mut dst); + assert_eq!(dst.len(), 3); + } +} diff --git a/crates/ironrdp-graphics/src/image_processing.rs b/crates/ironrdp-graphics/src/image_processing.rs index 1dc22bbf8b..1b76dc6377 100644 --- a/crates/ironrdp-graphics/src/image_processing.rs +++ b/crates/ironrdp-graphics/src/image_processing.rs @@ -1,10 +1,7 @@ use core::{cmp, fmt}; use std::io; -use byteorder::WriteBytesExt as _; use ironrdp_pdu::geometry::{InclusiveRectangle, Rectangle as _}; -use num_derive::ToPrimitive; -use num_traits::ToPrimitive as _; const ALPHA_OPAQUE: u8 = 0xff; @@ -99,7 +96,7 @@ impl ImageRegion<'_> { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] pub enum PixelFormat { ARgb32 = 536_971_400, XRgb32 = 536_938_632, @@ -130,6 +127,19 @@ impl TryFrom for PixelFormat { } impl PixelFormat { + fn as_u32(&self) -> u32 { + match self { + Self::ARgb32 => 536_971_400, + Self::XRgb32 => 536_938_632, + Self::ABgr32 => 537_036_936, + Self::XBgr32 => 537_004_168, + Self::BgrA32 => 537_168_008, + Self::BgrX32 => 537_135_240, + Self::RgbA32 => 537_102_472, + Self::RgbX32 => 537_069_704, + } + } + pub const fn bytes_per_pixel(self) -> u8 { match self { Self::ARgb32 @@ -146,134 +156,57 @@ impl PixelFormat { pub fn eq_no_alpha(self, other: Self) -> bool { let mask = !(8 << 12); - (self.to_u32().unwrap() & mask) == (other.to_u32().unwrap() & mask) + (self.as_u32() & mask) == (other.as_u32() & mask) } - pub fn read_color(self, buffer: &[u8]) -> io::Result { + /// Returns the byte offsets for the (r, g, b, a) channels within a pixel. + pub const fn channel_offsets(self) -> [usize; 4] { match self { - Self::ARgb32 - | Self::XRgb32 - | Self::ABgr32 - | Self::XBgr32 - | Self::BgrA32 - | Self::BgrX32 - | Self::RgbA32 - | Self::RgbX32 => { - if buffer.len() < 4 { - Err(io::Error::new( - io::ErrorKind::InvalidInput, - "input buffer is not large enough (this is a bug)", - )) - } else { - let color = &buffer[..4]; - - match self { - Self::ARgb32 => Ok(Rgba { - a: color[0], - r: color[1], - g: color[2], - b: color[3], - }), - Self::XRgb32 => Ok(Rgba { - a: ALPHA_OPAQUE, - r: color[1], - g: color[2], - b: color[3], - }), - Self::ABgr32 => Ok(Rgba { - a: color[0], - b: color[1], - g: color[2], - r: color[3], - }), - Self::XBgr32 => Ok(Rgba { - a: ALPHA_OPAQUE, - b: color[1], - g: color[2], - r: color[3], - }), - Self::BgrA32 => Ok(Rgba { - b: color[0], - g: color[1], - r: color[2], - a: color[3], - }), - Self::BgrX32 => Ok(Rgba { - b: color[0], - g: color[1], - r: color[2], - a: ALPHA_OPAQUE, - }), - Self::RgbA32 => Ok(Rgba { - r: color[0], - g: color[1], - b: color[2], - a: color[3], - }), - Self::RgbX32 => Ok(Rgba { - r: color[0], - g: color[1], - b: color[2], - a: ALPHA_OPAQUE, - }), - } - } - } + Self::ARgb32 | Self::XRgb32 => [1, 2, 3, 0], + Self::ABgr32 | Self::XBgr32 => [3, 2, 1, 0], + Self::BgrA32 | Self::BgrX32 => [2, 1, 0, 3], + Self::RgbA32 | Self::RgbX32 => [0, 1, 2, 3], } } - pub fn write_color(self, color: Rgba, mut buffer: &mut [u8]) -> io::Result<()> { + /// Returns `true` if this format carries an alpha channel, `false` for X (padding) formats. + pub const fn has_alpha(self) -> bool { match self { - Self::ARgb32 => { - buffer.write_u8(color.a)?; - buffer.write_u8(color.r)?; - buffer.write_u8(color.g)?; - buffer.write_u8(color.b)?; - } - Self::XRgb32 => { - buffer.write_u8(ALPHA_OPAQUE)?; - buffer.write_u8(color.r)?; - buffer.write_u8(color.g)?; - buffer.write_u8(color.b)?; - } - Self::ABgr32 => { - buffer.write_u8(color.a)?; - buffer.write_u8(color.b)?; - buffer.write_u8(color.g)?; - buffer.write_u8(color.r)?; - } - Self::XBgr32 => { - buffer.write_u8(ALPHA_OPAQUE)?; - buffer.write_u8(color.b)?; - buffer.write_u8(color.g)?; - buffer.write_u8(color.r)?; - } - Self::BgrA32 => { - buffer.write_u8(color.b)?; - buffer.write_u8(color.g)?; - buffer.write_u8(color.r)?; - buffer.write_u8(color.a)?; - } - Self::BgrX32 => { - buffer.write_u8(color.b)?; - buffer.write_u8(color.g)?; - buffer.write_u8(color.r)?; - buffer.write_u8(ALPHA_OPAQUE)?; - } - Self::RgbA32 => { - buffer.write_u8(color.r)?; - buffer.write_u8(color.g)?; - buffer.write_u8(color.b)?; - buffer.write_u8(color.a)?; - } - Self::RgbX32 => { - buffer.write_u8(color.r)?; - buffer.write_u8(color.g)?; - buffer.write_u8(color.b)?; - buffer.write_u8(ALPHA_OPAQUE)?; - } + Self::ARgb32 | Self::ABgr32 | Self::BgrA32 | Self::RgbA32 => true, + Self::XRgb32 | Self::XBgr32 | Self::BgrX32 | Self::RgbX32 => false, + } + } + + pub fn read_color(self, buffer: &[u8]) -> io::Result { + if buffer.len() < 4 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "input buffer is not large enough (this is a bug)", + )); + } + + let [ri, gi, bi, ai] = self.channel_offsets(); + Ok(Rgba { + r: buffer[ri], + g: buffer[gi], + b: buffer[bi], + a: if self.has_alpha() { buffer[ai] } else { ALPHA_OPAQUE }, + }) + } + + pub fn write_color(self, color: Rgba, buffer: &mut [u8]) -> io::Result<()> { + if buffer.len() < 4 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "output buffer is not large enough (this is a bug)", + )); } + let [ri, gi, bi, ai] = self.channel_offsets(); + buffer[ri] = color.r; + buffer[gi] = color.g; + buffer[bi] = color.b; + buffer[ai] = if self.has_alpha() { color.a } else { ALPHA_OPAQUE }; Ok(()) } } diff --git a/crates/ironrdp-graphics/src/lib.rs b/crates/ironrdp-graphics/src/lib.rs index 192c5bbaae..f375cf701b 100644 --- a/crates/ironrdp-graphics/src/lib.rs +++ b/crates/ironrdp-graphics/src/lib.rs @@ -1,26 +1,29 @@ #![cfg_attr(doc, doc = include_str!("../README.md"))] #![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] #![allow(clippy::arithmetic_side_effects)] // FIXME: remove -#![allow(clippy::cast_lossless)] // FIXME: remove -#![allow(clippy::cast_possible_truncation)] // FIXME: remove -#![allow(clippy::cast_possible_wrap)] // FIXME: remove -#![allow(clippy::cast_sign_loss)] // FIXME: remove +pub mod clearcodec; pub mod color_conversion; pub mod diff; pub mod dwt; +pub mod dwt_extrapolate; pub mod image_processing; pub mod pointer; +pub mod progressive; pub mod quantization; pub mod rdp6; pub mod rectangle_processing; pub mod rle; pub mod rlgr; +pub mod srl; pub mod subband_reconstruction; pub mod zgfx; mod utils; +/// # Panics +/// +/// Panics if `input.len()` is not 4096 (64 * 46). pub fn rfx_encode_component( input: &mut [i16], output: &mut [u8], diff --git a/crates/ironrdp-graphics/src/pointer.rs b/crates/ironrdp-graphics/src/pointer.rs index 48b1792758..91fa127dd3 100644 --- a/crates/ironrdp-graphics/src/pointer.rs +++ b/crates/ironrdp-graphics/src/pointer.rs @@ -236,7 +236,7 @@ impl DecodedPointer { let compute_inverted_pixel = if target.should_invert_pixels_using_check_pattern() { |row_idx: u16, col_idx: u16| -> [u8; 4] { // Checkered pattern is used to represent inverted pixels. - if (row_idx + col_idx) % 2 == 0 { + if (row_idx + col_idx).is_multiple_of(2) { [0xff, 0xff, 0xff, 0xff] } else { [0x00, 0x00, 0x00, 0xff] @@ -260,9 +260,12 @@ impl DecodedPointer { } else if target.should_premultiply_alpha() { // Calculate premultiplied alpha via integer arithmetic let with_premultiplied_alpha = [ - ((color[0] as u16 * color[0] as u16) >> 8) as u8, - ((color[1] as u16 * color[1] as u16) >> 8) as u8, - ((color[2] as u16 * color[2] as u16) >> 8) as u8, + u8::try_from((u16::from(color[0]) * u16::from(color[0])) >> 8) + .expect("(u16 >> 8) fits into u8"), + u8::try_from((u16::from(color[1]) * u16::from(color[1])) >> 8) + .expect("(u16 >> 8) fits into u8"), + u8::try_from((u16::from(color[2]) * u16::from(color[2])) >> 8) + .expect("(u16 >> 8) fits into u8"), color[3], ]; bitmap_data.extend_from_slice(&with_premultiplied_alpha); diff --git a/crates/ironrdp-graphics/src/progressive.rs b/crates/ironrdp-graphics/src/progressive.rs new file mode 100644 index 0000000000..a6a4273f1a --- /dev/null +++ b/crates/ironrdp-graphics/src/progressive.rs @@ -0,0 +1,1862 @@ +//! Progressive RFX decode and encode algorithms ([MS-RDPEGFX] 2.2.4.2). +//! +//! Provides first-pass decode (RLGR1 + progressive dequantization + sign capture) +//! and upgrade-pass decode (SRL/raw routing by DAS sign state, coefficient +//! accumulation) for the RemoteFX Progressive codec. +//! +//! These are pure algorithmic functions operating on coefficient buffers. +//! Tile state management and EGFX integration belong in a higher layer. + +extern crate alloc; + +use alloc::collections::BTreeMap; +use alloc::collections::btree_map::Entry; + +use ironrdp_pdu::codecs::rfx::EntropyAlgorithm; +use ironrdp_pdu::codecs::rfx::progressive::ComponentCodecQuant; + +use crate::dwt_extrapolate::BandInfo; +use crate::rlgr::RlgrError; +use crate::srl; + +/// Number of DWT coefficients per component in a 64x64 tile. +pub const COEFFICIENTS_PER_COMPONENT: usize = 4096; + +/// Number of subbands in a 3-level DWT decomposition. +pub const NUM_BANDS: usize = 10; + +/// DAS (Delta-Analysis State) values for tri-state sign tracking. +/// +/// After the first pass, each coefficient position is classified: +/// - `SIGN_ZERO`: coefficient was zero (eligible for SRL upgrade) +/// - `SIGN_POSITIVE`: coefficient was positive (eligible for raw upgrade) +/// - `SIGN_NEGATIVE`: coefficient was negative (eligible for raw upgrade) +pub const SIGN_ZERO: i8 = 0; +pub const SIGN_POSITIVE: i8 = 1; +pub const SIGN_NEGATIVE: i8 = -1; + +// --------------------------------------------------------------------------- +// First-pass decode (TILE_SIMPLE / TILE_FIRST) +// --------------------------------------------------------------------------- + +/// Decode a first-pass component from RLGR1-encoded data. +/// +/// Performs: RLGR1 decode -> base dequantization -> progressive dequantization +/// -> LL3 delta decode -> sign capture. +/// +/// # Arguments +/// - `data`: RLGR1-encoded coefficient stream +/// - `base_quant`: base quantization values (from region quant table, `ComponentCodecQuant` format) +/// - `prog_quant`: progressive quantization BitPos values for this quality level +/// - `use_reduce_extrapolate`: whether to use asymmetric band sizes +/// - `coefficients`: output buffer for decoded coefficients (4096 i16) +/// - `sign`: output buffer for DAS sign state (4096 i8) +/// +/// # Panics +/// +/// Panics if `coefficients` or `sign` has fewer than 4096 elements. +/// +/// # Errors +/// Returns `RlgrError` if RLGR decoding fails. +pub fn decode_first_pass( + data: &[u8], + base_quant: &ComponentCodecQuant, + prog_quant: &ComponentCodecQuant, + use_reduce_extrapolate: bool, + coefficients: &mut [i16], + sign: &mut [i8], +) -> Result<(), RlgrError> { + assert!(coefficients.len() >= COEFFICIENTS_PER_COMPONENT); + assert!(sign.len() >= COEFFICIENTS_PER_COMPONENT); + + // Step 1: RLGR1 decode into coefficient buffer + crate::rlgr::decode(EntropyAlgorithm::Rlgr1, data, coefficients)?; + + // Step 2: LL3 differential decoding (reverse delta encoding on last subband) + crate::subband_reconstruction::decode(&mut coefficients[ll3_offset(use_reduce_extrapolate)..]); + + // Step 3: Base dequantization (shift left by quant - 1) + dequantize_component_ccq(coefficients, base_quant, use_reduce_extrapolate); + + // Step 4: Progressive dequantization (shift left by BitPos) + progressive_dequantize(coefficients, prog_quant, use_reduce_extrapolate); + + // Step 5: Capture sign state for DAS + capture_sign(coefficients, sign); + + Ok(()) +} + +/// Decode an upgrade-pass component from SRL and raw data streams. +/// +/// For each coefficient position: +/// - DAS = 0 (zero): decode from SRL stream, update DAS if non-zero +/// - DAS != 0 (non-zero): decode raw magnitude bits, accumulate +/// +/// # Arguments +/// - `srl_data`: SRL-encoded stream for zero-DAS positions +/// - `raw_data`: raw bit stream for non-zero-DAS positions +/// - `prev_prog_quant`: BitPos values from previous quality level +/// - `curr_prog_quant`: BitPos values for this quality level +/// - `use_reduce_extrapolate`: whether to use asymmetric band sizes +/// - `coefficients`: coefficient buffer to accumulate into (modified in-place) +/// - `sign`: DAS sign buffer (modified in-place when zeros become non-zero) +/// +/// # Panics +/// +/// Panics if `coefficients` or `sign` has fewer than 4096 elements. +pub fn decode_upgrade_pass( + srl_data: &[u8], + raw_data: &[u8], + prev_prog_quant: &ComponentCodecQuant, + curr_prog_quant: &ComponentCodecQuant, + use_reduce_extrapolate: bool, + coefficients: &mut [i16], + sign: &mut [i8], +) { + assert!(coefficients.len() >= COEFFICIENTS_PER_COMPONENT); + assert!(sign.len() >= COEFFICIENTS_PER_COMPONENT); + + let bands = get_band_layout(use_reduce_extrapolate); + + for (band_idx, band) in bands.iter().enumerate() { + let prev_bit_pos = prev_prog_quant.for_band(band_idx); + let curr_bit_pos = curr_prog_quant.for_band(band_idx); + + // Number of raw bits per coefficient in this band + let num_bits = prev_bit_pos.saturating_sub(curr_bit_pos); + if num_bits == 0 { + continue; + } + + // Count zero-DAS positions in this band (for SRL decode) + let zero_count = band_zero_count(sign, band); + + // SRL decode for zero-DAS positions + let srl_values = srl::decode_srl(srl_data, zero_count, num_bits); + + // Apply upgrade values to this band + let mut srl_idx = 0; + let mut raw_reader = RawBitReader::new(raw_data); + + for i in 0..band.count() { + let coeff_idx = band.offset + i; + let is_ll3 = band_idx == 9; + + if sign[coeff_idx] == SIGN_ZERO { + // Zero-DAS: get value from SRL stream + let value = if srl_idx < srl_values.len() { + srl_values[srl_idx] + } else { + 0 + }; + srl_idx += 1; + + if value != 0 { + // Coefficient transitions from zero to non-zero + let shifted = i32::from(value) << i32::from(curr_bit_pos); + coefficients[coeff_idx] = clamp_i16(shifted); + sign[coeff_idx] = if value > 0 { SIGN_POSITIVE } else { SIGN_NEGATIVE }; + } + } else { + // Non-zero DAS: read raw magnitude bits + let raw_mag = raw_reader.read_bits(u32::from(num_bits)); + + if raw_mag != 0 { + // raw_mag fits in i32 (at most 2^15 from bit stream) + let mag_i32 = i32::try_from(raw_mag).unwrap_or(i32::MAX); + let shifted = mag_i32 << i32::from(curr_bit_pos); + if is_ll3 || sign[coeff_idx] == SIGN_POSITIVE { + // LL3 is always positive; positive DAS adds + coefficients[coeff_idx] = clamp_i16(i32::from(coefficients[coeff_idx]) + shifted); + } else { + // Negative DAS subtracts + coefficients[coeff_idx] = clamp_i16(i32::from(coefficients[coeff_idx]) - shifted); + } + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Progressive (de)quantization +// --------------------------------------------------------------------------- + +/// Apply progressive dequantization: left-shift each band by its BitPos value. +/// +/// For non-LL3 bands, this shifts the absolute value (preserving sign). +/// For LL3, this is a simple left shift (floor toward negative infinity). +fn progressive_dequantize(coefficients: &mut [i16], prog_quant: &ComponentCodecQuant, use_reduce_extrapolate: bool) { + let bands = get_band_layout(use_reduce_extrapolate); + + for (band_idx, band) in bands.iter().enumerate() { + let bit_pos = prog_quant.for_band(band_idx); + if bit_pos == 0 { + continue; + } + + let is_ll3 = band_idx == 9; + let start = band.offset; + let end = start + band.count(); + + if is_ll3 { + // LL3: simple left shift (floor toward negative infinity) + for coeff in &mut coefficients[start..end] { + *coeff = clamp_i16(i32::from(*coeff) << i32::from(bit_pos)); + } + } else { + // Other bands: shift absolute value, preserve sign + for coeff in &mut coefficients[start..end] { + let val = i32::from(*coeff); + if val >= 0 { + *coeff = clamp_i16(val << i32::from(bit_pos)); + } else { + *coeff = clamp_i16(-((-val) << i32::from(bit_pos))); + } + } + } + } +} + +/// Apply progressive quantization: right-shift each band by its BitPos value. +/// +/// Inverse of `progressive_dequantize`. +pub fn progressive_quantize(coefficients: &mut [i16], prog_quant: &ComponentCodecQuant, use_reduce_extrapolate: bool) { + let bands = get_band_layout(use_reduce_extrapolate); + + for (band_idx, band) in bands.iter().enumerate() { + let bit_pos = prog_quant.for_band(band_idx); + if bit_pos == 0 { + continue; + } + + let is_ll3 = band_idx == 9; + let start = band.offset; + let end = start + band.count(); + + if is_ll3 { + // LL3: floor division (right shift) + for coeff in &mut coefficients[start..end] { + *coeff >>= bit_pos; + } + } else { + // Other bands: truncation toward zero + for coeff in &mut coefficients[start..end] { + let val = i32::from(*coeff); + if val >= 0 { + *coeff = clamp_i16(val >> i32::from(bit_pos)); + } else { + *coeff = clamp_i16(-((-val) >> i32::from(bit_pos))); + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Server-side encode pipeline +// --------------------------------------------------------------------------- + +/// Encode a first-pass component from spatial-domain coefficients. +/// +/// Pipeline: forward DWT -> base quantization -> progressive quantization +/// -> LL3 delta encode -> RLGR1 encode. +/// +/// Returns the number of bytes written to `output`. +/// +/// # Arguments +/// - `coefficients`: spatial-domain coefficients (4096 i16, modified in-place) +/// - `output`: output buffer for RLGR1-encoded data +/// - `base_quant`: base quantization values +/// - `prog_quant`: progressive quantization BitPos values for this quality level +/// - `use_reduce_extrapolate`: DWT mode flag +/// +/// # Panics +/// +/// Panics if `coefficients` has fewer than 4096 elements. +/// +/// # Errors +/// Returns `RlgrError` if RLGR encoding fails. +pub fn encode_first_pass( + coefficients: &mut [i16], + output: &mut [u8], + base_quant: &ComponentCodecQuant, + prog_quant: &ComponentCodecQuant, + use_reduce_extrapolate: bool, +) -> Result { + assert!(coefficients.len() >= COEFFICIENTS_PER_COMPONENT); + + let mut temp = [0i16; COEFFICIENTS_PER_COMPONENT]; + + // Step 1: Forward DWT + if use_reduce_extrapolate { + crate::dwt_extrapolate::encode(coefficients, &mut temp); + } else { + crate::dwt::encode(coefficients, &mut temp); + } + + // Step 2: Base quantization (right-shift by quant - 1) + quantize_component_ccq(coefficients, base_quant, use_reduce_extrapolate); + + // Step 3: Progressive quantization (right-shift by BitPos) + progressive_quantize(coefficients, prog_quant, use_reduce_extrapolate); + + // Step 4: LL3 delta encoding + crate::subband_reconstruction::encode(&mut coefficients[ll3_offset(use_reduce_extrapolate)..]); + + // Step 5: RLGR1 entropy encode + crate::rlgr::encode(EntropyAlgorithm::Rlgr1, coefficients, output) +} + +/// Base quantization using `ComponentCodecQuant` (progressive format). +/// +/// Each band is right-shifted by `(quant_value - 1)`. Inverse of `dequantize_component_ccq`. +fn quantize_component_ccq(coefficients: &mut [i16], quant: &ComponentCodecQuant, use_reduce_extrapolate: bool) { + let bands = get_band_layout(use_reduce_extrapolate); + + for (band_idx, band) in bands.iter().enumerate() { + let q = quant.for_band(band_idx); + let factor = q.saturating_sub(1); + if factor > 0 { + let start = band.offset; + let end = start + band.count(); + for coeff in &mut coefficients[start..end] { + // Truncation toward zero (same as classic quantization::encode) + let val = i32::from(*coeff); + if val >= 0 { + *coeff = clamp_i16(val >> i32::from(factor)); + } else { + *coeff = clamp_i16(-((-val) >> i32::from(factor))); + } + } + } + } +} + +/// Compute the upgrade-pass data for a single component. +/// +/// Given the previous and current progressive quantization, produces +/// SRL-encoded data (for zero-DAS positions) and raw bit data (for +/// non-zero DAS positions) representing the refinement. +/// +/// # Arguments +/// - `coefficients`: current full-resolution DWT coefficients for this component +/// - `prev_coefficients`: coefficients as reconstructed from the previous pass +/// - `prev_prog_quant`: BitPos values from the previous pass +/// - `curr_prog_quant`: BitPos values for this upgrade pass +/// - `sign`: DAS sign array from the previous pass +/// - `use_reduce_extrapolate`: DWT mode flag +/// +/// # Returns +/// A tuple of `(srl_data, raw_data)` byte vectors. +/// +/// # Wire-format invariants (MS-RDPRFX 3.1.8.1.7.2) +/// +/// The non-zero-DAS raw-magnitude path uses `saturating_sub` to compute +/// `raw_mag = curr_q - prev_q`. Upgrade passes are *monotonic refinements*: +/// the encoder only adds magnitude bits, never subtracts. The decoder's +/// counterpart accumulates raw_mag onto the previously-decoded coefficient +/// with the DAS-determined sign (`+=` for SIGN_POSITIVE / LL3, `-=` for +/// SIGN_NEGATIVE), so a hypothetical signed delta would have no place in +/// the wire format. Switching this to a signed-delta encoding would break +/// wire compatibility with mstsc/FreeRDP — do not "fix" the saturating_sub. +/// +/// The zero-DAS SRL path uses `clamp_i16(curr_shifted - prev_shifted)`. SRL +/// stream values are i16 by wire-format definition, so wider precision is +/// not available without a spec extension. The clamp is the wire-format +/// boundary, not a precision compromise. +pub fn encode_upgrade_pass( + coefficients: &[i16], + prev_coefficients: &[i16], + prev_prog_quant: &ComponentCodecQuant, + curr_prog_quant: &ComponentCodecQuant, + sign: &[i8], + use_reduce_extrapolate: bool, +) -> (Vec, Vec) { + let bands = get_band_layout(use_reduce_extrapolate); + let mut all_srl_values = Vec::new(); + let mut raw_writer = RawBitWriter::new(); + + for (band_idx, band) in bands.iter().enumerate() { + let prev_bit_pos = prev_prog_quant.for_band(band_idx); + let curr_bit_pos = curr_prog_quant.for_band(band_idx); + + let num_bits = prev_bit_pos.saturating_sub(curr_bit_pos); + if num_bits == 0 { + continue; + } + + let mut band_srl_values = Vec::new(); + + for i in 0..band.count() { + let coeff_idx = band.offset + i; + + if sign[coeff_idx] == SIGN_ZERO { + // Zero-DAS: compute the refined value and encode via SRL + let curr_shifted = i32::from(coefficients[coeff_idx]) >> i32::from(curr_bit_pos); + let prev_shifted = i32::from(prev_coefficients[coeff_idx]) >> i32::from(curr_bit_pos); + let delta = clamp_i16(curr_shifted - prev_shifted); + band_srl_values.push(delta); + } else { + // Non-zero DAS: compute raw magnitude bits + let curr_abs = i32::from(coefficients[coeff_idx]).unsigned_abs(); + let prev_abs = i32::from(prev_coefficients[coeff_idx]).unsigned_abs(); + + let curr_q = curr_abs >> u32::from(curr_bit_pos); + let prev_q = prev_abs >> u32::from(curr_bit_pos); + let raw_mag = curr_q.saturating_sub(prev_q); + + raw_writer.write_bits(raw_mag, u32::from(num_bits)); + } + } + + // Encode SRL values for this band + let srl_encoded = srl::encode_srl(&band_srl_values, num_bits); + all_srl_values.extend_from_slice(&srl_encoded); + } + + let raw_data = raw_writer.finish(); + (all_srl_values, raw_data) +} + +/// Encode RGBA pixels to spatial-domain i16 coefficients (RGB to YCbCr). +/// +/// Performs ITU-R BT.601 RGB-to-YCbCr conversion on a 64x64 pixel tile. +/// Output is 3 buffers of 4096 i16 coefficients (Y, Cb, Cr) in tile order. +/// +/// # Panics +/// +/// Panics if `pixels` has fewer than 64 * 64 * 4 = 16384 bytes. +#[expect(clippy::similar_names)] +pub fn rgba_to_ycbcr(pixels: &[u8], y_out: &mut [i16], cb_out: &mut [i16], cr_out: &mut [i16]) { + assert!(pixels.len() >= 64 * 64 * 4); + assert!(y_out.len() >= COEFFICIENTS_PER_COMPONENT); + assert!(cb_out.len() >= COEFFICIENTS_PER_COMPONENT); + assert!(cr_out.len() >= COEFFICIENTS_PER_COMPONENT); + + for i in 0..64 * 64 { + let off = i * 4; + let r = i32::from(pixels[off]); + let g = i32::from(pixels[off + 1]); + let b = i32::from(pixels[off + 2]); + + // ITU-R BT.601: Y = 0.299R + 0.587G + 0.114B + // Cb = -0.169R - 0.331G + 0.500B + // Cr = 0.500R - 0.419G - 0.081B + // Fixed-point with 16-bit precision + let y = ((19595 * r + 38470 * g + 7471 * b + 32768) >> 16) - 128; + let cb = (-11059 * r - 21709 * g + 32768 * b + 32768) >> 16; + let cr = (32768 * r - 27439 * g - 5329 * b + 32768) >> 16; + + y_out[i] = clamp_i16(y); + cb_out[i] = clamp_i16(cb); + cr_out[i] = clamp_i16(cr); + } +} + +/// Base dequantization using `ComponentCodecQuant` (progressive-format quantization). +/// +/// Each band is shifted left by `(quant_value - 1)`. Uses `for_band()` to map +/// band indices to quant values, which handles the progressive nibble ordering. +fn dequantize_component_ccq(coefficients: &mut [i16], quant: &ComponentCodecQuant, use_reduce_extrapolate: bool) { + let bands = get_band_layout(use_reduce_extrapolate); + + for (band_idx, band) in bands.iter().enumerate() { + let q = quant.for_band(band_idx); + let factor = i16::from(q).saturating_sub(1); + if factor > 0 { + let start = band.offset; + let end = start + band.count(); + for coeff in &mut coefficients[start..end] { + *coeff <<= factor; + } + } + } +} + +// --------------------------------------------------------------------------- +// Sign capture +// --------------------------------------------------------------------------- + +/// Capture the tri-state sign of each coefficient into the DAS array. +fn capture_sign(coefficients: &[i16], sign: &mut [i8]) { + for (s, &c) in sign.iter_mut().zip(coefficients.iter()) { + *s = match c.cmp(&0) { + core::cmp::Ordering::Greater => SIGN_POSITIVE, + core::cmp::Ordering::Less => SIGN_NEGATIVE, + core::cmp::Ordering::Equal => SIGN_ZERO, + }; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Get the band layout for the current DWT mode. +fn get_band_layout(use_reduce_extrapolate: bool) -> [BandInfo; NUM_BANDS] { + if use_reduce_extrapolate { + crate::dwt_extrapolate::band_layout() + } else { + standard_band_layout() + } +} + +/// Standard (non-extrapolate) band layout for a 64x64 tile. +/// Band sizes: 1024 each for level 1, 256 each for level 2, 64 each for level 3. +fn standard_band_layout() -> [BandInfo; NUM_BANDS] { + let mut off = 0; + let mut b = |w: usize, h: usize| { + let info = BandInfo { + width: w, + height: h, + offset: off, + }; + off += w * h; + info + }; + + [ + b(32, 32), // HL1: 1024 + b(32, 32), // LH1: 1024 + b(32, 32), // HH1: 1024 + b(16, 16), // HL2: 256 + b(16, 16), // LH2: 256 + b(16, 16), // HH2: 256 + b(8, 8), // HL3: 64 + b(8, 8), // LH3: 64 + b(8, 8), // HH3: 64 + b(8, 8), // LL3: 64 + ] +} + +/// Starting offset of the LL3 subband for delta decoding. +fn ll3_offset(use_reduce_extrapolate: bool) -> usize { + if use_reduce_extrapolate { + 4015 // reduce-extrapolate: 9x9 = 81 coefficients at offset 4015 + } else { + 4032 // standard: 8x8 = 64 coefficients at offset 4032 + } +} + +/// Count zero-DAS positions within a band. +fn band_zero_count(sign: &[i8], band: &BandInfo) -> usize { + let start = band.offset; + let end = start + band.count(); + sign[start..end].iter().filter(|&&s| s == SIGN_ZERO).count() +} + +/// Clamp i32 to u8 range (0-255). +#[expect( + clippy::as_conversions, + clippy::cast_sign_loss, + reason = "value is clamped to 0..255 before cast" +)] +fn clamp_u8(value: i32) -> u8 { + value.clamp(0, 255) as u8 +} + +/// Clamp i32 to i16 range. +#[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "value is clamped to i16 range before cast" +)] +fn clamp_i16(value: i32) -> i16 { + value.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16 +} + +// --------------------------------------------------------------------------- +// Raw bit I/O for upgrade pass +// --------------------------------------------------------------------------- + +/// Writes raw magnitude bits MSB-first to a byte stream. +/// +/// Symmetric counterpart of [`RawBitReader`]. Callers are expected to pass +/// `count <= 32` to [`write_bits`](Self::write_bits); the upgrade-pass call +/// site bounds `count` by `prev_bit_pos - curr_bit_pos` which is at most a +/// few bits in practice. `count > 32` reads beyond `u32` width in the shift +/// expression, which is wrap-on-release / panic-on-debug — caller responsibility. +struct RawBitWriter { + bytes: Vec, + current: u8, + bit_count: u8, +} + +impl RawBitWriter { + fn new() -> Self { + Self { + bytes: Vec::new(), + current: 0, + bit_count: 0, + } + } + + fn write_bit(&mut self, bit: bool) { + self.current = (self.current << 1) | u8::from(bit); + self.bit_count += 1; + if self.bit_count >= 8 { + self.bytes.push(self.current); + self.current = 0; + self.bit_count = 0; + } + } + + /// Write the low `count` bits of `value`, MSB-first. Caller must ensure + /// `count <= 32` (see type-level docs). + fn write_bits(&mut self, value: u32, count: u32) { + debug_assert!(count <= 32, "RawBitWriter::write_bits count must be <= 32"); + for i in (0..count).rev() { + self.write_bit((value >> i) & 1 != 0); + } + } + + fn finish(mut self) -> Vec { + if self.bit_count > 0 { + self.current <<= 8 - self.bit_count; + self.bytes.push(self.current); + } + self.bytes + } +} + +/// Reads raw magnitude bits MSB-first from a byte stream. +/// +/// Past-end-of-stream reads return zero bits rather than an error: a +/// truncated `raw_data` produces zero coefficient magnitudes (no-op upgrade) +/// for the missing positions, matching the FreeRDP reference implementation's +/// tolerance for short truncation in this exact upgrade path. +/// +/// Callers are expected to pass `count <= 32` to [`read_bits`](Self::read_bits); +/// the upgrade-pass call site bounds `count` by `prev_bit_pos - curr_bit_pos` +/// which is at most a few bits in practice. +struct RawBitReader<'a> { + data: &'a [u8], + byte_idx: usize, + bit_idx: u8, +} + +impl<'a> RawBitReader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + byte_idx: 0, + bit_idx: 0, + } + } + + /// Read `count` bits MSB-first into a `u32`. Bits past the end of the + /// underlying stream read as zero. + fn read_bits(&mut self, count: u32) -> u32 { + let mut value = 0u32; + for _ in 0..count { + value = (value << 1) | u32::from(self.read_bit()); + } + value + } + + /// Read one bit. Returns `false` past end-of-stream by design (see + /// type-level docs). + fn read_bit(&mut self) -> bool { + if self.byte_idx >= self.data.len() { + return false; + } + let bit = (self.data[self.byte_idx] >> (7 - self.bit_idx)) & 1 != 0; + self.bit_idx += 1; + if self.bit_idx >= 8 { + self.bit_idx = 0; + self.byte_idx += 1; + } + bit + } +} + +// --------------------------------------------------------------------------- +// Tile state machine +// --------------------------------------------------------------------------- + +/// Per-tile progressive state: coefficients, signs, and quality tracking. +/// +/// Each tile in a progressive surface maintains this state across decode +/// passes. The first pass (TILE_SIMPLE or TILE_FIRST) initializes the +/// coefficients and signs; subsequent upgrade passes (TILE_UPGRADE) +/// accumulate refinement data. +/// +/// Memory per tile: ~37 KB (24 KB coefficients + 12 KB signs + metadata). +pub struct TileState { + /// Accumulated DWT coefficients per component (Y, Cb, Cr). + pub coefficients: [[i16; COEFFICIENTS_PER_COMPONENT]; 3], + /// Tri-state sign tracking per component (DAS array). + pub sign: [[i8; COEFFICIENTS_PER_COMPONENT]; 3], + /// Progressive quantization BitPos from the last applied pass. + pub prog_quant: [ComponentCodecQuant; 3], + /// Base quantization indices (Y, Cb, Cr) into the region's quant table. + pub quant_idx: [u8; 3], + /// Progressive pass counter (0 = no data, 1 = first pass complete, 2+ = upgrade). + pub pass: u16, + /// Whether the tile was encoded as a difference tile. + pub is_difference: bool, + /// Last progressive quality byte (0xFF = full quality). + pub quality: u8, + /// Whether reduce-extrapolate DWT is used for this tile's context. + pub use_reduce_extrapolate: bool, +} + +impl TileState { + /// Create a new tile with zeroed state. + pub fn new() -> Self { + Self { + coefficients: [[0; COEFFICIENTS_PER_COMPONENT]; 3], + sign: [[0; COEFFICIENTS_PER_COMPONENT]; 3], + prog_quant: [ComponentCodecQuant::LOSSLESS; 3], + quant_idx: [0; 3], + pass: 0, + is_difference: false, + quality: 0, + use_reduce_extrapolate: false, + } + } + + /// Decode a first-pass tile (TILE_SIMPLE or TILE_FIRST). + /// + /// Resets this tile's state and decodes three components from RLGR1 data. + /// After this call, `coefficients` hold DWT-domain values ready for + /// inverse DWT + color conversion. + /// + /// # Arguments + /// - `component_data`: RLGR1-encoded data for [Y, Cb, Cr] + /// - `base_quants`: base quantization values for [Y, Cb, Cr] + /// - `prog_quants`: progressive quantization for [Y, Cb, Cr] + /// - `quality`: progressive quality byte + /// - `use_reduce_extrapolate`: DWT mode flag + /// + /// # Errors + /// Returns `RlgrError` if any component's RLGR decode fails. + pub fn decode_first( + &mut self, + component_data: [&[u8]; 3], + base_quants: [&ComponentCodecQuant; 3], + prog_quants: [ComponentCodecQuant; 3], + quant_idx: [u8; 3], + quality: u8, + use_reduce_extrapolate: bool, + ) -> Result<(), RlgrError> { + self.pass = 1; + self.quality = quality; + self.quant_idx = quant_idx; + self.use_reduce_extrapolate = use_reduce_extrapolate; + self.is_difference = false; + self.prog_quant = prog_quants; + + for c in 0..3 { + decode_first_pass( + component_data[c], + base_quants[c], + &prog_quants[c], + use_reduce_extrapolate, + &mut self.coefficients[c], + &mut self.sign[c], + )?; + } + + Ok(()) + } + + /// Decode an upgrade-pass tile (TILE_UPGRADE). + /// + /// Accumulates refinement data into existing coefficients. + /// + /// # Arguments + /// - `srl_data`: SRL-encoded streams for [Y, Cb, Cr] + /// - `raw_data`: raw bit streams for [Y, Cb, Cr] + /// - `prog_quants`: progressive quantization for this upgrade level + /// - `quality`: progressive quality byte for this pass + pub fn decode_upgrade( + &mut self, + srl_data: [&[u8]; 3], + raw_data: [&[u8]; 3], + prog_quants: [ComponentCodecQuant; 3], + quality: u8, + ) { + let prev_prog_quant = self.prog_quant; + + for c in 0..3 { + decode_upgrade_pass( + srl_data[c], + raw_data[c], + &prev_prog_quant[c], + &prog_quants[c], + self.use_reduce_extrapolate, + &mut self.coefficients[c], + &mut self.sign[c], + ); + } + + self.prog_quant = prog_quants; + self.quality = quality; + self.pass = self.pass.saturating_add(1); + } + + /// Reconstruct the tile to spatial domain and write RGBA pixels. + /// + /// Applies inverse DWT to each component, then YCbCr-to-RGB color + /// conversion. The pixel buffer receives 64x64 RGBA pixels (16384 bytes). + /// + /// # Panics + /// + /// Panics if `pixels` has fewer than 64 * 64 * 4 = 16384 bytes. + #[expect(clippy::similar_names, reason = "y/cb/cr are standard YCbCr component names")] + pub fn reconstruct_to_rgba(&self, pixels: &mut [u8]) { + assert!(pixels.len() >= 64 * 64 * 4, "pixel buffer too small"); + + // Copy coefficients to scratch buffers for in-place DWT + let mut y_buf = self.coefficients[0]; + let mut cb_buf = self.coefficients[1]; + let mut cr_buf = self.coefficients[2]; + let mut temp = [0i16; COEFFICIENTS_PER_COMPONENT]; + + // Inverse DWT + if self.use_reduce_extrapolate { + crate::dwt_extrapolate::decode(&mut y_buf, &mut temp); + crate::dwt_extrapolate::decode(&mut cb_buf, &mut temp); + crate::dwt_extrapolate::decode(&mut cr_buf, &mut temp); + } else { + let mut dwt_temp = [0i16; COEFFICIENTS_PER_COMPONENT]; + crate::dwt::decode(&mut y_buf, &mut dwt_temp); + crate::dwt::decode(&mut cb_buf, &mut dwt_temp); + crate::dwt::decode(&mut cr_buf, &mut dwt_temp); + } + + // YCbCr to RGBA conversion + for i in 0..64 * 64 { + let y = i32::from(y_buf[i]) + 128; + let cb = i32::from(cb_buf[i]); + let cr = i32::from(cr_buf[i]); + + // ITU-R BT.601 YCbCr to RGB conversion + let r = y + ((cr * 91881 + 32768) >> 16); + let g = y - ((cb * 22554 + cr * 46802 + 32768) >> 16); + let b = y + ((cb * 116130 + 32768) >> 16); + + let off = i * 4; + pixels[off] = clamp_u8(r); + pixels[off + 1] = clamp_u8(g); + pixels[off + 2] = clamp_u8(b); + pixels[off + 3] = 0xFF; + } + } +} + +impl Default for TileState { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Surface tile grid +// --------------------------------------------------------------------------- + +/// Grid of progressive tiles for a single surface. +/// +/// Manages tile state for a surface identified by its codec context ID. +/// Tiles are lazily allocated on first access to avoid upfront memory +/// cost for surfaces that only partially receive progressive updates. +pub struct SurfaceTiles { + /// Width of the surface in tiles (ceildiv of pixel width by 64). + pub tiles_wide: u16, + /// Height of the surface in tiles. + pub tiles_high: u16, + /// Whether the associated context uses reduce-extrapolate DWT. + pub use_reduce_extrapolate: bool, + /// Tile storage, indexed by `y_idx * tiles_wide + x_idx`. + /// `None` entries haven't received any progressive data yet. + pub tiles: Vec>>, +} + +impl SurfaceTiles { + /// Create a new tile grid for the given surface dimensions. + /// + /// Returns [`ProgressiveDecodeError::SurfaceTooLarge`] if either axis + /// exceeds [`MAX_SURFACE_DIM`]. The check rejects only inputs that exceed + /// the MS-RDPEGFX 2.2.2.14 normative ceiling (32766 px), so every + /// spec-conformant surface is accepted. + pub fn new( + width_pixels: u16, + height_pixels: u16, + use_reduce_extrapolate: bool, + ) -> Result { + if width_pixels > MAX_SURFACE_DIM || height_pixels > MAX_SURFACE_DIM { + return Err(ProgressiveDecodeError::SurfaceTooLarge { + width: width_pixels, + height: height_pixels, + }); + } + + let tiles_wide = width_pixels.div_ceil(64); + let tiles_high = height_pixels.div_ceil(64); + let count = usize::from(tiles_wide) * usize::from(tiles_high); + + Ok(Self { + tiles_wide, + tiles_high, + use_reduce_extrapolate, + tiles: core::iter::repeat_with(|| None).take(count).collect(), + }) + } + + /// Get or create the tile at the given grid position. + /// + /// Returns `None` if the coordinates are out of bounds. + pub fn get_or_create(&mut self, x_idx: u16, y_idx: u16) -> Option<&mut TileState> { + let idx = self.tile_index(x_idx, y_idx)?; + let tile = self.tiles[idx].get_or_insert_with(|| { + let mut t = Box::new(TileState::new()); + t.use_reduce_extrapolate = self.use_reduce_extrapolate; + t + }); + Some(tile) + } + + /// Get the tile at the given grid position, if it exists. + pub fn get(&self, x_idx: u16, y_idx: u16) -> Option<&TileState> { + let idx = self.tile_index(x_idx, y_idx)?; + self.tiles[idx].as_deref() + } + + /// Reset all tiles (e.g., on context reset or surface resize). + pub fn reset(&mut self) { + for tile in &mut self.tiles { + *tile = None; + } + } + + fn tile_index(&self, x_idx: u16, y_idx: u16) -> Option { + if x_idx >= self.tiles_wide || y_idx >= self.tiles_high { + return None; + } + Some(usize::from(y_idx) * usize::from(self.tiles_wide) + usize::from(x_idx)) + } +} + +// --------------------------------------------------------------------------- +// Progressive decoder (EGFX integration) +// --------------------------------------------------------------------------- + +/// Decoded tile pixel data for compositing onto a surface. +pub struct DecodedTile { + /// Tile grid X coordinate (tile column). + pub x_idx: u16, + /// Tile grid Y coordinate (tile row). + pub y_idx: u16, + /// RGBA pixel data (64x64 = 16384 bytes). + pub pixels: Vec, +} + +/// Per-axis cap on surface dimensions, in pixels. +/// +/// Per MS-RDPEGFX 2.2.2.14 RDPGFX_RESET_GRAPHICS_PDU, the normative maximum +/// allowed width and height are 32766 pixels. We round up to 32768 so that +/// the cap accepts every spec-conformant surface while bounding the +/// per-surface tile-grid allocation: at the cap the backing +/// `Vec>>` is 512 * 512 = 262144 slots * 8 bytes per +/// slot = 2 MiB of pointer storage per surface before any tile is populated. +pub const MAX_SURFACE_DIM: u16 = 32768; + +/// Error type for progressive decoding operations. +#[derive(Debug)] +pub enum ProgressiveDecodeError { + /// PDU parsing failed. + Pdu(ironrdp_core::DecodeError), + /// RLGR decode failed within a tile. + Rlgr(RlgrError), + /// The progressive stream is missing a required block. + MissingBlock(&'static str), + /// Tile coordinates are out of bounds for the surface. + TileOutOfBounds { x_idx: u16, y_idx: u16 }, + /// Region references a quant index beyond the table. + InvalidQuantIndex { index: usize, table_len: usize }, + /// Surface dimensions exceed [`MAX_SURFACE_DIM`] per axis. + SurfaceTooLarge { width: u16, height: u16 }, +} + +impl core::fmt::Display for ProgressiveDecodeError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Pdu(e) => write!(f, "progressive PDU decode: {e}"), + Self::Rlgr(e) => write!(f, "progressive RLGR decode: {e}"), + Self::MissingBlock(name) => write!(f, "progressive stream missing {name} block"), + Self::TileOutOfBounds { x_idx, y_idx } => { + write!(f, "tile ({x_idx}, {y_idx}) out of surface bounds") + } + Self::InvalidQuantIndex { index, table_len } => { + write!(f, "quant index {index} exceeds table length {table_len}") + } + Self::SurfaceTooLarge { width, height } => { + write!( + f, + "surface dimensions {width}x{height} exceed per-axis cap of {MAX_SURFACE_DIM} \ + (MS-RDPEGFX 2.2.2.14 normative ceiling: 32766)" + ) + } + } + } +} + +impl From for ProgressiveDecodeError { + fn from(e: ironrdp_core::DecodeError) -> Self { + Self::Pdu(e) + } +} + +impl From for ProgressiveDecodeError { + fn from(e: RlgrError) -> Self { + Self::Rlgr(e) + } +} + +/// Per-context progressive state, identified by codec_context_id. +struct ProgressiveContext { + surface: SurfaceTiles, +} + +/// High-level progressive bitmap decoder for EGFX WireToSurface2 processing. +/// +/// Maintains per-context tile state across frames, keyed by `codec_context_id`. +/// Feed it progressive bitmap data from `WireToSurface2Pdu.bitmap_data` and +/// get back decoded RGBA tiles for compositing. +/// +/// # Usage +/// +/// ```ignore +/// let mut decoder = ProgressiveDecoder::new(); +/// +/// // On receiving WireToSurface2Pdu: +/// let tiles = decoder.decode_bitmap( +/// pdu.codec_context_id, +/// surface_width, surface_height, +/// &pdu.bitmap_data, +/// )?; +/// +/// for tile in &tiles { +/// blit_tile(surface, tile.x_idx, tile.y_idx, &tile.pixels); +/// } +/// ``` +pub struct ProgressiveDecoder { + contexts: BTreeMap, +} + +impl ProgressiveDecoder { + /// Create a new progressive decoder with no context state. + pub fn new() -> Self { + Self { + contexts: BTreeMap::new(), + } + } + + /// Decode a progressive bitmap stream from WireToSurface2Pdu. + /// + /// Parses the progressive block stream, updates per-tile state, and + /// returns RGBA pixel data for each tile that was updated. + /// + /// # Arguments + /// - `codec_context_id`: context ID from the WireToSurface2Pdu + /// - `surface_width`: surface width in pixels (for tile grid sizing) + /// - `surface_height`: surface height in pixels + /// - `bitmap_data`: raw progressive block stream from the PDU + pub fn decode_bitmap( + &mut self, + codec_context_id: u32, + surface_width: u16, + surface_height: u16, + bitmap_data: &[u8], + ) -> Result, ProgressiveDecodeError> { + use ironrdp_pdu::codecs::rfx::progressive::{ProgressiveBlock, decode_progressive_stream}; + + let blocks = decode_progressive_stream(bitmap_data)?; + + // Extract the band-layout flag from the CONTEXT block when present. + // Per MS-RDPEGFX 2.2.4.2 the SYNC + CONTEXT blocks establish a codec + // context once (keyed by `codec_context_id`) and are not required to be + // repeated on subsequent frames that reference the same context. + // Real-world servers (xrdp, GNOME Remote Desktop) omit the CONTEXT + // block on every frame after the first one that established the + // context. The strict requirement rejected each of those frames with + // `MissingBlock("CONTEXT")`, freezing the image on the coarse first + // pass. + // + // Fall back to the value stored when the context was first created. + // Only error when neither source is available, i.e. the very first + // frame for a context arrived without a CONTEXT block. + let use_reduce_extrapolate = match blocks.iter().find_map(|block| match block { + ProgressiveBlock::Context(ctx) => Some(ctx.uses_reduce_extrapolate()), + _ => None, + }) { + Some(v) => v, + None => self + .contexts + .get(&codec_context_id) + .map(|c| c.surface.use_reduce_extrapolate) + .ok_or(ProgressiveDecodeError::MissingBlock("CONTEXT"))?, + }; + + // Get or create the context for this codec_context_id + let context = match self.contexts.entry(codec_context_id) { + Entry::Occupied(e) => e.into_mut(), + Entry::Vacant(e) => { + let surface = SurfaceTiles::new(surface_width, surface_height, use_reduce_extrapolate)?; + e.insert(ProgressiveContext { surface }) + } + }; + + // If surface dimensions changed, reallocate + let expected_wide = surface_width.div_ceil(64); + let expected_high = surface_height.div_ceil(64); + if context.surface.tiles_wide != expected_wide || context.surface.tiles_high != expected_high { + context.surface = SurfaceTiles::new(surface_width, surface_height, use_reduce_extrapolate)?; + } + context.surface.use_reduce_extrapolate = use_reduce_extrapolate; + + let mut decoded_tiles = Vec::new(); + + // Process REGION blocks (the main content) + for block in &blocks { + let region = match block { + ProgressiveBlock::Region(r) => r, + _ => continue, + }; + + let quant_vals = ®ion.quant_vals; + let prog_quant_vals = ®ion.quant_prog_vals; + + for tile_block in ®ion.tiles { + let tiles = decode_tile_block( + &mut context.surface, + tile_block, + quant_vals, + prog_quant_vals, + use_reduce_extrapolate, + )?; + decoded_tiles.extend(tiles); + } + } + + Ok(decoded_tiles) + } + + /// Delete a codec context, freeing its tile state. + /// + /// Called when the server sends RDPGFX_DELETE_ENCODING_CONTEXT. + pub fn delete_context(&mut self, codec_context_id: u32) { + self.contexts.remove(&codec_context_id); + } + + /// Reset all contexts (e.g., on EGFX channel reset). + pub fn reset(&mut self) { + self.contexts.clear(); + } +} + +#[expect( + clippy::similar_names, + reason = "q_y/q_cb/q_cr are standard component quant index names" +)] +fn decode_tile_block( + surface: &mut SurfaceTiles, + tile_block: &ironrdp_pdu::codecs::rfx::progressive::ProgressiveTile<'_>, + quant_vals: &[ComponentCodecQuant], + prog_quant_vals: &[ironrdp_pdu::codecs::rfx::progressive::ProgressiveCodecQuant], + use_reduce_extrapolate: bool, +) -> Result, ProgressiveDecodeError> { + use ironrdp_pdu::codecs::rfx::progressive::ProgressiveTile; + + match tile_block { + ProgressiveTile::Simple(tile) => { + let x_idx = tile.x_idx; + let y_idx = tile.y_idx; + + let tile_state = surface + .get_or_create(x_idx, y_idx) + .ok_or(ProgressiveDecodeError::TileOutOfBounds { x_idx, y_idx })?; + + let q_y = usize::from(tile.quant_idx_y); + let q_cb = usize::from(tile.quant_idx_cb); + let q_cr = usize::from(tile.quant_idx_cr); + + if q_y >= quant_vals.len() || q_cb >= quant_vals.len() || q_cr >= quant_vals.len() { + return Err(ProgressiveDecodeError::InvalidQuantIndex { + index: q_y.max(q_cb).max(q_cr), + table_len: quant_vals.len(), + }); + } + + // TILE_SIMPLE uses lossless progressive quant (no progressive refinement) + let prog = ComponentCodecQuant::LOSSLESS; + + tile_state.decode_first( + [tile.y_data, tile.cb_data, tile.cr_data], + [&quant_vals[q_y], &quant_vals[q_cb], &quant_vals[q_cr]], + [prog, prog, prog], + [tile.quant_idx_y, tile.quant_idx_cb, tile.quant_idx_cr], + 0xFF, // full quality + use_reduce_extrapolate, + )?; + + let mut pixels = vec![0u8; 64 * 64 * 4]; + tile_state.reconstruct_to_rgba(&mut pixels); + + Ok(vec![DecodedTile { x_idx, y_idx, pixels }]) + } + + ProgressiveTile::First(tile) => { + let x_idx = tile.x_idx; + let y_idx = tile.y_idx; + + let tile_state = surface + .get_or_create(x_idx, y_idx) + .ok_or(ProgressiveDecodeError::TileOutOfBounds { x_idx, y_idx })?; + + let q_y = usize::from(tile.quant_idx_y); + let q_cb = usize::from(tile.quant_idx_cb); + let q_cr = usize::from(tile.quant_idx_cr); + + if q_y >= quant_vals.len() || q_cb >= quant_vals.len() || q_cr >= quant_vals.len() { + return Err(ProgressiveDecodeError::InvalidQuantIndex { + index: q_y.max(q_cb).max(q_cr), + table_len: quant_vals.len(), + }); + } + + let pq_idx = usize::from(tile.quality); + if pq_idx >= prog_quant_vals.len() { + return Err(ProgressiveDecodeError::InvalidQuantIndex { + index: pq_idx, + table_len: prog_quant_vals.len(), + }); + } + let pq = &prog_quant_vals[pq_idx]; + + tile_state.decode_first( + [tile.y_data, tile.cb_data, tile.cr_data], + [&quant_vals[q_y], &quant_vals[q_cb], &quant_vals[q_cr]], + [pq.y_quant, pq.cb_quant, pq.cr_quant], + [tile.quant_idx_y, tile.quant_idx_cb, tile.quant_idx_cr], + tile.quality, + use_reduce_extrapolate, + )?; + + let mut pixels = vec![0u8; 64 * 64 * 4]; + tile_state.reconstruct_to_rgba(&mut pixels); + + Ok(vec![DecodedTile { x_idx, y_idx, pixels }]) + } + + ProgressiveTile::Upgrade(tile) => { + let x_idx = tile.x_idx; + let y_idx = tile.y_idx; + + let tile_state = surface + .get_or_create(x_idx, y_idx) + .ok_or(ProgressiveDecodeError::TileOutOfBounds { x_idx, y_idx })?; + + // If this tile hasn't had a first pass, skip the upgrade + if tile_state.pass == 0 { + return Ok(Vec::new()); + } + + let pq_idx = usize::from(tile.quality); + if pq_idx >= prog_quant_vals.len() { + return Err(ProgressiveDecodeError::InvalidQuantIndex { + index: pq_idx, + table_len: prog_quant_vals.len(), + }); + } + let pq = &prog_quant_vals[pq_idx]; + + tile_state.decode_upgrade( + [tile.y_srl_data, tile.cb_srl_data, tile.cr_srl_data], + [tile.y_raw_data, tile.cb_raw_data, tile.cr_raw_data], + [pq.y_quant, pq.cb_quant, pq.cr_quant], + tile.quality, + ); + + let mut pixels = vec![0u8; 64 * 64 * 4]; + tile_state.reconstruct_to_rgba(&mut pixels); + + Ok(vec![DecodedTile { x_idx, y_idx, pixels }]) + } + } +} + +impl Default for ProgressiveDecoder { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[expect(clippy::as_conversions, clippy::cast_possible_truncation, clippy::cast_possible_wrap)] +mod tests { + use super::*; + + #[test] + fn surface_tiles_rejects_over_cap_dimensions() { + // At-cap accepted on both axes + assert!(SurfaceTiles::new(MAX_SURFACE_DIM, MAX_SURFACE_DIM, false).is_ok()); + + // One axis over the cap is rejected with SurfaceTooLarge carrying both inputs + let over_w = MAX_SURFACE_DIM.checked_add(1).unwrap(); + match SurfaceTiles::new(over_w, 1024, false) { + Err(ProgressiveDecodeError::SurfaceTooLarge { width, height }) => { + assert_eq!(width, over_w); + assert_eq!(height, 1024); + } + Err(other) => panic!("expected SurfaceTooLarge, got Err({other})"), + Ok(_) => panic!("expected SurfaceTooLarge, got Ok"), + } + + match SurfaceTiles::new(1024, over_w, false) { + Err(ProgressiveDecodeError::SurfaceTooLarge { width, height }) => { + assert_eq!(width, 1024); + assert_eq!(height, over_w); + } + Err(other) => panic!("expected SurfaceTooLarge, got Err({other})"), + Ok(_) => panic!("expected SurfaceTooLarge, got Ok"), + } + } + + #[test] + fn standard_band_layout_totals_4096() { + let bands = standard_band_layout(); + let total: usize = bands.iter().map(|b| b.count()).sum(); + assert_eq!(total, 4096); + } + + #[test] + fn standard_band_offsets() { + let bands = standard_band_layout(); + assert_eq!(bands[0].offset, 0); + assert_eq!(bands[1].offset, 1024); + assert_eq!(bands[2].offset, 2048); + assert_eq!(bands[3].offset, 3072); + assert_eq!(bands[4].offset, 3328); + assert_eq!(bands[5].offset, 3584); + assert_eq!(bands[6].offset, 3840); + assert_eq!(bands[7].offset, 3904); + assert_eq!(bands[8].offset, 3968); + assert_eq!(bands[9].offset, 4032); + } + + #[test] + fn sign_capture_tri_state() { + let coefficients = [10i16, -5, 0, 100, -1, 0]; + let mut sign = [0i8; 6]; + capture_sign(&coefficients, &mut sign); + assert_eq!(sign, [1, -1, 0, 1, -1, 0]); + } + + #[test] + fn progressive_dequantize_ll3_shift() { + // LL3 is band index 9, at offset 4032 for standard layout + let mut coefficients = vec![0i16; 4096]; + coefficients[4032] = 5; + coefficients[4033] = -3; + + let prog_quant = ComponentCodecQuant { + ll3: 2, + hl3: 0, + lh3: 0, + hh3: 0, + hl2: 0, + lh2: 0, + hh2: 0, + hl1: 0, + lh1: 0, + hh1: 0, + }; + + progressive_dequantize(&mut coefficients, &prog_quant, false); + + // LL3 uses floor shift: 5 << 2 = 20, -3 << 2 = -12 + assert_eq!(coefficients[4032], 20); + assert_eq!(coefficients[4033], -12); + } + + #[test] + fn progressive_dequantize_non_ll3_preserves_sign() { + // HL1 is band index 0, at offset 0 for standard layout + let mut coefficients = vec![0i16; 4096]; + coefficients[0] = 5; + coefficients[1] = -5; + + let prog_quant = ComponentCodecQuant { + ll3: 0, + hl3: 0, + lh3: 0, + hh3: 0, + hl2: 0, + lh2: 0, + hh2: 0, + hl1: 2, + lh1: 0, + hh1: 0, + }; + + progressive_dequantize(&mut coefficients, &prog_quant, false); + + // Non-LL3: shift absolute value, preserve sign + assert_eq!(coefficients[0], 20); // 5 << 2 + assert_eq!(coefficients[1], -20); // -(5 << 2) + } + + #[test] + fn progressive_quantize_round_trip() { + let mut coefficients = vec![0i16; 4096]; + for (i, c) in coefficients.iter_mut().enumerate() { + *c = (i as i16).wrapping_mul(7); + } + let original = coefficients.clone(); + + let prog_quant = ComponentCodecQuant { + ll3: 2, + hl3: 3, + lh3: 3, + hh3: 4, + hl2: 3, + lh2: 3, + hh2: 4, + hl1: 2, + lh1: 2, + hh1: 3, + }; + + progressive_quantize(&mut coefficients, &prog_quant, false); + progressive_dequantize(&mut coefficients, &prog_quant, false); + + // After quantize->dequantize, values lose precision from truncation + // but should be in the right ballpark + for (i, (&a, &b)) in coefficients.iter().zip(original.iter()).enumerate() { + let err = (i32::from(a) - i32::from(b)).unsigned_abs(); + // Max error bounded by 2^(bit_pos) + assert!(err < 32, "index {i}: error {err} too large"); + } + } + + #[test] + fn raw_bit_reader_basic() { + let data = [0b10110000, 0b01010000]; + let mut reader = RawBitReader::new(&data); + assert_eq!(reader.read_bits(4), 0b1011); + assert_eq!(reader.read_bits(4), 0b0000); + assert_eq!(reader.read_bits(4), 0b0101); + } + + #[test] + fn clamp_i16_limits() { + assert_eq!(clamp_i16(40000), i16::MAX); + assert_eq!(clamp_i16(-40000), i16::MIN); + assert_eq!(clamp_i16(100), 100); + assert_eq!(clamp_i16(-100), -100); + } + + #[test] + fn band_zero_count_counts_correctly() { + let mut sign = [0i8; 4096]; + // Band 0 (HL1): offset 0, count 1024 + sign[0] = SIGN_POSITIVE; + sign[1] = SIGN_NEGATIVE; + sign[2] = SIGN_ZERO; + // Rest are SIGN_ZERO by default + + let bands = standard_band_layout(); + assert_eq!(band_zero_count(&sign, &bands[0]), 1022); // 1024 - 2 non-zero + } + + #[test] + fn ll3_offsets_correct() { + assert_eq!(ll3_offset(false), 4032); + assert_eq!(ll3_offset(true), 4015); + } + + #[test] + fn upgrade_pass_zero_das_becomes_nonzero() { + let mut coefficients = vec![0i16; 4096]; + let mut sign = vec![SIGN_ZERO; 4096]; + + // Set up SRL data that produces a non-zero value for the first position + // For band 0 (HL1), with num_bits=2, SRL should produce some values + let prev_prog_quant = ComponentCodecQuant { + ll3: 0, + hl3: 0, + lh3: 0, + hh3: 0, + hl2: 0, + lh2: 0, + hh2: 0, + hl1: 4, + lh1: 0, + hh1: 0, + }; + let curr_prog_quant = ComponentCodecQuant { + ll3: 0, + hl3: 0, + lh3: 0, + hh3: 0, + hl2: 0, + lh2: 0, + hh2: 0, + hl1: 2, + lh1: 0, + hh1: 0, + }; + + // Simple SRL data: a non-zero value (the SRL decoder will interpret + // bits as magnitude + sign). With num_bits=2, k=0 initially, + // it goes straight to magnitude decode. + let srl_data = vec![0b01000000, 0x00]; // sign=0(+), magnitude bits follow + let raw_data = vec![]; + + decode_upgrade_pass( + &srl_data, + &raw_data, + &prev_prog_quant, + &curr_prog_quant, + false, + &mut coefficients, + &mut sign, + ); + + // After decode, at least some positions should have been updated + // (exact values depend on SRL interpretation, but the function shouldn't panic) + } + + #[test] + fn tile_state_default_is_zeroed() { + let tile = TileState::new(); + assert_eq!(tile.pass, 0); + assert_eq!(tile.quality, 0); + assert!(!tile.use_reduce_extrapolate); + assert!(tile.coefficients[0].iter().all(|&v| v == 0)); + assert!(tile.sign[0].iter().all(|&v| v == 0)); + } + + #[test] + fn surface_tiles_dimensions() { + let surface = SurfaceTiles::new(1920, 1080, true).unwrap(); + assert_eq!(surface.tiles_wide, 30); + assert_eq!(surface.tiles_high, 17); + assert!(surface.use_reduce_extrapolate); + } + + #[test] + fn surface_tiles_exact_multiple() { + // 1280 / 64 = 20, 768 / 64 = 12 (exact, no rounding) + let surface = SurfaceTiles::new(1280, 768, false).unwrap(); + assert_eq!(surface.tiles_wide, 20); + assert_eq!(surface.tiles_high, 12); + } + + #[test] + fn surface_tiles_lazy_allocation() { + let mut surface = SurfaceTiles::new(128, 128, false).unwrap(); + // No tiles allocated yet + assert!(surface.get(0, 0).is_none()); + + // Access creates tile + let tile = surface.get_or_create(0, 0).unwrap(); + assert_eq!(tile.pass, 0); + assert!(!tile.use_reduce_extrapolate); + + // Now it exists + assert!(surface.get(0, 0).is_some()); + + // Out of bounds returns None + assert!(surface.get_or_create(2, 2).is_none()); + } + + #[test] + fn surface_tiles_reset() { + let mut surface = SurfaceTiles::new(128, 128, false).unwrap(); + surface.get_or_create(0, 0); + assert!(surface.get(0, 0).is_some()); + + surface.reset(); + assert!(surface.get(0, 0).is_none()); + } + + #[test] + fn decoder_new_is_empty() { + let decoder = ProgressiveDecoder::new(); + assert!(decoder.contexts.is_empty()); + } + + #[test] + fn decoder_delete_nonexistent_context() { + let mut decoder = ProgressiveDecoder::new(); + // Should not panic on non-existent context + decoder.delete_context(42); + } + + #[test] + fn decoder_reset_clears_contexts() { + let mut decoder = ProgressiveDecoder::new(); + + // Decode a minimal valid stream to create a context + use ironrdp_pdu::codecs::rfx::RfxRectangle; + use ironrdp_pdu::codecs::rfx::progressive::{ + ProgressiveBlock, ProgressiveContextPdu, ProgressiveFrameBeginPdu, ProgressiveFrameEndPdu, + ProgressiveRegion, ProgressiveSyncPdu, encode_progressive_stream, + }; + + let region = ProgressiveRegion { + tile_size: 0x40, + rects: vec![RfxRectangle { + x: 0, + y: 0, + width: 64, + height: 64, + }], + quant_vals: vec![], + quant_prog_vals: vec![], + flags: 0, + tiles: vec![], + }; + + let blocks = vec![ + ProgressiveBlock::Sync(ProgressiveSyncPdu), + ProgressiveBlock::Context(ProgressiveContextPdu { + context_id: 0, + tile_size: 0x0040, + flags: 0, + }), + ProgressiveBlock::FrameBegin(ProgressiveFrameBeginPdu { + frame_index: 0, + region_count: 1, + }), + ProgressiveBlock::Region(region), + ProgressiveBlock::FrameEnd(ProgressiveFrameEndPdu), + ]; + + let encoded = encode_progressive_stream(&blocks).unwrap(); + let result = decoder.decode_bitmap(1, 640, 480, &encoded); + assert!(result.is_ok()); + assert_eq!(decoder.contexts.len(), 1); + + decoder.reset(); + assert!(decoder.contexts.is_empty()); + } + + #[test] + fn decoder_error_display() { + let e = ProgressiveDecodeError::MissingBlock("SYNC"); + assert!(e.to_string().contains("SYNC")); + + let e = ProgressiveDecodeError::TileOutOfBounds { x_idx: 5, y_idx: 10 }; + assert!(e.to_string().contains("5")); + assert!(e.to_string().contains("10")); + + let e = ProgressiveDecodeError::InvalidQuantIndex { index: 3, table_len: 2 }; + assert!(e.to_string().contains("3")); + } + + #[test] + fn dequantize_component_ccq_shifts_correctly() { + let mut coefficients = vec![0i16; 4096]; + coefficients[0] = 10; // HL1 band (index 0) + coefficients[4032] = 5; // LL3 band (index 9, standard layout) + + let quant = ComponentCodecQuant { + ll3: 3, + hl3: 0, + lh3: 0, + hh3: 0, + hl2: 0, + lh2: 0, + hh2: 0, + hl1: 4, + lh1: 0, + hh1: 0, + }; + + dequantize_component_ccq(&mut coefficients, &quant, false); + + // HL1: shift left by (4 - 1) = 3 -> 10 << 3 = 80 + assert_eq!(coefficients[0], 80); + // LL3: shift left by (3 - 1) = 2 -> 5 << 2 = 20 + assert_eq!(coefficients[4032], 20); + } + + // --- B10: Server encode pipeline tests --- + + #[test] + fn rgba_to_ycbcr_pure_white() { + let pixels = vec![255u8; 64 * 64 * 4]; + let mut y = vec![0i16; 4096]; + let mut cb = vec![0i16; 4096]; + let mut cr = vec![0i16; 4096]; + + rgba_to_ycbcr(&pixels, &mut y, &mut cb, &mut cr); + + // Pure white: R=G=B=255 + // Y = (19595*255 + 38470*255 + 7471*255 + 32768) >> 16 - 128 + // = (65536*255 + 32768) >> 16 - 128 = 255 - 128 = 127 + // Cb and Cr should be ~0 (achromatic) + assert!((y[0] - 127).abs() <= 1, "Y for white: got {}", y[0]); + assert!(cb[0].abs() <= 1, "Cb for white: got {}", cb[0]); + assert!(cr[0].abs() <= 1, "Cr for white: got {}", cr[0]); + } + + #[test] + fn rgba_to_ycbcr_pure_black() { + let pixels = vec![0u8; 64 * 64 * 4]; + let mut y = vec![0i16; 4096]; + let mut cb = vec![0i16; 4096]; + let mut cr = vec![0i16; 4096]; + + rgba_to_ycbcr(&pixels, &mut y, &mut cb, &mut cr); + + // Pure black: Y = -128, Cb = 0, Cr = 0 + assert_eq!(y[0], -128); + assert_eq!(cb[0], 0); + assert_eq!(cr[0], 0); + } + + #[test] + fn quantize_ccq_right_shifts() { + let mut coefficients = [0i16; 4096]; + coefficients[0] = 80; // HL1 band + coefficients[4032] = 20; // LL3 band + + let quant = ComponentCodecQuant { + ll3: 3, + hl3: 0, + lh3: 0, + hh3: 0, + hl2: 0, + lh2: 0, + hh2: 0, + hl1: 4, + lh1: 0, + hh1: 0, + }; + + quantize_component_ccq(&mut coefficients, &quant, false); + + // HL1: 80 >> (4 - 1) = 80 >> 3 = 10 + assert_eq!(coefficients[0], 10); + // LL3: 20 >> (3 - 1) = 20 >> 2 = 5 + assert_eq!(coefficients[4032], 5); + } + + #[test] + fn quantize_ccq_negative_truncates_toward_zero() { + let mut coefficients = [0i16; 4096]; + coefficients[0] = -80; // HL1 band, negative + + let quant = ComponentCodecQuant { + ll3: 0, + hl3: 0, + lh3: 0, + hh3: 0, + hl2: 0, + lh2: 0, + hh2: 0, + hl1: 4, + lh1: 0, + hh1: 0, + }; + + quantize_component_ccq(&mut coefficients, &quant, false); + + // -80 truncated toward zero: -(80 >> 3) = -10 + assert_eq!(coefficients[0], -10); + } + + #[test] + fn raw_bit_writer_single_byte() { + let mut w = RawBitWriter::new(); + w.write_bits(0xA5, 8); + assert_eq!(w.finish(), vec![0xA5]); + } + + #[test] + fn raw_bit_writer_partial_byte_padded() { + let mut w = RawBitWriter::new(); + w.write_bits(0b101, 3); + // 3 bits: 101, padded to 10100000 = 0xA0 + assert_eq!(w.finish(), vec![0xA0]); + } + + #[test] + fn raw_bit_writer_multi_byte() { + let mut w = RawBitWriter::new(); + w.write_bits(0xFF, 8); + w.write_bits(0b1010, 4); + // First byte: 0xFF, second partial: 1010_0000 = 0xA0 + assert_eq!(w.finish(), vec![0xFF, 0xA0]); + } + + #[test] + fn encode_first_pass_produces_output() { + // Flat tile: all same value, should compress well + let mut coefficients = [100i16; 4096]; + let mut output = vec![0u8; 8192]; + + let base_quant = ComponentCodecQuant::LOSSLESS; + let prog_quant = ComponentCodecQuant::LOSSLESS; + + let result = encode_first_pass(&mut coefficients, &mut output, &base_quant, &prog_quant, false); + + assert!(result.is_ok(), "RLGR encode failed: {:?}", result.err()); + let bytes_written = result.unwrap(); + assert!(bytes_written > 0, "expected non-zero encoded output"); + assert!(bytes_written < 8192, "flat tile should compress"); + } + + #[test] + fn encode_first_pass_reduce_extrapolate() { + let mut coefficients = [50i16; 4096]; + let mut output = vec![0u8; 8192]; + + let base_quant = ComponentCodecQuant::LOSSLESS; + let prog_quant = ComponentCodecQuant::LOSSLESS; + + let result = encode_first_pass( + &mut coefficients, + &mut output, + &base_quant, + &prog_quant, + true, // reduce-extrapolate mode + ); + + assert!(result.is_ok(), "RLGR encode failed: {:?}", result.err()); + assert!(result.unwrap() > 0); + } + + #[test] + fn encode_upgrade_pass_empty_when_no_refinement() { + let coefficients = [0i16; 4096]; + let prev_coefficients = [0i16; 4096]; + let sign = [SIGN_ZERO; 4096]; + + // Same prog_quant for prev and curr -> num_bits = 0, no refinement + let prog_quant = ComponentCodecQuant::LOSSLESS; + + let (srl_data, raw_data) = encode_upgrade_pass( + &coefficients, + &prev_coefficients, + &prog_quant, + &prog_quant, + &sign, + false, + ); + + assert!(srl_data.is_empty(), "no refinement bits, SRL should be empty"); + assert!(raw_data.is_empty(), "no refinement bits, raw should be empty"); + } +} diff --git a/crates/ironrdp-graphics/src/quantization.rs b/crates/ironrdp-graphics/src/quantization.rs index 38c0f65b56..c4ab7d97aa 100644 --- a/crates/ironrdp-graphics/src/quantization.rs +++ b/crates/ironrdp-graphics/src/quantization.rs @@ -11,7 +11,7 @@ pub fn decode(buffer: &mut [i16], quant: &Quant) { let (first_level, buffer) = buffer.split_at_mut(FIRST_LEVEL_SUBBANDS_COUNT * FIRST_LEVEL_SIZE); let (second_level, third_level) = buffer.split_at_mut(SECOND_LEVEL_SUBBANDS_COUNT * SECOND_LEVEL_SIZE); - let decode_chunk = |a: (&mut [i16], u8)| decode_block(a.0, a.1 as i16 - 1); + let decode_chunk = |a: (&mut [i16], u8)| decode_block(a.0, i16::from(a.1) - 1); first_level .chunks_mut(FIRST_LEVEL_SIZE) @@ -49,7 +49,7 @@ pub fn encode(buffer: &mut [i16], quant: &Quant) { let (first_level, buffer) = buffer.split_at_mut(FIRST_LEVEL_SUBBANDS_COUNT * FIRST_LEVEL_SIZE); let (second_level, third_level) = buffer.split_at_mut(SECOND_LEVEL_SUBBANDS_COUNT * SECOND_LEVEL_SIZE); - let encode_chunk = |a: (&mut [i16], u8)| encode_block(a.0, a.1 as i16 - 1); + let encode_chunk = |a: (&mut [i16], u8)| encode_block(a.0, i16::from(a.1) - 1); first_level .chunks_mut(FIRST_LEVEL_SIZE) diff --git a/crates/ironrdp-graphics/src/rdp6/bitmap_stream/decoder.rs b/crates/ironrdp-graphics/src/rdp6/bitmap_stream/decoder.rs index 0e26231eec..78b511b7b1 100644 --- a/crates/ironrdp-graphics/src/rdp6/bitmap_stream/decoder.rs +++ b/crates/ironrdp-graphics/src/rdp6/bitmap_stream/decoder.rs @@ -1,8 +1,8 @@ -use ironrdp_core::{decode, DecodeError}; +use ironrdp_core::{DecodeError, decode}; use ironrdp_pdu::bitmap::rdp6::{BitmapStream as BitmapStreamPdu, ColorPlaneDefinition}; use crate::color_conversion::Rgb; -use crate::rdp6::rle::{decompress_8bpp_plane, RleDecodeError}; +use crate::rdp6::rle::{RleDecodeError, decompress_8bpp_plane}; #[derive(Debug)] pub enum BitmapDecodeError { @@ -195,8 +195,9 @@ impl<'a> BitmapStreamDecoderImpl<'a> { } fn write_aycocg_planes_to_rgb24(&self, params: AYCoCgParams, planes: &[u8], dst: &mut Vec) { - #![allow(clippy::similar_names)] // It’s hard to find better names for co, cg, etc. - let sample_shift = params.chroma_subsampling as usize; + #![allow(clippy::similar_names, reason = "it’s hard to find better names for co, cg, etc")] + + let sample_shift = usize::from(params.chroma_subsampling); let (y_offset, co_offset, cg_offset) = ( self.color_plane_offsets[0], @@ -265,12 +266,13 @@ fn ycocg_with_cll_to_rgb(cll: u8, y: u8, co: u8, cg: u8) -> Rgb { // |R| |1 1/2 -1/2| |Y | // |G| = |1 0 1/2| * |Co| // |B| |1 -1/2 -1/2| |Cg| - let chroma_shift = (cll - 1) as usize; + let chroma_shift = cll - 1; - let clip_i16 = |v: i16| v.clamp(0, 255) as u8; + let clip_i16 = + |v: i16| u8::try_from(v.clamp(0, 255)).expect("fits into u8 because the value is clamped to [0..256]"); - let co_signed = (co << chroma_shift) as i8; - let cg_signed = (cg << chroma_shift) as i8; + let co_signed = (co << chroma_shift).cast_signed(); + let cg_signed = (cg << chroma_shift).cast_signed(); let y = i16::from(y); let co = i16::from(co_signed); diff --git a/crates/ironrdp-graphics/src/rdp6/bitmap_stream/encoder.rs b/crates/ironrdp-graphics/src/rdp6/bitmap_stream/encoder.rs index 14d5306f41..3887d14d50 100644 --- a/crates/ironrdp-graphics/src/rdp6/bitmap_stream/encoder.rs +++ b/crates/ironrdp-graphics/src/rdp6/bitmap_stream/encoder.rs @@ -1,7 +1,7 @@ -use ironrdp_core::{not_enough_bytes_err, EncodeError, WriteCursor}; +use ironrdp_core::{EncodeError, WriteCursor, not_enough_bytes_err}; use ironrdp_pdu::bitmap::rdp6::{BitmapStreamHeader, ColorPlaneDefinition}; -use crate::rdp6::rle::{compress_8bpp_plane, RleEncodeError}; +use crate::rdp6::rle::{RleEncodeError, compress_8bpp_plane}; #[derive(Debug)] pub enum BitmapEncodeError { @@ -227,9 +227,7 @@ impl BitmapStreamEncoder { self.encode_channels_stream((r, g, b), dst, rle) } -} -impl BitmapStreamEncoder { pub fn encode_channels_stream_alpha( &mut self, (r, g, b, a): (R, G, B, A), diff --git a/crates/ironrdp-graphics/src/rdp6/rle.rs b/crates/ironrdp-graphics/src/rdp6/rle.rs index e8540f21ea..7e9a66fc51 100644 --- a/crates/ironrdp-graphics/src/rdp6/rle.rs +++ b/crates/ironrdp-graphics/src/rdp6/rle.rs @@ -93,9 +93,9 @@ impl RlePlaneDecoder { let raw_bytes_field = (control_byte >> 4) & 0x0F; let (run_length, raw_bytes_count) = match rle_bytes_field { - 1 => (16 + raw_bytes_field as usize, 0), - 2 => (32 + raw_bytes_field as usize, 0), - rle_control => (rle_control as usize, raw_bytes_field as usize), + 1 => (16 + usize::from(raw_bytes_field), 0), + 2 => (32 + usize::from(raw_bytes_field), 0), + rle_control => (usize::from(rle_control), usize::from(raw_bytes_field)), }; self.decoded_data_len = raw_bytes_count + run_length; @@ -207,7 +207,8 @@ impl RleEncoderScanlineIterator { } fn delta_value(prev: u8, next: u8) -> u8 { - let mut result = (next as i16 - prev as i16) as u8; + let mut result = u8::try_from((i16::from(next) - i16::from(prev)) & 0xFF) + .expect("masking with 0xFF ensures that the value fits into u8"); // bit magic from 3.1.9.2.1 of [MS-RDPEGDI]. if result < 128 { @@ -249,8 +250,8 @@ macro_rules! ensure_size { (dst: $buf:ident, size: $expected:expr) => {{ let available = $buf.len(); let needed = $expected; - if !(available >= needed) { - return None; + if !(needed <= available) { + return Err(RleEncodeError::BufferTooSmall); } }}; } @@ -289,9 +290,7 @@ impl RlePlaneEncoder { } else { match count { 3.. => { - written += self - .encode_segment(&raw, count, dst) - .ok_or(RleEncodeError::BufferTooSmall)?; + written += self.encode_segment(&raw, count, dst)?; raw.clear(); } 2 => raw.extend_from_slice(&[last, last]), @@ -311,14 +310,16 @@ impl RlePlaneEncoder { count = 0; } - written += self - .encode_segment(&raw, count, dst) - .ok_or(RleEncodeError::BufferTooSmall)?; + written += self.encode_segment(&raw, count, dst)?; Ok(written) } - fn encode_segment(&self, mut raw: &[u8], run: usize, dst: &mut WriteCursor<'_>) -> Option { + fn encode_segment(&self, mut raw: &[u8], run: usize, dst: &mut WriteCursor<'_>) -> Result { + if raw.is_empty() { + return Err(RleEncodeError::NotEnoughBytes); + } + let mut extra_bytes = 0; while raw.len() > 15 { @@ -326,7 +327,10 @@ impl RlePlaneEncoder { raw = &raw[15..]; } - let control = ((raw.len() as u8) << 4) + cmp::min(run, 15) as u8; + let raw_len = u8::try_from(raw.len()).expect("max value is guaranteed to be 15 due to the prior while loop"); + let run_capped = u8::try_from(cmp::min(run, 15)).expect("max value is guaranteed to be 15"); + + let control = (raw_len << 4) + run_capped; ensure_size!(dst: dst, size: raw.len() + 1); @@ -334,20 +338,26 @@ impl RlePlaneEncoder { dst.write_slice(raw); if run > 15 { - let last = raw.last().unwrap(); + let last = raw.last().expect("buffer cannot be empty"); extra_bytes += self.encode_long_sequence(run - 15, *last, dst)?; } - Some(1 + raw.len() + extra_bytes) + Ok(1 + raw.len() + extra_bytes) } - fn encode_long_sequence(&self, mut run: usize, last: u8, dst: &mut WriteCursor<'_>) -> Option { + fn encode_long_sequence( + &self, + mut run: usize, + last: u8, + dst: &mut WriteCursor<'_>, + ) -> Result { let mut written = 0; while run >= 16 { ensure_size!(dst: dst, size: 1); - let current = cmp::min(run, MAX_DECODED_SEGMENT_SIZE) as u8; + let current = u8::try_from(cmp::min(run, MAX_DECODED_SEGMENT_SIZE)) + .expect("max value is guaranteed to be MAX_DECODED_SEGMENT_SIZE (47)"); let c_raw_bytes = cmp::min(current / 16, 2); let n_run_length = current - c_raw_bytes * 16; @@ -356,7 +366,7 @@ impl RlePlaneEncoder { dst.write_u8(control); written += 1; - run -= current as usize; + run -= usize::from(current); } if run > 0 { @@ -370,7 +380,7 @@ impl RlePlaneEncoder { } } - Some(written) + Ok(written) } } diff --git a/crates/ironrdp-graphics/src/rectangle_processing.rs b/crates/ironrdp-graphics/src/rectangle_processing.rs index 53d69171ff..eaffb39edf 100644 --- a/crates/ironrdp-graphics/src/rectangle_processing.rs +++ b/crates/ironrdp-graphics/src/rectangle_processing.rs @@ -400,88 +400,86 @@ fn bands_internals_equal(first_band: &[InclusiveRectangle], second_band: &[Inclu #[cfg(test)] mod tests { - use lazy_static::lazy_static; + use std::sync::LazyLock; use super::*; - lazy_static! { - static ref REGION_FOR_RECTANGLES_INTERSECTION: Region = Region { - extents: InclusiveRectangle { + static REGION_FOR_RECTANGLES_INTERSECTION: LazyLock = LazyLock::new(|| Region { + extents: InclusiveRectangle { + left: 1, + top: 1, + right: 11, + bottom: 9, + }, + rectangles: vec![ + InclusiveRectangle { left: 1, top: 1, + right: 5, + bottom: 3, + }, + InclusiveRectangle { + left: 7, + top: 1, + right: 8, + bottom: 3, + }, + InclusiveRectangle { + left: 9, + top: 1, right: 11, + bottom: 3, + }, + InclusiveRectangle { + left: 7, + top: 3, + right: 11, + bottom: 4, + }, + InclusiveRectangle { + left: 3, + top: 4, + right: 6, + bottom: 6, + }, + InclusiveRectangle { + left: 7, + top: 4, + right: 11, + bottom: 6, + }, + InclusiveRectangle { + left: 1, + top: 6, + right: 3, + bottom: 8, + }, + InclusiveRectangle { + left: 4, + top: 6, + right: 5, + bottom: 8, + }, + InclusiveRectangle { + left: 6, + top: 6, + right: 10, + bottom: 8, + }, + InclusiveRectangle { + left: 4, + top: 8, + right: 5, bottom: 9, }, - rectangles: vec![ - InclusiveRectangle { - left: 1, - top: 1, - right: 5, - bottom: 3, - }, - InclusiveRectangle { - left: 7, - top: 1, - right: 8, - bottom: 3, - }, - InclusiveRectangle { - left: 9, - top: 1, - right: 11, - bottom: 3, - }, - InclusiveRectangle { - left: 7, - top: 3, - right: 11, - bottom: 4, - }, - InclusiveRectangle { - left: 3, - top: 4, - right: 6, - bottom: 6, - }, - InclusiveRectangle { - left: 7, - top: 4, - right: 11, - bottom: 6, - }, - InclusiveRectangle { - left: 1, - top: 6, - right: 3, - bottom: 8, - }, - InclusiveRectangle { - left: 4, - top: 6, - right: 5, - bottom: 8, - }, - InclusiveRectangle { - left: 6, - top: 6, - right: 10, - bottom: 8, - }, - InclusiveRectangle { - left: 4, - top: 8, - right: 5, - bottom: 9, - }, - InclusiveRectangle { - left: 6, - top: 8, - right: 10, - bottom: 9, - }, - ], - }; - } + InclusiveRectangle { + left: 6, + top: 8, + right: 10, + bottom: 9, + }, + ], + }); #[test] fn union_rectangle_sets_extents_and_single_rectangle_for_empty_region() { diff --git a/crates/ironrdp-graphics/src/rlgr.rs b/crates/ironrdp-graphics/src/rlgr.rs index e6aa9cc1fb..42a8a0914a 100644 --- a/crates/ironrdp-graphics/src/rlgr.rs +++ b/crates/ironrdp-graphics/src/rlgr.rs @@ -4,6 +4,7 @@ use std::io; use bitvec::field::BitField as _; use bitvec::prelude::*; use ironrdp_pdu::codecs::rfx::EntropyAlgorithm; +use yuv::YuvError; use crate::utils::Bits; @@ -62,17 +63,23 @@ impl<'a> BitStream<'a> { } pub fn encode(mode: EntropyAlgorithm, input: &[i16], tile: &mut [u8]) -> Result { - let mut k: u32 = 1; - let kr: u32 = 1; - let mut kp: u32 = k << LS_GR; - let mut krp: u32 = kr << LS_GR; + #![expect( + clippy::as_conversions, + reason = "u32-to-usize and usize-to-u32 conversions, mostly fine, and hot loop" + )] if input.is_empty() { return Err(RlgrError::EmptyTile); } + let mut k: u32 = 1; + let kr: u32 = 1; + let mut kp: u32 = k << LS_GR; + let mut krp: u32 = kr << LS_GR; let mut bits = BitStream::new(tile); + let mut input = input.iter().peekable(); + while input.peek().is_some() { match CompressionMode::from(k) { CompressionMode::RunLength => { @@ -97,44 +104,51 @@ pub fn encode(mode: EntropyAlgorithm, input: &[i16], tile: &mut [u8]) -> Result< bits.output_bits(k as usize, nz); if let Some(val) = input.next() { - let mag = val.unsigned_abs() as u32; + let mag = u32::from(val.unsigned_abs()); bits.output_bit(1, *val < 0); code_gr(&mut bits, &mut krp, mag - 1); } kp = kp.saturating_sub(DN_GR); k = kp >> LS_GR; } - CompressionMode::GolombRice => match mode { - EntropyAlgorithm::Rlgr1 => { - let two_ms = get_2magsign(*input.next().unwrap()); - code_gr(&mut bits, &mut krp, two_ms); - if two_ms == 0 { - kp = min(kp + UP_GR, KP_MAX); - } else { - kp = kp.saturating_sub(DQ_GR); - } - k = kp >> LS_GR; - } - EntropyAlgorithm::Rlgr3 => { - let two_ms1 = input.next().map(|&n| get_2magsign(n)).unwrap(); - let two_ms2 = input.next().map(|&n| get_2magsign(n)).unwrap_or(1); - let sum2ms = two_ms1 + two_ms2; - code_gr(&mut bits, &mut krp, sum2ms); - - let m = 32 - sum2ms.leading_zeros() as usize; - if m != 0 { - bits.output_bits(m, two_ms1); - } + CompressionMode::GolombRice => { + #[expect(clippy::missing_panics_doc, reason = "unreachable panic (prior check)")] + let input_first = *input + .next() + .expect("value is guaranteed to be `Some` due to the prior check"); - if two_ms1 != 0 && two_ms2 != 0 { - kp = kp.saturating_sub(2 * DQ_GR); - k = kp >> LS_GR; - } else if two_ms1 == 0 && two_ms2 == 0 { - kp = min(kp + 2 * UQ_GR, KP_MAX); + match mode { + EntropyAlgorithm::Rlgr1 => { + let two_ms = get_2magsign(input_first); + code_gr(&mut bits, &mut krp, two_ms); + if two_ms == 0 { + kp = min(kp + UP_GR, KP_MAX); + } else { + kp = kp.saturating_sub(DQ_GR); + } k = kp >> LS_GR; } + EntropyAlgorithm::Rlgr3 => { + let two_ms1 = get_2magsign(input_first); + let two_ms2 = input.next().map(|&n| get_2magsign(n)).unwrap_or(1); + let sum2ms = two_ms1 + two_ms2; + code_gr(&mut bits, &mut krp, sum2ms); + + let m = 32 - sum2ms.leading_zeros() as usize; + if m != 0 { + bits.output_bits(m, two_ms1); + } + + if two_ms1 != 0 && two_ms2 != 0 { + kp = kp.saturating_sub(2 * DQ_GR); + k = kp >> LS_GR; + } else if two_ms1 == 0 && two_ms2 == 0 { + kp = min(kp + 2 * UQ_GR, KP_MAX); + k = kp >> LS_GR; + } + } } - }, + } } } @@ -144,37 +158,53 @@ pub fn encode(mode: EntropyAlgorithm, input: &[i16], tile: &mut [u8]) -> Result< fn get_2magsign(val: i16) -> u32 { let sign = if val < 0 { 1 } else { 0 }; - (val.unsigned_abs() as u32) * 2 - sign + (u32::from(val.unsigned_abs())) * 2 - sign } fn code_gr(bits: &mut BitStream<'_>, krp: &mut u32, val: u32) { + #![expect( + clippy::as_conversions, + reason = "u32-to-usize and usize-to-u32 conversions, mostly fine, and hot loop" + )] + let kr = (*krp >> LS_GR) as usize; - let vk = (val >> kr) as usize; - bits.output_bit(vk, true); + let vk = val >> kr; + let vk_usize = vk as usize; + + bits.output_bit(vk_usize, true); bits.output_bit(1, false); + if kr != 0 { let remainder = val & ((1 << kr) - 1); bits.output_bits(kr, remainder); } + if vk == 0 { *krp = krp.saturating_sub(2); } else if vk > 1 { - *krp = min(*krp + vk as u32, KP_MAX); + *krp = min(*krp + vk, KP_MAX); } } pub fn decode(mode: EntropyAlgorithm, tile: &[u8], mut output: &mut [i16]) -> Result<(), RlgrError> { - let mut k: u32 = 1; - let mut kr: u32 = 1; - let mut kp: u32 = k << LS_GR; - let mut krp: u32 = kr << LS_GR; + #![expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "u32-to-usize and usize-to-u32 conversions, mostly fine, and hot loop" + )] if tile.is_empty() { return Err(RlgrError::EmptyTile); } + let mut k: u32 = 1; + let mut kr: u32 = 1; + let mut kp: u32 = k << LS_GR; + let mut krp: u32 = kr << LS_GR; + let mut bits = Bits::new(BitSlice::from_slice(tile)); + while !bits.is_empty() && !output.is_empty() { match CompressionMode::from(k) { CompressionMode::RunLength => { @@ -193,7 +223,7 @@ pub fn decode(mode: EntropyAlgorithm, tile: &[u8], mut output: &mut [i16]) -> Re kp = kp.saturating_sub(DN_GR); k = kp >> LS_GR; - let magnitude = compute_rl_magnitude(sign_bit, code_remainder); + let magnitude = compute_rl_magnitude(sign_bit, code_remainder)?; let size = min(run as usize, output.len()); fill(&mut output[..size], 0); @@ -210,7 +240,7 @@ pub fn decode(mode: EntropyAlgorithm, tile: &[u8], mut output: &mut [i16]) -> Re match mode { EntropyAlgorithm::Rlgr1 => { - let magnitude = compute_rlgr1_magnitude(code_remainder, &mut k, &mut kp); + let magnitude = compute_rlgr1_magnitude(code_remainder, &mut k, &mut kp)?; write_byte!(output, magnitude); } EntropyAlgorithm::Rlgr3 => { @@ -226,10 +256,10 @@ pub fn decode(mode: EntropyAlgorithm, tile: &[u8], mut output: &mut [i16]) -> Re k = kp >> LS_GR; } - let magnitude = compute_rlgr3_magnitude(val1); + let magnitude = compute_rlgr3_magnitude(val1)?; write_byte!(output, magnitude); - let magnitude = compute_rlgr3_magnitude(val2); + let magnitude = compute_rlgr3_magnitude(val2)?; write_byte!(output, magnitude); } } @@ -237,7 +267,7 @@ pub fn decode(mode: EntropyAlgorithm, tile: &[u8], mut output: &mut [i16]) -> Re } } - // fill remaining buffer with zeros + // Fill remaining buffer with zeros. fill(output, 0); Ok(()) @@ -250,11 +280,7 @@ fn fill(buffer: &mut [i16], value: i16) { } fn load_be_u32(s: &BitSlice) -> u32 { - if s.is_empty() { - 0 - } else { - s.load_be::() - } + if s.is_empty() { 0 } else { s.load_be::() } } // Returns number of truncated bits @@ -269,48 +295,52 @@ fn truncate_leading_value(bits: &mut Bits<'_>, value: bool) -> usize { } fn count_run(number_of_zeros: usize, k: &mut u32, kp: &mut u32) -> u32 { - (0..number_of_zeros) - .map(|_| { - let run = 1 << *k; - *kp = min(*kp + UP_GR, KP_MAX); - *k = *kp >> LS_GR; - - run - }) - .sum() + core::iter::repeat_with(|| { + let run = 1 << *k; + *kp = min(*kp + UP_GR, KP_MAX); + *k = *kp >> LS_GR; + + run + }) + .take(number_of_zeros) + .sum() } -fn compute_rl_magnitude(sign_bit: u8, code_remainder: u32) -> i16 { +fn compute_rl_magnitude(sign_bit: u8, code_remainder: u32) -> Result { + let rl_magnitude = + i16::try_from(code_remainder + 1).map_err(|_| RlgrError::InvalidIntegralConversion("code remainder + 1"))?; + if sign_bit != 0 { - -((code_remainder + 1) as i16) + Ok(-rl_magnitude) } else { - (code_remainder + 1) as i16 + Ok(rl_magnitude) } } -fn compute_rlgr1_magnitude(code_remainder: u32, k: &mut u32, kp: &mut u32) -> i16 { +fn compute_rlgr1_magnitude(code_remainder: u32, k: &mut u32, kp: &mut u32) -> Result { if code_remainder == 0 { *kp = min(*kp + UQ_GR, KP_MAX); *k = *kp >> LS_GR; - 0 + Ok(0) } else { *kp = kp.saturating_sub(DQ_GR); *k = *kp >> LS_GR; - if code_remainder % 2 != 0 { - -(((code_remainder + 1) >> 1) as i16) + if !code_remainder.is_multiple_of(2) { + Ok(-i16::try_from((code_remainder + 1) >> 1) + .map_err(|_| RlgrError::InvalidIntegralConversion("(code remainder + 1) >> 1"))?) } else { - (code_remainder >> 1) as i16 + i16::try_from(code_remainder >> 1).map_err(|_| RlgrError::InvalidIntegralConversion("code remainder >> 1")) } } } -fn compute_rlgr3_magnitude(val: u32) -> i16 { - if val % 2 != 0 { - -(((val + 1) >> 1) as i16) +fn compute_rlgr3_magnitude(val: u32) -> Result { + if !val.is_multiple_of(2) { + Ok(-i16::try_from((val + 1) >> 1).map_err(|_| RlgrError::InvalidIntegralConversion("(val + 1) >> 1"))?) } else { - (val >> 1) as i16 + i16::try_from(val >> 1).map_err(|_| RlgrError::InvalidIntegralConversion("val >> 1")) } } @@ -327,11 +357,17 @@ fn compute_n_index(code_remainder: u32) -> usize { } fn update_parameters_according_to_number_of_ones(number_of_ones: usize, kr: &mut u32, krp: &mut u32) { + #![expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "usize-to-u32 conversions, hot loop" + )] + if number_of_ones == 0 { *krp = (*krp).saturating_sub(2); *kr = *krp >> LS_GR; } else if number_of_ones > 1 { - *krp = min(*krp + number_of_ones as u32, KP_MAX); + *krp = min(*krp + (number_of_ones as u32), KP_MAX); *kr = *krp >> LS_GR; } } @@ -344,25 +380,25 @@ enum CompressionMode { impl From for CompressionMode { fn from(m: u32) -> Self { - if m != 0 { - Self::RunLength - } else { - Self::GolombRice - } + if m != 0 { Self::RunLength } else { Self::GolombRice } } } #[derive(Debug)] pub enum RlgrError { - IoError(io::Error), + Io(io::Error), + Yuv(YuvError), EmptyTile, + InvalidIntegralConversion(&'static str), } impl core::fmt::Display for RlgrError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { - Self::IoError(_error) => write!(f, "IO error"), + Self::Io(_) => write!(f, "IO error"), + Self::Yuv(_) => write!(f, "YUV error"), Self::EmptyTile => write!(f, "the input tile is empty"), + Self::InvalidIntegralConversion(s) => write!(f, "invalid `{s}`: out of range integral type conversion"), } } } @@ -370,14 +406,16 @@ impl core::fmt::Display for RlgrError { impl core::error::Error for RlgrError { fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { match self { - Self::IoError(error) => Some(error), + Self::Io(error) => Some(error), + Self::Yuv(error) => Some(error), Self::EmptyTile => None, + Self::InvalidIntegralConversion(_) => None, } } } impl From for RlgrError { fn from(err: io::Error) -> Self { - Self::IoError(err) + Self::Io(err) } } diff --git a/crates/ironrdp-graphics/src/srl.rs b/crates/ironrdp-graphics/src/srl.rs new file mode 100644 index 0000000000..6f1ecf3ddf --- /dev/null +++ b/crates/ironrdp-graphics/src/srl.rs @@ -0,0 +1,381 @@ +//! SRL (Simplified Run-Length) entropy codec for progressive upgrade passes. +//! +//! Used during progressive TILE_UPGRADE decoding where the tri-state sign +//! array (DAS) indicates zero-valued coefficients. SRL encodes/decodes +//! magnitudes for coefficients that were previously zero. +//! +//! The algorithm is similar to RLGR's zero-run mode with a simpler structure: +//! adaptive K parameter controlling zero-run lengths, followed by unary-coded +//! magnitudes with sign bits. + +/// Decode SRL data for a set of zero-valued (DAS=0) coefficient positions. +/// +/// `data` is the SRL byte stream (terminated by a 0x00 sentinel). +/// `num_values` is the number of coefficients to decode. +/// `num_bits` is the bit width for each magnitude value. +/// +/// Returns a vector of decoded signed coefficient values. Zero entries +/// mean the coefficient remains zero after this upgrade pass. +pub fn decode_srl(data: &[u8], num_values: usize, num_bits: u8) -> Vec { + if num_values == 0 || data.is_empty() { + return vec![0; num_values]; + } + + let mut output = vec![0i16; num_values]; + let mut reader = BitReader::new(data); + let mut kp: u32 = 0; + let mut out_idx = 0; + let mut nz: u32 = 0; // remaining zeros in current run + + while out_idx < num_values { + let k = kp >> 3; + + if nz > 0 { + // Still emitting zeros from a previous run + nz -= 1; + output[out_idx] = 0; + out_idx += 1; + continue; + } + + // Zero-run mode: chunk_size = 1 << k (1 when k=0). + // read_bits(0) returns 0, so k=0 degenerates to single-zero runs. + { + let bit = reader.read_bit(); + if !bit { + nz = 1u32.checked_shl(k).unwrap_or(0); + kp = kp.saturating_add(4).min(80); + nz -= 1; + output[out_idx] = 0; + out_idx += 1; + continue; + } + let zeros = reader.read_bits(k); + if zeros > 0 { + nz = zeros; + nz -= 1; + output[out_idx] = 0; + out_idx += 1; + continue; + } + // Fall through to unary mode (no more zeros) + } + + // Unary mode: decode a non-zero magnitude + kp = kp.saturating_sub(6); + + if num_bits == 0 { + // No bits to decode, just emit +/-1 from sign bit + let sign = reader.read_bit(); + output[out_idx] = if sign { -1 } else { 1 }; + out_idx += 1; + continue; + } + + // Read sign bit + let sign = reader.read_bit(); + + if num_bits == 1 { + output[out_idx] = if sign { -1 } else { 1 }; + out_idx += 1; + continue; + } + + // Decode unary quotient: count 0-bits before the terminating 1-bit. + // magnitude = (quotient << extra_bits) | remainder. + let mut quotient: u32 = 0; + loop { + let bit = reader.read_bit(); + if bit || quotient >= 0x8000 { + break; + } + quotient += 1; + } + + let extra_bits = u32::from(num_bits).saturating_sub(1); + let magnitude = if extra_bits > 0 && extra_bits < 16 { + let remainder = reader.read_bits(extra_bits); + (quotient << extra_bits) | remainder + } else { + quotient + }; + + let value = i16::try_from(magnitude.min(0x7FFF)).unwrap_or(i16::MAX); + output[out_idx] = if sign { -value } else { value }; + out_idx += 1; + } + + output +} + +/// Encode coefficient magnitudes using the SRL algorithm. +/// +/// `values` contains signed coefficient values (non-zero = needs encoding, +/// zero = contributes to zero runs). +/// `num_bits` is the bit width for magnitude encoding. +/// +/// Returns the encoded SRL byte stream (with trailing 0x00 sentinel). +pub fn encode_srl(values: &[i16], num_bits: u8) -> Vec { + if values.is_empty() { + return vec![0x00]; + } + + let mut writer = BitWriter::new(); + let mut kp: u32 = 0; + let mut idx = 0; + + while idx < values.len() { + // Count leading zeros (may be 0) + let mut zero_count: u32 = 0; + while idx + usize::try_from(zero_count).unwrap_or(usize::MAX) < values.len() + && values[idx + usize::try_from(zero_count).unwrap_or(usize::MAX)] == 0 + { + zero_count += 1; + } + + // Encode zero run one chunk at a time, recomputing k after + // each kp update to stay in sync with the decoder. + while zero_count > 0 { + let cur_k = kp >> 3; + let chunk_size = 1u32.checked_shl(cur_k).unwrap_or(u32::MAX); + if zero_count >= chunk_size { + writer.write_bit(false); + kp = kp.saturating_add(4).min(80); + zero_count -= chunk_size; + idx += usize::try_from(chunk_size).unwrap_or(usize::MAX); + } else { + // Remaining zeros < chunk: escape bit + count + writer.write_bit(true); + writer.write_bits(zero_count, cur_k); + idx += usize::try_from(zero_count).unwrap_or(usize::MAX); + zero_count = 0; + continue; + } + } + // No remaining zeros: write escape with zero count + let cur_k = kp >> 3; + writer.write_bit(true); + writer.write_bits(0, cur_k); + + if idx >= values.len() { + break; + } + + // Encode non-zero value + kp = kp.saturating_sub(6); + let value = values[idx]; + let sign = value < 0; + let magnitude = u32::from(value.unsigned_abs()); + + writer.write_bit(sign); + + if num_bits <= 1 { + idx += 1; + continue; + } + + // Unary encode: quotient zeros + terminator + remainder bits. + // magnitude = (quotient << extra_bits) | remainder. + let extra_bits = u32::from(num_bits).saturating_sub(1); + if extra_bits > 0 && extra_bits < 16 { + let quotient = magnitude >> extra_bits; + let remainder = magnitude & ((1u32 << extra_bits) - 1); + + for _ in 0..quotient { + writer.write_bit(false); + } + writer.write_bit(true); + writer.write_bits(remainder, extra_bits); + } + + idx += 1; + } + + // Trailing sentinel + let mut result = writer.finish(); + result.push(0x00); + result +} + +// --------------------------------------------------------------------------- +// Bit-level I/O helpers +// --------------------------------------------------------------------------- + +struct BitReader<'a> { + data: &'a [u8], + byte_idx: usize, + bit_idx: u8, // 0..7, MSB first +} + +impl<'a> BitReader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { + data, + byte_idx: 0, + bit_idx: 0, + } + } + + fn read_bit(&mut self) -> bool { + if self.byte_idx >= self.data.len() { + return false; + } + let bit = (self.data[self.byte_idx] >> (7 - self.bit_idx)) & 1 != 0; + self.bit_idx += 1; + if self.bit_idx >= 8 { + self.bit_idx = 0; + self.byte_idx += 1; + } + bit + } + + fn read_bits(&mut self, count: u32) -> u32 { + let mut value = 0u32; + for _ in 0..count { + value = (value << 1) | u32::from(self.read_bit()); + } + value + } +} + +struct BitWriter { + bytes: Vec, + current: u8, + bit_count: u8, // bits written in current byte (0..7) +} + +impl BitWriter { + fn new() -> Self { + Self { + bytes: Vec::new(), + current: 0, + bit_count: 0, + } + } + + fn write_bit(&mut self, bit: bool) { + self.current = (self.current << 1) | u8::from(bit); + self.bit_count += 1; + if self.bit_count >= 8 { + self.bytes.push(self.current); + self.current = 0; + self.bit_count = 0; + } + } + + fn write_bits(&mut self, value: u32, count: u32) { + for i in (0..count).rev() { + self.write_bit((value >> i) & 1 != 0); + } + } + + fn finish(mut self) -> Vec { + if self.bit_count > 0 { + // Pad remaining bits with zeros (MSB aligned) + self.current <<= 8 - self.bit_count; + self.bytes.push(self.current); + } + self.bytes + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_empty() { + let result = decode_srl(&[], 0, 1); + assert!(result.is_empty()); + } + + #[test] + fn decode_empty_data() { + // With no data (empty slice), all positions default to zero + let result = decode_srl(&[], 5, 1); + assert_eq!(result, vec![0, 0, 0, 0, 0]); + } + + #[test] + fn encode_empty() { + let encoded = encode_srl(&[], 1); + assert_eq!(encoded, vec![0x00]); // just sentinel + } + + #[test] + fn encode_all_zeros() { + let encoded = encode_srl(&[0, 0, 0], 1); + // Sentinel must be present + assert_eq!(*encoded.last().unwrap(), 0x00); + // Round-trip: all zeros must survive + let decoded = decode_srl(&encoded, 3, 1); + assert_eq!(decoded, vec![0, 0, 0]); + } + + #[test] + fn round_trip_single_positive() { + let original = vec![1]; + let encoded = encode_srl(&original, 1); + let decoded = decode_srl(&encoded, 1, 1); + assert_eq!(decoded, original); + } + + #[test] + fn round_trip_single_negative() { + let original = vec![-1]; + let encoded = encode_srl(&original, 1); + let decoded = decode_srl(&encoded, 1, 1); + assert_eq!(decoded, original); + } + + #[test] + fn round_trip_mixed_zeros() { + // Zeros at the start (where k=0) must survive the round-trip + let original = vec![0, 0, 1, -1, 0, 3]; + let encoded = encode_srl(&original, 4); + let decoded = decode_srl(&encoded, original.len(), 4); + assert_eq!(decoded, original); + } + + #[test] + fn round_trip_nonzero_only() { + let original = vec![1, -1, 2, -3, 1]; + let encoded = encode_srl(&original, 4); + let decoded = decode_srl(&encoded, original.len(), 4); + assert_eq!(decoded, original); + } + + #[test] + fn bit_reader_basic() { + let data = [0b10110000]; + let mut reader = BitReader::new(&data); + assert!(reader.read_bit()); // 1 + assert!(!reader.read_bit()); // 0 + assert!(reader.read_bit()); // 1 + assert!(reader.read_bit()); // 1 + } + + #[test] + fn bit_writer_basic() { + let mut writer = BitWriter::new(); + writer.write_bit(true); + writer.write_bit(false); + writer.write_bit(true); + writer.write_bit(true); + writer.write_bit(false); + writer.write_bit(false); + writer.write_bit(false); + writer.write_bit(false); + let result = writer.finish(); + assert_eq!(result, vec![0b10110000]); + } + + #[test] + fn bit_writer_multi_byte() { + let mut writer = BitWriter::new(); + writer.write_bits(0xFF, 8); + writer.write_bits(0x00, 8); + let result = writer.finish(); + assert_eq!(result, vec![0xFF, 0x00]); + } +} diff --git a/crates/ironrdp-graphics/src/zgfx/api.rs b/crates/ironrdp-graphics/src/zgfx/api.rs new file mode 100644 index 0000000000..eb6f44646d --- /dev/null +++ b/crates/ironrdp-graphics/src/zgfx/api.rs @@ -0,0 +1,117 @@ +//! High-level ZGFX compression API for EGFX PDU preparation. + +use super::ZgfxError; +use super::compressor::Compressor; +use super::wrapper::{ZGFX_SEGMENTED_MAXSIZE, wrap_compressed, wrap_uncompressed}; + +/// Controls whether ZGFX compression is applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompressionMode { + /// Send uncompressed (no CPU overhead). + Never, + /// Compress and use the smaller result (bandwidth vs CPU trade-off). + Auto, + /// Always compress (best bandwidth). + Always, +} + +/// Compress and wrap EGFX PDU bytes into ZGFX segment format for DVC transmission. +/// +/// In `Auto` mode, compression is only used when it actually reduces size. +/// The `compressor` maintains history state across calls for back-reference +/// efficiency. +pub fn compress_and_wrap_egfx( + data: &[u8], + compressor: &mut Compressor, + mode: CompressionMode, +) -> Result, ZgfxError> { + match mode { + CompressionMode::Never => Ok(wrap_uncompressed(data)), + CompressionMode::Auto => { + let compressed = compressor.compress(data)?; + + // Only use compressed wrapping if it fits a single segment. + // Incompressible data can expand beyond the limit; fall back + // to uncompressed which handles multipart natively. + if compressed.len() <= ZGFX_SEGMENTED_MAXSIZE { + let wrapped_compressed = wrap_compressed(&compressed); + let wrapped_uncompressed = wrap_uncompressed(data); + + if wrapped_compressed.len() < wrapped_uncompressed.len() { + Ok(wrapped_compressed) + } else { + Ok(wrapped_uncompressed) + } + } else { + Ok(wrap_uncompressed(data)) + } + } + CompressionMode::Always => { + let compressed = compressor.compress(data)?; + + if compressed.len() <= ZGFX_SEGMENTED_MAXSIZE { + Ok(wrap_compressed(&compressed)) + } else { + // Compressed output too large for single segment; + // send uncompressed to avoid invalid segmentation + Ok(wrap_uncompressed(data)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mode_never_produces_uncompressed() { + let mut compressor = Compressor::new(); + let data = b"Test data"; + + let wrapped = compress_and_wrap_egfx(data, &mut compressor, CompressionMode::Never).unwrap(); + + assert_eq!(wrapped[0], 0xE0); + assert_eq!(wrapped[1], 0x04); // RDP8, not compressed + } + + #[test] + fn mode_always_produces_compressed() { + let mut compressor = Compressor::new(); + let data = b"Test data"; + + let wrapped = compress_and_wrap_egfx(data, &mut compressor, CompressionMode::Always).unwrap(); + + assert_eq!(wrapped[0], 0xE0); + assert_eq!(wrapped[1], 0x24); // RDP8 + COMPRESSED + } + + #[test] + fn mode_auto_compresses_repetitive_data() { + let mut compressor = Compressor::new(); + let data = b"AAAAAAAAAAAABBBBBBBBBBBBCCCCCCCCCCCC"; + + let wrapped = compress_and_wrap_egfx(data, &mut compressor, CompressionMode::Auto).unwrap(); + + assert_eq!(wrapped[0], 0xE0); + assert_eq!(wrapped[1], 0x24); + } + + #[test] + fn round_trip_all_modes() { + use super::super::Decompressor; + + let data = b"Test data with some repetition: AAAA BBBB CCCC"; + let mut decompressor = Decompressor::new(); + + for mode in [CompressionMode::Never, CompressionMode::Auto, CompressionMode::Always] { + let mut compressor = Compressor::new(); + let wrapped = compress_and_wrap_egfx(data, &mut compressor, mode).unwrap(); + + let mut output = Vec::new(); + decompressor.decompress(&wrapped, &mut output).unwrap(); + + assert_eq!(&output, data, "Round-trip failed for mode {mode:?}"); + } + } +} diff --git a/crates/ironrdp-graphics/src/zgfx/compressor.rs b/crates/ironrdp-graphics/src/zgfx/compressor.rs new file mode 100644 index 0000000000..f9c11585d0 --- /dev/null +++ b/crates/ironrdp-graphics/src/zgfx/compressor.rs @@ -0,0 +1,561 @@ +//! ZGFX (RDP8) LZ77 compression. +//! +//! Implements the compression side of the ZGFX codec defined in +//! [\[MS-RDPEGFX\] 2.2.1.1.1]. Uses a hash table mapping 3-byte prefixes to +//! history positions for O(1) match candidate lookup against the 2.5 MB +//! sliding window. +//! +//! [\[MS-RDPEGFX\] 2.2.1.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/ + +use std::collections::HashMap; + +use bitvec::prelude::*; + +use super::{HISTORY_SIZE, TOKEN_TABLE, ZgfxError}; +const MIN_MATCH_LENGTH: usize = 3; +const MAX_MATCH_LENGTH: usize = 65535; +/// Maximum back-reference distance (last token in MS-RDPEGFX table) +const MAX_MATCH_DISTANCE: usize = 2_097_152; + +/// Cap candidates per lookup to bound worst-case search time +const MAX_CANDIDATES: usize = 16; + +/// Cap stored positions per prefix to bound memory +const MAX_POSITIONS_PER_PREFIX: usize = 32; + +/// Trigger hash table compaction when entry count exceeds this +const MAX_HASH_TABLE_ENTRIES: usize = 50_000; + +/// Compaction evicts down to this low watermark, so it runs at most once per +/// `MAX_HASH_TABLE_ENTRIES - COMPACT_TARGET_ENTRIES` inserted prefixes +const COMPACT_TARGET_ENTRIES: usize = MAX_HASH_TABLE_ENTRIES / 2; + +/// ZGFX compressor maintaining a 2.5 MB history buffer and prefix hash table. +pub struct Compressor { + history: Vec, + /// 3-byte prefix → history positions, for O(1) match candidate lookup + match_table: HashMap<[u8; 3], Vec>, +} + +impl Compressor { + pub fn new() -> Self { + Self { + history: Vec::with_capacity(HISTORY_SIZE), + match_table: HashMap::new(), + } + } + + /// Compress `input` into raw ZGFX segment data (without segment headers). + pub fn compress(&mut self, input: &[u8]) -> Result, ZgfxError> { + let mut bit_writer = BitWriter::new(); + let mut pos = 0; + + while pos < input.len() { + let best_match = self.find_best_match(input, pos); + + if let Some(m) = best_match { + if m.length >= MIN_MATCH_LENGTH { + Self::encode_match(&mut bit_writer, m.distance, m.length)?; + self.add_to_history(&input[pos..pos + m.length]); + pos += m.length; + continue; + } + } + + let byte = input[pos]; + Self::encode_literal(&mut bit_writer, byte)?; + self.add_to_history(&[byte]); + pos += 1; + } + + Ok(bit_writer.finish()) + } + + /// Extend the sliding window, evicting oldest bytes when full. + fn add_to_history(&mut self, bytes: &[u8]) { + if self.history.len() + bytes.len() > HISTORY_SIZE { + let overflow = (self.history.len() + bytes.len()) - HISTORY_SIZE; + + self.history.drain(..overflow); + + // Shift all stored positions to account for evicted bytes + for positions in self.match_table.values_mut() { + positions.retain_mut(|pos| { + if *pos >= overflow { + *pos -= overflow; + true + } else { + false + } + }); + } + self.match_table.retain(|_, positions| !positions.is_empty()); + } + + let base_pos = self.history.len(); + self.history.extend_from_slice(bytes); + + // For large chunks (match replays), sample every 4th position to keep + // the hash table manageable without sacrificing much compression ratio + let step_size = if bytes.len() > 256 { 4 } else { 1 }; + + for i in (0..bytes.len().saturating_sub(MIN_MATCH_LENGTH - 1)).step_by(step_size) { + let pos = base_pos + i; + let prefix = [self.history[pos], self.history[pos + 1], self.history[pos + 2]]; + + let entry = self.match_table.entry(prefix).or_default(); + + if entry.len() < MAX_POSITIONS_PER_PREFIX { + entry.push(pos); + } else { + // Evict oldest position to keep recent data preferred + entry.remove(0); + entry.push(pos); + } + } + + if self.match_table.len() > MAX_HASH_TABLE_ENTRIES { + self.compact_hash_table(); + } + + // Index positions spanning the old/new data boundary so matches + // that straddle the append point can be found + for offset in [2, 1] { + if base_pos >= offset && bytes.len() + offset > 2 { + let pos = base_pos - offset; + if pos + MIN_MATCH_LENGTH <= self.history.len() { + let prefix = [self.history[pos], self.history[pos + 1], self.history[pos + 2]]; + let entry = self.match_table.entry(prefix).or_default(); + if entry.last() != Some(&pos) { + if entry.len() >= MAX_POSITIONS_PER_PREFIX { + entry.remove(0); + } + entry.push(pos); + } + } + } + } + } + + /// Halve stored positions per prefix, then evict whole prefixes down to + /// `COMPACT_TARGET_ENTRIES` when the table is over the cap. + /// + /// INVARIANT: the table holds at most `MAX_HASH_TABLE_ENTRIES` prefixes on + /// return. Incompressible input yields a near-unique prefix per byte, so + /// trimming position lists alone never lowers the prefix count; without + /// evicting whole prefixes the table would stay above the threshold and the + /// caller would re-run compaction on every literal byte at O(table) cost. + /// Evicting to a lower watermark amortizes that cost to O(1) per byte. + fn compact_hash_table(&mut self) { + for positions in self.match_table.values_mut() { + if positions.len() > MAX_POSITIONS_PER_PREFIX / 2 { + let keep_from = positions.len() - (MAX_POSITIONS_PER_PREFIX / 2); + *positions = positions[keep_from..].to_vec(); + } + } + self.match_table.retain(|_, positions| !positions.is_empty()); + + if self.match_table.len() <= COMPACT_TARGET_ENTRIES { + return; + } + + // Keep the most-recently-seen prefixes; older ones point further back + // than a fresh match can reach, and distance is capped at + // MAX_MATCH_DISTANCE regardless. Each history position belongs to a + // single prefix, so these newest positions are distinct and the cutoff + // retains exactly COMPACT_TARGET_ENTRIES entries. + let mut newest: Vec = self + .match_table + .values() + .map(|positions| positions.last().copied().unwrap_or(0)) + .collect(); + let cutoff_index = newest.len() - COMPACT_TARGET_ENTRIES; + let cutoff = *newest.select_nth_unstable(cutoff_index).1; + self.match_table + .retain(|_, positions| positions.last().is_some_and(|&pos| pos >= cutoff)); + } + + /// Search hash table for the longest match at `input[pos..]`. + fn find_best_match(&self, input: &[u8], pos: usize) -> Option { + let remaining = input.len() - pos; + if remaining < MIN_MATCH_LENGTH || self.history.is_empty() { + return None; + } + + let prefix = [input[pos], input[pos + 1], input[pos + 2]]; + let candidates = self.match_table.get(&prefix)?; + + let max_match_len = remaining.min(MAX_MATCH_LENGTH); + let mut best_match: Option = None; + let search_limit = self.history.len().min(MAX_MATCH_DISTANCE); + + // Most recent candidates first — better locality, often longer matches + for &hist_pos in candidates.iter().rev().take(MAX_CANDIDATES) { + let distance = self.history.len() - hist_pos; + + if distance > search_limit { + continue; + } + + // Prefix already matched via hash table; extend from byte 3 onward + let mut match_len = MIN_MATCH_LENGTH; + + while match_len < max_match_len + && hist_pos + match_len < self.history.len() + && self.history[hist_pos + match_len] == input[pos + match_len] + { + match_len += 1; + } + + if best_match.as_ref().is_none_or(|b| match_len > b.length) { + best_match = Some(Match { + distance, + length: match_len, + }); + } + + // Good enough — diminishing returns from longer searches + if match_len >= 32 { + break; + } + } + + best_match + } + + /// Select the ZGFX token whose distance range covers `distance`. + #[expect( + clippy::as_conversions, + reason = "distance_base is u32 from TOKEN_TABLE, always fits usize on 32+ bit targets" + )] + fn find_match_token(distance: usize) -> MatchToken { + for token in TOKEN_TABLE.iter().skip(26) { + if let super::TokenType::Match { + distance_value_size, + distance_base, + } = token.ty + { + let max_distance = distance_base as usize + (1 << distance_value_size) - 1; + if distance <= max_distance { + return MatchToken { + prefix: token.prefix, + distance_value_size, + distance_base: distance_base as usize, + }; + } + } + } + + // Fallback: last token covers the full 2 MB history range + if let super::TokenType::Match { + distance_value_size, + distance_base, + } = TOKEN_TABLE[39].ty + { + MatchToken { + prefix: TOKEN_TABLE[39].prefix, + distance_value_size, + distance_base: distance_base as usize, + } + } else { + unreachable!("TOKEN_TABLE[39] is always a Match variant"); + } + } + + /// Return the index of the literal token for `byte`, if one exists. + fn find_literal_token(byte: u8) -> Option { + for (i, token) in TOKEN_TABLE.iter().enumerate().take(26).skip(1) { + if let super::TokenType::Literal { literal_value } = token.ty { + if literal_value == byte { + return Some(i); + } + } + } + None + } + + fn encode_literal(writer: &mut BitWriter, byte: u8) -> Result<(), ZgfxError> { + if let Some(token_idx) = Self::find_literal_token(byte) { + writer.write_bits_from_slice(TOKEN_TABLE[token_idx].prefix); + } else { + // Null literal: "0" prefix + 8-bit value + writer.write_bit(false); + writer.write_bits(u32::from(byte), 8); + } + Ok(()) + } + + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "distance_value bounded by token table, value fits u32" + )] + fn encode_match(writer: &mut BitWriter, distance: usize, length: usize) -> Result<(), ZgfxError> { + let match_token = Self::find_match_token(distance); + + writer.write_bits_from_slice(match_token.prefix); + + let distance_value = distance - match_token.distance_base; + writer.write_bits(distance_value as u32, match_token.distance_value_size); + + Self::encode_match_length(writer, length)?; + + Ok(()) + } + + /// Encode match length using the variable-length scheme from the spec. + /// + /// Length 3 is a special case (single zero bit). All other lengths use + /// unary-coded token size: `token_size` one-bits, a zero bit, then + /// `token_size + 1` value bits, where `length = 2^(token_size+1) + value`. + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "length bounded by MAX_MATCH_LENGTH (u16), ilog2 result fits u32/usize" + )] + fn encode_match_length(writer: &mut BitWriter, length: usize) -> Result<(), ZgfxError> { + if length == 3 { + writer.write_bit(false); + } else { + let length_token_size = usize::try_from(length.ilog2()).expect("ilog2 of usize fits usize") - 1; + let base = 1 << (length_token_size + 1); + let value = length - base; + + for _ in 0..length_token_size { + writer.write_bit(true); + } + writer.write_bit(false); + + writer.write_bits(value as u32, length_token_size + 1); + } + + Ok(()) + } +} + +impl Default for Compressor { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, Copy)] +struct Match { + distance: usize, + length: usize, +} + +struct MatchToken { + prefix: &'static BitSlice, + distance_value_size: usize, + distance_base: usize, +} + +/// MSB-first bit writer for ZGFX token encoding. +struct BitWriter { + bytes: Vec, + current_byte: u8, + bits_in_current: usize, +} + +impl BitWriter { + fn new() -> Self { + Self { + bytes: Vec::new(), + current_byte: 0, + bits_in_current: 0, + } + } + + fn write_bit(&mut self, bit: bool) { + if bit { + self.current_byte |= 1 << (7 - self.bits_in_current); + } + self.bits_in_current += 1; + + if self.bits_in_current == 8 { + self.bytes.push(self.current_byte); + self.current_byte = 0; + self.bits_in_current = 0; + } + } + + fn write_bits(&mut self, value: u32, num_bits: usize) { + for i in (0..num_bits).rev() { + self.write_bit((value >> i) & 1 == 1); + } + } + + fn write_bits_from_slice(&mut self, bits: &BitSlice) { + for bit in bits { + self.write_bit(*bit); + } + } + + /// Finalize: append partial byte (if any) and the ZGFX-required + /// trailing byte indicating unused bits in the final data byte. + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "unused_bits is 0..=7, always fits u8" + )] + fn finish(mut self) -> Vec { + let unused_bits = if self.bits_in_current == 0 { + 0 + } else { + 8 - self.bits_in_current + }; + + if self.bits_in_current > 0 { + self.bytes.push(self.current_byte); + } + + self.bytes.push(unused_bits as u8); + + self.bytes + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compress_empty() { + let mut compressor = Compressor::new(); + let compressed = compressor.compress(&[]).unwrap(); + + assert_eq!(compressed.len(), 1); + assert_eq!(compressed[0], 0); + } + + #[test] + fn compress_single_byte() { + let mut compressor = Compressor::new(); + let compressed = compressor.compress(&[0x42]).unwrap(); + + // Null literal: "0" + 8 bits + padding = 9 bits = 2 bytes + padding byte + assert!(compressed.len() >= 2); + } + + #[test] + fn compress_round_trip() { + use super::super::Decompressor; + + let mut compressor = Compressor::new(); + let mut decompressor = Decompressor::new(); + + let data = b"Hello, ZGFX compression! This is a test."; + let compressed = compressor.compress(data).unwrap(); + + let mut output = Vec::new(); + decompressor.decompress_segment(&compressed, &mut output).unwrap(); + + assert_eq!(&output, data); + } + + #[test] + fn compress_repetitive_data() { + use super::super::Decompressor; + + let mut compressor = Compressor::new(); + let mut decompressor = Decompressor::new(); + + let data = b"AAAAAAAAAABBBBBBBBBBCCCCCCCCCC"; + let compressed = compressor.compress(data).unwrap(); + + let mut output = Vec::new(); + decompressor.decompress_segment(&compressed, &mut output).unwrap(); + + assert_eq!(&output, data); + } + + #[test] + fn compress_large_patterned_data() { + use super::super::Decompressor; + + let mut compressor = Compressor::new(); + let mut decompressor = Decompressor::new(); + + let mut data = Vec::new(); + for i in 0..1000 { + data.extend_from_slice(b"Pattern"); + data.push(u8::try_from(i % 256).unwrap()); + } + + let compressed = compressor.compress(&data).unwrap(); + + let mut output = Vec::new(); + decompressor.decompress_segment(&compressed, &mut output).unwrap(); + + assert_eq!(output, data); + } + + #[test] + fn compress_high_entropy_round_trips_and_bounds_table() { + use super::super::Decompressor; + + // A near-unique 3-byte prefix per byte is the compactor's worst case: + // trimming per-prefix position lists frees nothing, so without evicting + // whole prefixes the table grows past MAX_HASH_TABLE_ENTRIES and + // compaction re-runs on every literal byte at O(table) cost. A + // deterministic LCG makes the stream exceed the entry cap reproducibly. + let mut state: u32 = 0x1234_5678; + let data: Vec = core::iter::repeat_with(|| { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + u8::try_from(state >> 24).unwrap() + }) + .take(100_000) + .collect(); + + let mut compressor = Compressor::new(); + let compressed = compressor.compress(&data).unwrap(); + + let mut decompressor = Decompressor::new(); + let mut output = Vec::new(); + decompressor.decompress_segment(&compressed, &mut output).unwrap(); + assert_eq!(output, data); + + assert!( + compressor.match_table.len() <= MAX_HASH_TABLE_ENTRIES, + "hash table must stay bounded, got {} entries", + compressor.match_table.len() + ); + } + + #[test] + fn bit_writer_basic() { + let mut writer = BitWriter::new(); + + writer.write_bit(true); + writer.write_bit(false); + writer.write_bit(true); + writer.write_bits(0b101, 3); + + let result = writer.finish(); + assert_eq!(result.len(), 2); + assert_eq!(result[0], 0b10110100); + assert_eq!(result[1], 2); + } + + #[test] + fn encode_literal_with_token() { + let mut writer = BitWriter::new(); + + Compressor::encode_literal(&mut writer, 0x00).unwrap(); + + let result = writer.finish(); + assert!(!result.is_empty()); + } + + #[test] + fn encode_literal_null_prefix() { + let mut writer = BitWriter::new(); + + Compressor::encode_literal(&mut writer, 0x42).unwrap(); + + let result = writer.finish(); + assert_eq!(result.len(), 3); + assert_eq!(result[2], 7); + } +} diff --git a/crates/ironrdp-graphics/src/zgfx/control_messages.rs b/crates/ironrdp-graphics/src/zgfx/control_messages.rs index 5223192976..ce311a7856 100644 --- a/crates/ironrdp-graphics/src/zgfx/control_messages.rs +++ b/crates/ironrdp-graphics/src/zgfx/control_messages.rs @@ -23,12 +23,14 @@ impl<'a> SegmentedDataPdu<'a> { match descriptor { SegmentedDescriptor::Single => Ok(SegmentedDataPdu::Single(BulkEncodedData::from_buffer(buffer)?)), SegmentedDescriptor::Multipart => { - let segment_count = buffer.read_u16::()? as usize; - let uncompressed_size = buffer.read_u32::()? as usize; + let segment_count = usize::from(buffer.read_u16::()?); + let uncompressed_size = usize::try_from(buffer.read_u32::()?) + .map_err(|_| ZgfxError::InvalidIntegralConversion("segments uncompressed size"))?; let mut segments = Vec::with_capacity(segment_count); for _ in 0..segment_count { - let size = buffer.read_u32::()? as usize; + let size = usize::try_from(buffer.read_u32::()?) + .map_err(|_| ZgfxError::InvalidIntegralConversion("segment data size"))?; let (segment_data, new_buffer) = buffer.split_at(size); buffer = new_buffer; @@ -55,7 +57,7 @@ impl<'a> BulkEncodedData<'a> { let compression_type_and_flags = buffer.read_u8()?; let _compression_type = CompressionType::from_u8(compression_type_and_flags.get_bits(..4)) .ok_or(ZgfxError::InvalidCompressionType)?; - let compression_flags = CompressionFlags::from_bits_truncate(compression_type_and_flags.get_bits(4..)); + let compression_flags = CompressionFlags::from_bits_retain(compression_type_and_flags.get_bits(4..)); Ok(Self { compression_flags, @@ -79,12 +81,14 @@ bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CompressionFlags: u8 { const COMPRESSED = 0x2; + + const _ = !0; } } #[cfg(test)] mod test { - use lazy_static::lazy_static; + use std::sync::LazyLock; use super::*; @@ -111,29 +115,30 @@ mod test { 0x02, // the third segment: data ]; - lazy_static! { - static ref SINGLE_SEGMENTED_DATA_PDU: SegmentedDataPdu<'static> = SegmentedDataPdu::Single(BulkEncodedData { + static SINGLE_SEGMENTED_DATA_PDU: LazyLock> = LazyLock::new(|| { + SegmentedDataPdu::Single(BulkEncodedData { compression_flags: CompressionFlags::COMPRESSED, data: &SINGLE_SEGMENTED_DATA_PDU_BUFFER[2..], - }); - static ref MULTIPART_SEGMENTED_DATA_PDU: SegmentedDataPdu<'static> = SegmentedDataPdu::Multipart { + }) + }); + static MULTIPART_SEGMENTED_DATA_PDU: LazyLock> = + LazyLock::new(|| SegmentedDataPdu::Multipart { uncompressed_size: 0x2B, segments: vec![ BulkEncodedData { compression_flags: CompressionFlags::empty(), - data: &MULTIPART_SEGMENTED_DATA_PDU_BUFFER[12..12 + 16] + data: &MULTIPART_SEGMENTED_DATA_PDU_BUFFER[12..12 + 16], }, BulkEncodedData { compression_flags: CompressionFlags::empty(), - data: &MULTIPART_SEGMENTED_DATA_PDU_BUFFER[33..33 + 13] + data: &MULTIPART_SEGMENTED_DATA_PDU_BUFFER[33..33 + 13], }, BulkEncodedData { compression_flags: CompressionFlags::COMPRESSED, - data: &MULTIPART_SEGMENTED_DATA_PDU_BUFFER[51..] + data: &MULTIPART_SEGMENTED_DATA_PDU_BUFFER[51..], }, ], - }; - } + }); #[test] fn from_buffer_correctly_parses_zgfx_single_segmented_data_pdu() { diff --git a/crates/ironrdp-graphics/src/zgfx/mod.rs b/crates/ironrdp-graphics/src/zgfx/mod.rs index 4e6a3e47c3..8de349e7dc 100644 --- a/crates/ironrdp-graphics/src/zgfx/mod.rs +++ b/crates/ironrdp-graphics/src/zgfx/mod.rs @@ -1,21 +1,29 @@ //! ZGFX (RDP8) Bulk Data Compression +mod api; mod circular_buffer; +mod compressor; mod control_messages; +mod wrapper; use std::io::{self, Write as _}; +use std::sync::LazyLock; +pub use api::{CompressionMode, compress_and_wrap_egfx}; use bitvec::bits; use bitvec::field::BitField as _; use bitvec::order::Msb0; use bitvec::slice::BitSlice; use byteorder::WriteBytesExt as _; +pub use compressor::Compressor; +pub use wrapper::{wrap_compressed, wrap_uncompressed}; use self::circular_buffer::FixedCircularBuffer; use self::control_messages::{BulkEncodedData, CompressionFlags, SegmentedDataPdu}; use crate::utils::Bits; -const HISTORY_SIZE: usize = 2_500_000; +/// Sliding window size shared by compressor and decompressor. +pub(crate) const HISTORY_SIZE: usize = 2_500_000; pub struct Decompressor { history: FixedCircularBuffer, @@ -71,10 +79,15 @@ impl Decompressor { } fn decompress_segment(&mut self, encoded_data: &[u8], output: &mut Vec) -> Result { + if encoded_data.is_empty() { + return Ok(0); + } + let mut bits = BitSlice::from_slice(encoded_data); // The value of the last byte indicates the number of unused bits in the final byte - bits = &bits[..8 * (encoded_data.len() - 1) - *encoded_data.last().unwrap() as usize]; + bits = &bits + [..8 * (encoded_data.len() - 1) - usize::from(*encoded_data.last().expect("encoded_data is not empty"))]; let mut bits = Bits::new(bits); let mut bytes_written = 0; @@ -129,14 +142,15 @@ fn handle_match( distance_base: u32, history: &mut FixedCircularBuffer, output: &mut Vec, -) -> io::Result { +) -> Result { // Each token has been assigned a different base distance // and number of additional value bits to be added to compute the full distance. - let distance = (distance_base + bits.split_to(distance_value_size).load_be::()) as usize; + let distance = usize::try_from(distance_base + bits.split_to(distance_value_size).load_be::()) + .map_err(|_| ZgfxError::InvalidIntegralConversion("token's full distance"))?; if distance == 0 { - read_unencoded_bytes(bits, history, output) + read_unencoded_bytes(bits, history, output).map_err(ZgfxError::from) } else { read_encoded_bytes(bits, distance, history, output) } @@ -150,7 +164,7 @@ fn read_unencoded_bytes( // A match distance of zero is a special case, // which indicates that an unencoded run of bytes follows. // The count of bytes is encoded as a 15-bit value - let length = bits.split_to(15).load_be::() as usize; + let length = bits.split_to(15).load_be::(); if bits.remaining_bits_of_last_byte() > 0 { let pad_to_byte_boundary = 8 - bits.remaining_bits_of_last_byte(); @@ -173,7 +187,7 @@ fn read_encoded_bytes( distance: usize, history: &mut FixedCircularBuffer, output: &mut Vec, -) -> io::Result { +) -> Result { // A match length prefix follows the token and indicates // how many additional bits will be needed to get the full length // (the number of bytes to be copied). @@ -186,9 +200,12 @@ fn read_encoded_bytes( 3 } else { - let length = bits.split_to(length_token_size + 1).load_be::() as usize; + let length = bits.split_to(length_token_size + 1).load_be::(); - let base = 2u32.pow(length_token_size as u32 + 1) as usize; + let length_token_size = u32::try_from(length_token_size) + .map_err(|_| ZgfxError::InvalidIntegralConversion("length of the token size"))?; + + let base = 2usize.pow(length_token_size + 1); base + length }; @@ -218,8 +235,8 @@ enum TokenType { }, } -lazy_static::lazy_static! { - static ref TOKEN_TABLE: [Token; 40] = [ +static TOKEN_TABLE: LazyLock<[Token; 40]> = LazyLock::new(|| { + [ Token { prefix: bits![static u8, Msb0; 0], ty: TokenType::NullLiteral, @@ -422,8 +439,8 @@ lazy_static::lazy_static! { distance_base: 17_094_304, }, }, - ]; -} + ] +}); #[derive(Debug)] pub enum ZgfxError { @@ -435,6 +452,7 @@ pub enum ZgfxError { uncompressed_size: usize, }, TokenBitsNotFound, + InvalidIntegralConversion(&'static str), } impl core::fmt::Display for ZgfxError { @@ -451,6 +469,9 @@ impl core::fmt::Display for ZgfxError { "decompressed size of segments ({decompressed_size}) does not equal to uncompressed size ({uncompressed_size})", ), Self::TokenBitsNotFound => write!(f, "token bits not found"), + Self::InvalidIntegralConversion(type_name) => { + write!(f, "invalid `{type_name}`: out of range integral type conversion") + } } } } @@ -463,6 +484,7 @@ impl core::error::Error for ZgfxError { Self::InvalidSegmentedDescriptor => None, Self::InvalidDecompressedSize { .. } => None, Self::TokenBitsNotFound => None, + Self::InvalidIntegralConversion(_) => None, } } } diff --git a/crates/ironrdp-graphics/src/zgfx/wrapper.rs b/crates/ironrdp-graphics/src/zgfx/wrapper.rs new file mode 100644 index 0000000000..3580c0f119 --- /dev/null +++ b/crates/ironrdp-graphics/src/zgfx/wrapper.rs @@ -0,0 +1,355 @@ +//! ZGFX Segment Wrapper +//! +//! Provides utilities to wrap data in ZGFX segment structure for transmission over +//! DVC channels. Supports both uncompressed wrapping (data sent as-is) and compressed +//! wrapping (data already ZGFX-compressed by [`super::Compressor`]). +//! +//! # Specification +//! +//! Per MS-RDPEGFX section 2.2.1.1, ZGFX segments use RDP8 (0x04) compression type. +//! The COMPRESSED flag (0x02) distinguishes raw from compressed payloads. +//! +//! ## Single Segment Format +//! +//! ```text +//! Descriptor (1 byte): 0xE0 (ZGFX_SEGMENTED_SINGLE) +//! Flags (1 byte): 0x04 (RDP8 type, not compressed) +//! Data: Raw data bytes +//! ``` +//! +//! ## Multipart Segment Format (for data > 65535 bytes) +//! +//! ```text +//! Descriptor (1 byte): 0xE1 (ZGFX_SEGMENTED_MULTIPART) +//! SegmentCount (2 bytes LE): Number of segments +//! UncompressedSize (4 bytes LE): Total data size +//! For each segment: +//! Size (4 bytes LE): Segment size including flags byte +//! Flags (1 byte): 0x04 (RDP8 type, not compressed) +//! Data: Segment data bytes +//! ``` + +use byteorder::{LittleEndian, WriteBytesExt as _}; + +/// ZGFX descriptor for single segment +const ZGFX_SEGMENTED_SINGLE: u8 = 0xE0; + +/// ZGFX descriptor for multipart segments +const ZGFX_SEGMENTED_MULTIPART: u8 = 0xE1; + +/// RDP8 compression type (lower 4 bits of flags byte) +const ZGFX_PACKET_COMPR_TYPE_RDP8: u8 = 0x04; + +/// COMPRESSED flag (upper 4 bits of flags byte) +const ZGFX_PACKET_COMPRESSED: u8 = 0x02; + +/// Maximum size for a single ZGFX segment (65535 bytes) +pub(crate) const ZGFX_SEGMENTED_MAXSIZE: usize = 65535; + +/// Wrap data in ZGFX segment structure (uncompressed) +/// +/// This creates a spec-compliant ZGFX packet that clients can process, +/// but doesn't actually compress the data. The COMPRESSED flag (0x02) +/// is NOT set, indicating to the client to use the data directly. +/// +/// # Arguments +/// +/// * `data` - Raw data to wrap (typically EGFX PDU bytes) +/// +/// # Returns +/// +/// ZGFX-wrapped data ready for transmission over DVC channel +/// +/// # Examples +/// +/// ``` +/// use ironrdp_graphics::zgfx::wrap_uncompressed; +/// +/// let egfx_pdu_bytes = vec![0x01, 0x02, 0x03, 0x04]; +/// let wrapped = wrap_uncompressed(&egfx_pdu_bytes); +/// +/// // Wrapped data has 2-byte overhead for small data +/// assert_eq!(wrapped.len(), egfx_pdu_bytes.len() + 2); +/// assert_eq!(wrapped[0], 0xE0); // Single segment descriptor +/// assert_eq!(wrapped[1], 0x04); // RDP8 type, not compressed +/// ``` +pub fn wrap_uncompressed(data: &[u8]) -> Vec { + if data.len() <= ZGFX_SEGMENTED_MAXSIZE { + wrap_single_segment(data, false) + } else { + wrap_multipart_segments(data, false) + } +} + +/// Wrap already-compressed data in a single ZGFX segment +/// +/// The COMPRESSED flag is set, telling the client to decompress using ZGFX. +/// +/// Only single-segment wrapping is supported for compressed data because a ZGFX +/// compressed bitstream cannot be split at arbitrary byte boundaries -- each segment +/// must be an independently decodable stream. If multi-segment compressed output is +/// needed, the compressor must emit pre-segmented output. +/// +/// # Panics +/// +/// Panics if `compressed_data` exceeds [`ZGFX_SEGMENTED_MAXSIZE`] (65535 bytes). +pub fn wrap_compressed(compressed_data: &[u8]) -> Vec { + assert!( + compressed_data.len() <= ZGFX_SEGMENTED_MAXSIZE, + "compressed data ({} bytes) exceeds single-segment limit ({}); \ + the compressor must emit pre-segmented output for larger payloads", + compressed_data.len(), + ZGFX_SEGMENTED_MAXSIZE, + ); + + wrap_single_segment(compressed_data, true) +} + +/// Wrap data in a single ZGFX segment +/// +/// # Arguments +/// +/// * `data` - Data to wrap +/// * `compressed` - Whether the data is already ZGFX-compressed +fn wrap_single_segment(data: &[u8], compressed: bool) -> Vec { + let mut output = Vec::with_capacity(data.len() + 2); + + // Descriptor + output.push(ZGFX_SEGMENTED_SINGLE); + + // Flags: RDP8 type + optional COMPRESSED flag + // Lower 4 bits = compression type, upper 4 bits = flags + let flags = if compressed { + ZGFX_PACKET_COMPR_TYPE_RDP8 | (ZGFX_PACKET_COMPRESSED << 4) + } else { + ZGFX_PACKET_COMPR_TYPE_RDP8 + }; + output.push(flags); + + // Data (raw or compressed) + output.extend_from_slice(data); + + output +} + +/// Wrap data in multiple ZGFX segments +/// +/// # Arguments +/// +/// * `data` - Data to wrap +/// * `compressed` - Whether the data is already ZGFX-compressed +fn wrap_multipart_segments(data: &[u8], compressed: bool) -> Vec { + let segment_count = data.len().div_ceil(ZGFX_SEGMENTED_MAXSIZE); + + // Header: descriptor(1) + count(2) + uncompressed_size(4) + // Per segment: size(4) + flags(1) + data + let mut output = Vec::with_capacity(data.len() + 7 + segment_count * 5); + + output.push(ZGFX_SEGMENTED_MULTIPART); + + output + .write_u16::(u16::try_from(segment_count).expect("segment count exceeds u16")) + .expect("write to Vec cannot fail"); + + output + .write_u32::(u32::try_from(data.len()).expect("data exceeds u32")) + .expect("write to Vec cannot fail"); + + for segment in data.chunks(ZGFX_SEGMENTED_MAXSIZE) { + // Segment size (includes flags byte) - max ZGFX_SEGMENTED_MAXSIZE + 1 + output + .write_u32::(u32::try_from(segment.len() + 1).expect("segment size exceeds u32")) + .expect("write to Vec cannot fail"); + + // Flags: RDP8 type + optional COMPRESSED flag + let flags = if compressed { + ZGFX_PACKET_COMPR_TYPE_RDP8 | (ZGFX_PACKET_COMPRESSED << 4) + } else { + ZGFX_PACKET_COMPR_TYPE_RDP8 + }; + output.push(flags); + + // Segment data + output.extend_from_slice(segment); + } + + output +} + +#[cfg(test)] +#[expect(clippy::as_conversions, reason = "test assertions use as for clarity")] +mod tests { + use super::*; + + #[test] + fn test_wrap_small_data() { + let data = b"Hello, ZGFX!"; + let wrapped = wrap_uncompressed(data); + + // Should be: descriptor(1) + flags(1) + data + assert_eq!(wrapped.len(), data.len() + 2); + assert_eq!(wrapped[0], 0xE0); // Single segment + assert_eq!(wrapped[1], 0x04); // RDP8, not compressed + assert_eq!(&wrapped[2..], data); + } + + #[test] + fn test_wrap_empty_data() { + let data = b""; + let wrapped = wrap_uncompressed(data); + + assert_eq!(wrapped.len(), 2); + assert_eq!(wrapped[0], 0xE0); + assert_eq!(wrapped[1], 0x04); + } + + #[test] + fn test_wrap_max_single_segment() { + let data = vec![0xAB; 65535]; // Exactly at limit + let wrapped = wrap_uncompressed(&data); + + assert_eq!(wrapped[0], 0xE0); // Should still be single segment + assert_eq!(wrapped.len(), 65535 + 2); + } + + #[test] + fn test_wrap_large_data() { + let data = vec![0xCD; 100000]; // 100KB > 65KB limit + let wrapped = wrap_uncompressed(&data); + + assert_eq!(wrapped[0], 0xE1); // Multipart + + // Parse header + let segment_count = u16::from_le_bytes([wrapped[1], wrapped[2]]) as usize; + assert_eq!(segment_count, 2); // 100KB / 65KB = 2 segments + + let uncompressed_size = u32::from_le_bytes([wrapped[3], wrapped[4], wrapped[5], wrapped[6]]) as usize; + assert_eq!(uncompressed_size, 100000); + + // Verify first segment + let seg1_size = u32::from_le_bytes([wrapped[7], wrapped[8], wrapped[9], wrapped[10]]) as usize; + assert_eq!(seg1_size, 65536); // 65535 data + 1 flags + assert_eq!(wrapped[11], 0x04); // Flags + + // Verify second segment starts at correct offset + let seg2_offset = 7 + 4 + seg1_size; + let seg2_size = u32::from_le_bytes([ + wrapped[seg2_offset], + wrapped[seg2_offset + 1], + wrapped[seg2_offset + 2], + wrapped[seg2_offset + 3], + ]) as usize; + assert_eq!(seg2_size, 100000 - 65535 + 1); // Remaining data + 1 flags + assert_eq!(wrapped[seg2_offset + 4], 0x04); // Flags + } + + #[test] + fn test_round_trip_with_decompressor() { + use super::super::Decompressor; + + let data = b"Test data for ZGFX round-trip verification"; + let wrapped = wrap_uncompressed(data); + + // Verify decompressor can handle it + let mut decompressor = Decompressor::new(); + let mut output = Vec::new(); + decompressor.decompress(&wrapped, &mut output).unwrap(); + + assert_eq!(&output, data); + } + + #[test] + fn test_round_trip_large_data() { + use super::super::Decompressor; + + // Test with data that requires multiple segments + let data = vec![0x42; 150000]; + let wrapped = wrap_uncompressed(&data); + + let mut decompressor = Decompressor::new(); + let mut output = Vec::new(); + decompressor.decompress(&wrapped, &mut output).unwrap(); + + assert_eq!(output, data); + } + + #[test] + fn test_wrap_compressed_single_segment() { + let fake_compressed = vec![0xFF; 128]; + let wrapped = wrap_compressed(&fake_compressed); + + assert_eq!(wrapped[0], 0xE0); // Single segment + assert_eq!(wrapped[1], 0x24); // RDP8 (0x04) | COMPRESSED (0x02 << 4) + assert_eq!(&wrapped[2..], &*fake_compressed); + } + + #[test] + #[should_panic(expected = "exceeds single-segment limit")] + fn test_wrap_compressed_rejects_oversized() { + let too_large = vec![0xFF; ZGFX_SEGMENTED_MAXSIZE + 1]; + wrap_compressed(&too_large); + } + + #[test] + fn test_wrap_typical_egfx_pdu() { + // Simulate a typical EGFX CapabilitiesConfirm PDU (44 bytes) + let egfx_caps_confirm = vec![0x13, 0x00, 0x00, 0x00, 0x2C, 0x00, 0x00, 0x00]; // Simplified header + let wrapped = wrap_uncompressed(&egfx_caps_confirm); + + assert_eq!(wrapped[0], 0xE0); // Single segment + assert_eq!(wrapped[1], 0x04); // Not compressed + assert_eq!(wrapped.len(), egfx_caps_confirm.len() + 2); + } + + #[test] + fn test_wrap_typical_h264_frame() { + // Simulate a typical 85KB H.264 frame + let h264_frame = vec![0x00; 85000]; + let wrapped = wrap_uncompressed(&h264_frame); + + assert_eq!(wrapped[0], 0xE1); // Multipart (> 65KB) + + // Should produce 2 segments + let segment_count = u16::from_le_bytes([wrapped[1], wrapped[2]]); + assert_eq!(segment_count, 2); + } + + #[test] + fn test_wrap_compressed_data() { + use crate::zgfx::Compressor; + + let mut compressor = Compressor::new(); + let data = b"Test data with some patterns for compression"; + + let compressed = compressor.compress(data).unwrap(); + let wrapped = wrap_compressed(&compressed); + + // Should have COMPRESSED flag set + assert_eq!(wrapped[0], 0xE0); // Single segment + assert_eq!(wrapped[1], 0x24); // 0x04 (RDP8) | (0x02 << 4) = 0x24 + + use crate::zgfx::Decompressor; + let mut decompressor = Decompressor::new(); + let mut output = Vec::new(); + decompressor.decompress(&wrapped, &mut output).unwrap(); + + assert_eq!(&output, data); + } + + #[test] + fn test_compress_and_wrap_full_pipeline() { + use crate::zgfx::{Compressor, Decompressor}; + + let mut compressor = Compressor::new(); + let data = b"This is test data that will be compressed using ZGFX algorithm and then wrapped"; + + let compressed_data = compressor.compress(data).unwrap(); + let wrapped = wrap_compressed(&compressed_data); + + let mut decompressor = Decompressor::new(); + let mut output = Vec::new(); + decompressor.decompress(&wrapped, &mut output).unwrap(); + + assert_eq!(&output, data); + } +} diff --git a/crates/ironrdp-input/CHANGELOG.md b/crates/ironrdp-input/CHANGELOG.md index 21122b4aa7..001665e2da 100644 --- a/crates/ironrdp-input/CHANGELOG.md +++ b/crates/ironrdp-input/CHANGELOG.md @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-input-v0.6.0...ironrdp-input-v0.7.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-input-v0.5.0...ironrdp-input-v0.6.0)] - 2026-05-27 + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.8 + ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-input-v0.1.3...ironrdp-input-v0.2.0)] - 2025-03-12 ### Build @@ -13,7 +27,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump ironrdp-pdu - ## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-input-v0.1.2...ironrdp-input-v0.1.3)] - 2025-03-12 ### Build @@ -27,7 +40,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-input-v0.1.0...ironrdp-input-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-input/Cargo.toml b/crates/ironrdp-input/Cargo.toml index ca27a4c2ef..e10e352055 100644 --- a/crates/ironrdp-input/Cargo.toml +++ b/crates/ironrdp-input/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-input" -version = "0.3.0" +version = "0.7.0" readme = "README.md" description = "Utilities to manage and build RDP input packets" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -16,7 +17,7 @@ doctest = false test = false [dependencies] -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public bitvec = "1.0" smallvec = "1.15" diff --git a/crates/ironrdp-input/src/lib.rs b/crates/ironrdp-input/src/lib.rs index 628aa98c9f..5d8ad180e5 100644 --- a/crates/ironrdp-input/src/lib.rs +++ b/crates/ironrdp-input/src/lib.rs @@ -3,8 +3,8 @@ use std::collections::BTreeSet; -use bitvec::array::BitArray; use bitvec::BitArr; +use bitvec::array::BitArray; use ironrdp_pdu::input::fast_path::{FastPathInputEvent, KeyboardFlags}; use ironrdp_pdu::input::mouse::PointerFlags; use ironrdp_pdu::input::mouse_x::PointerXFlags; @@ -24,6 +24,10 @@ pub enum MouseButton { } impl MouseButton { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] pub fn as_idx(self) -> usize { self as usize } @@ -78,7 +82,11 @@ impl Scancode { pub const fn from_u16(scancode: u16) -> Self { let extended = scancode & 0xE000 == 0xE000; - #[expect(clippy::cast_possible_truncation)] // truncating on purpose + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "truncating on purpose" + )] let code = scancode as u8; Self { code, extended } @@ -86,6 +94,7 @@ impl Scancode { pub fn as_idx(self) -> usize { if self.extended { + #[expect(clippy::missing_panics_doc, reason = "unreachable panic (integer upcast)")] usize::from(self.code).checked_add(256).expect("never overflow") } else { usize::from(self.code) @@ -343,6 +352,7 @@ impl Database { let mut events = SmallVec::new(); for idx in self.mouse_buttons.iter_ones() { + #[expect(clippy::missing_panics_doc, reason = "unreachable panic (checked integer downcast)")] let button = MouseButton::from_idx(idx).expect("in-range index"); let event = match MouseButtonFlags::from(button) { @@ -362,12 +372,16 @@ impl Database { events.push(event) } + // The keyboard bit array size is 512. for idx in self.keyboard.iter_ones() { let (scancode, extended) = if idx >= 256 { + #[expect(clippy::missing_panics_doc, reason = "unreachable panic (checked integer underflow)")] let extended_code = idx.checked_sub(256).expect("never underflow"); - (u8::try_from(extended_code).unwrap(), true) + #[expect(clippy::missing_panics_doc, reason = "unreachable panic (checked integer downcast)")] + (u8::try_from(extended_code).expect("always in the range"), true) } else { - (u8::try_from(idx).unwrap(), false) + #[expect(clippy::missing_panics_doc, reason = "unreachable panic (checked integer downcast)")] + (u8::try_from(idx).expect("always in the range"), false) }; let mut flags = KeyboardFlags::RELEASE; diff --git a/crates/ironrdp-mstsgu/CHANGELOG.md b/crates/ironrdp-mstsgu/CHANGELOG.md new file mode 100644 index 0000000000..1c6f956786 --- /dev/null +++ b/crates/ironrdp-mstsgu/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [[0.0.1](https://github.com/Devolutions/IronRDP/releases/tag/ironrdp-mstsgu-v0.0.1)] - 2026-07-10 + +Initial release. diff --git a/crates/ironrdp-mstsgu/Cargo.toml b/crates/ironrdp-mstsgu/Cargo.toml index fdd83edb7c..b32451ecfa 100644 --- a/crates/ironrdp-mstsgu/Cargo.toml +++ b/crates/ironrdp-mstsgu/Cargo.toml @@ -3,8 +3,8 @@ name = "ironrdp-mstsgu" version = "0.0.1" readme = "README.md" description = "Terminal Services Gateway Server Protocol" -publish = false # TODO: publish edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -23,19 +23,19 @@ native-tls = ["ironrdp-tls/native-tls", "tokio-tungstenite/native-tls"] [dependencies] base64 = "0.22" -bitflags = "2.9" +bitflags = "2.11" futures-util = "0.3" http-body-util = { version = "0.1" } hyper-util = { version = "0.1", features = ["tokio"] } -hyper = { version = "1.7", features = ["client", "http1"] } -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["std"] } -ironrdp-error = { path = "../ironrdp-error", version = "0.1" } -ironrdp-tls = { path = "../ironrdp-tls", version = "0.1" } +hyper = { version = "1.9", features = ["client", "http1"] } +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["std"] } +ironrdp-error = { path = "../ironrdp-error", version = "0.2" } +ironrdp-tls = { path = "../ironrdp-tls", version = "0.2" } log = "0.4" -tokio-tungstenite = { version = "0.27" } +tokio-tungstenite = { version = "0.29" } tokio-util = { version = "0.7" } -tokio = { version = "1.43", features = ["macros", "rt"] } -uuid = { version = "1.16", features = ["v4"] } +tokio = { version = "1.52", features = ["macros", "rt"] } +uuid = { version = "1", features = ["v4"] } [lints] workspace = true diff --git a/crates/ironrdp-mstsgu/src/lib.rs b/crates/ironrdp-mstsgu/src/lib.rs index 0eb078e59d..522051d0a1 100644 --- a/crates/ironrdp-mstsgu/src/lib.rs +++ b/crates/ironrdp-mstsgu/src/lib.rs @@ -13,8 +13,8 @@ use core::task::Poll; use core::time::Duration; use std::io; -use base64::engine::general_purpose::STANDARD; use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; use futures_util::stream::{SplitSink, SplitStream}; use futures_util::{FutureExt as _, SinkExt as _, StreamExt as _}; use hyper::body::Bytes; @@ -24,10 +24,10 @@ use log::{error, warn}; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::TcpStream; use tokio::sync::oneshot; +use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::tungstenite::handshake::client::generate_key; use tokio_tungstenite::tungstenite::protocol::Role; -use tokio_tungstenite::tungstenite::{http, Message}; -use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::{Message, http}; use tokio_util::sync::PollSender; use self::proto::{ @@ -65,6 +65,7 @@ trait GwErrorExt { } impl GwErrorExt for ironrdp_error::Error { + #[track_caller] fn custom(context: &'static str, e: E) -> Self where E: core::error::Error + Sync + Send + 'static, @@ -144,7 +145,7 @@ impl GwClient { .header(hyper::header::SEC_WEBSOCKET_VERSION, "13") .header(hyper::header::SEC_WEBSOCKET_KEY, generate_key()) .body(http_body_util::Empty::::new()) - .expect("Failed to build request"); + .map_err(|e| custom_err!("failed to build request", e))?; let stream = hyper_util::rt::tokio::TokioIo::new(stream); let (mut sender, mut conn) = hyper::client::conn::http1::handshake(stream) @@ -200,8 +201,7 @@ impl GwClient { let work = tokio::spawn(async move { let iv = Duration::from_secs(15 * 60); - let mut keepalive_interval: tokio::time::Interval = - tokio::time::interval_at(tokio::time::Instant::now() + iv, iv); + let mut keepalive_interval = tokio::time::interval_at(tokio::time::Instant::now() + iv, iv); loop { let mut wsbuf = [0u8; 8192]; @@ -222,7 +222,8 @@ impl GwClient { let mut cur = ReadCursor::new(&msg); let hdr = PktHdr::decode(&mut cur).map_err(|e| custom_err!("Header Decode", e))?; - assert!(cur.len() >= hdr.length as usize - hdr.size()); + let header_length = usize::try_from(hdr.length).map_err(|_| Error::new("PktHdr too big", GwErrorKind::Decode))?; + assert!(cur.len() >= header_length - hdr.size()); match hdr.ty { PktTy::Keepalive => { continue; @@ -288,7 +289,10 @@ impl GwConn { let mut cur = ReadCursor::new(&msg); let hdr = PktHdr::decode(&mut cur).map_err(|_| Error::new("PktHdr", GwErrorKind::Decode))?; - if cur.len() != hdr.length as usize - hdr.size() { + + let header_length = + usize::try_from(hdr.length).map_err(|_| Error::new("PktHdr too big", GwErrorKind::Decode))?; + if cur.len() != header_length - hdr.size() { return Err(Error::new("read_packet", GwErrorKind::PacketEof)); } @@ -316,7 +320,7 @@ impl GwConn { async fn tunnel(&mut self) -> Result<(), Error> { let req = TunnelReqPkt { // Havent seen any server working without this. - caps: HttpCapsTy::MessagingConsentSign as u32, + caps: HttpCapsTy::MessagingConsentSign.as_u32(), fields_present: 0, ..TunnelReqPkt::default() }; @@ -351,7 +355,7 @@ impl GwConn { let resp: TunnelAuthRespPkt = TunnelAuthRespPkt::decode(&mut cur).map_err(|_| Error::new("TunnelAuth", GwErrorKind::Decode))?; - if resp.error_code != 0 { + if resp.error_code() != 0 { return Err(Error::new("TunnelAuth", GwErrorKind::Connect)); } Ok(()) @@ -370,7 +374,7 @@ impl GwConn { let mut cur: ReadCursor<'_> = ReadCursor::new(&bytes); let resp: ChannelResp = ChannelResp::decode(&mut cur).map_err(|_| Error::new("ChannelResp", GwErrorKind::Decode))?; - if resp.error_code != 0 { + if resp.error_code() != 0 { return Err(Error::new("ChannelCreate", GwErrorKind::Connect)); } assert!(cur.eof()); @@ -412,11 +416,7 @@ impl AsyncRead for GwClient { !rx_buf.is_empty() }); - if n > 0 { - Poll::Ready(Ok(())) - } else { - Poll::Pending - } + if n > 0 { Poll::Ready(Ok(())) } else { Poll::Pending } } } diff --git a/crates/ironrdp-mstsgu/src/macros.rs b/crates/ironrdp-mstsgu/src/macros.rs index bb2299a6dc..489cad9e24 100644 --- a/crates/ironrdp-mstsgu/src/macros.rs +++ b/crates/ironrdp-mstsgu/src/macros.rs @@ -1,7 +1,5 @@ /// Creates a [`crate::Error`] with `Custom` kind and a source error attached to it #[macro_export] macro_rules! custom_err { - ( $context:expr, $source:expr $(,)? ) => {{ - <$crate::Error as $crate::GwErrorExt>::custom($context, $source) - }}; + ( $context:expr, $source:expr $(,)? ) => {{ <$crate::Error as $crate::GwErrorExt>::custom($context, $source) }}; } diff --git a/crates/ironrdp-mstsgu/src/proto.rs b/crates/ironrdp-mstsgu/src/proto.rs index 494c7effb1..983ae291ae 100644 --- a/crates/ironrdp-mstsgu/src/proto.rs +++ b/crates/ironrdp-mstsgu/src/proto.rs @@ -1,7 +1,7 @@ use bitflags::bitflags; use ironrdp_core::{ - cast_int, cast_length, ensure_fixed_part_size, ensure_size, unsupported_value_err, Decode, Encode, ReadCursor, - WriteCursor, + Decode, Encode, ReadCursor, WriteCursor, cast_int, cast_length, ensure_fixed_part_size, ensure_size, + unsupported_value_err, }; bitflags! { @@ -37,6 +37,16 @@ pub(crate) enum PktTy { Keepalive = 0x0D, } +impl PktTy { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + impl TryFrom for PktTy { type Error = (); @@ -66,7 +76,7 @@ impl TryFrom for PktTy { #[derive(Default, Debug)] pub(crate) struct PktHdr { pub ty: PktTy, - _reserved: u16, + pub _reserved: u16, pub length: u32, } @@ -78,7 +88,7 @@ impl Encode for PktHdr { fn encode(&self, dst: &mut WriteCursor<'_>) -> ironrdp_core::EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - dst.write_u16(self.ty as u16); + dst.write_u16(self.ty.as_u16()); dst.write_u16(self._reserved); dst.write_u32(self.length); @@ -183,7 +193,7 @@ impl Decode<'_> for HandshakeRespPkt { pub(crate) struct TunnelReqPkt { pub caps: u32, pub fields_present: u16, - pub(crate) _reserved: u16, + pub _reserved: u16, } impl Encode for TunnelReqPkt { @@ -215,6 +225,7 @@ impl Encode for TunnelReqPkt { /// 2.2.5.3.9 HTTP_CAPABILITY_TYPE Enumeration #[repr(u32)] #[expect(dead_code)] +#[derive(Copy, Clone)] pub(crate) enum HttpCapsTy { QuarSOH = 1, IdleTimeout = 2, @@ -224,8 +235,19 @@ pub(crate) enum HttpCapsTy { UdpTransport = 0x20, } +impl HttpCapsTy { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub(crate) fn as_u32(self) -> u32 { + self as u32 + } +} + /// 2.2.5.3.8 HTTP_TUNNEL_RESPONSE_FIELDS_PRESENT_FLAGS #[repr(u16)] +#[derive(Copy, Clone)] enum HttpTunnelResponseFields { TunnelID = 1, Caps = 2, @@ -234,6 +256,16 @@ enum HttpTunnelResponseFields { Consent = 0x10, } +impl HttpTunnelResponseFields { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + /// 2.2.10.20 HTTP_TUNNEL_RESPONSE Structure #[derive(Debug, Default)] pub(crate) struct TunnelRespPkt { @@ -266,26 +298,26 @@ impl Decode<'_> for TunnelRespPkt { ..TunnelRespPkt::default() }; - if pkt.fields_present & (HttpTunnelResponseFields::TunnelID as u16) != 0 { + if pkt.fields_present & (HttpTunnelResponseFields::TunnelID.as_u16()) != 0 { ensure_size!(in: src, size: 4); pkt.tunnel_id = Some(src.read_u32()); } - if pkt.fields_present & (HttpTunnelResponseFields::Caps as u16) != 0 { + if pkt.fields_present & (HttpTunnelResponseFields::Caps.as_u16()) != 0 { ensure_size!(in: src, size: 4); pkt.caps_flags = Some(src.read_u32()); } - if pkt.fields_present & (HttpTunnelResponseFields::Soh as u16) != 0 { + if pkt.fields_present & (HttpTunnelResponseFields::Soh.as_u16()) != 0 { ensure_size!(in: src, size: 2 + 2); pkt.nonce = Some(src.read_u16()); - let len = src.read_u16(); - ensure_size!(in: src, size: len as usize); - pkt.server_cert = src.read_slice(len as usize).to_vec(); + let len = usize::from(src.read_u16()); + ensure_size!(in: src, size: len); + pkt.server_cert = src.read_slice(len).to_vec(); } - if pkt.fields_present & (HttpTunnelResponseFields::Consent as u16) != 0 { + if pkt.fields_present & (HttpTunnelResponseFields::Consent.as_u16()) != 0 { ensure_size!(in: src, size: 2); - let len = src.read_u16(); - ensure_size!(in: src, size: len as usize); - pkt.consent_msg = src.read_slice(len as usize).to_vec(); + let len = usize::from(src.read_u16()); + ensure_size!(in: src, size: len); + pkt.consent_msg = src.read_slice(len).to_vec(); } Ok(pkt) @@ -293,6 +325,7 @@ impl Decode<'_> for TunnelRespPkt { } /// 2.2.10.7 HTTP_EXTENDED_AUTH_PACKET Structure +#[expect(dead_code, reason = "defined for completeness per spec; not yet used")] pub(crate) struct ExtendedAuthPkt { error_code: u32, blob: Vec, @@ -330,12 +363,12 @@ impl Decode<'_> for ExtendedAuthPkt { fn decode(src: &mut ReadCursor<'_>) -> ironrdp_core::DecodeResult { ensure_size!(in: src, size: 4 + 2); let error_code = src.read_u32(); - let len = src.read_u16(); - ensure_size!(in: src, size: len as usize); + let len = usize::from(src.read_u16()); + ensure_size!(in: src, size: len); Ok(ExtendedAuthPkt { error_code, - blob: src.read_slice(len as usize).to_vec(), + blob: src.read_slice(len).to_vec(), }) } } @@ -384,13 +417,17 @@ impl Encode for TunnelAuthPkt { /// 2.2.10.16 HTTP_TUNNEL_AUTH_RESPONSE Structure #[derive(Debug)] pub(crate) struct TunnelAuthRespPkt { - pub error_code: u32, + error_code: u32, _fields_present: u16, _reserved: u16, } impl TunnelAuthRespPkt { const FIXED_PART_SIZE: usize = 4 /* error_code */ + 2 /* fields_present */ + 2 /* _reserved */; + + pub(crate) fn error_code(&self) -> u32 { + self.error_code + } } impl Decode<'_> for TunnelAuthRespPkt { @@ -455,7 +492,7 @@ impl Encode for ChannelPkt { /// 2.2.10.4 HTTP_CHANNEL_RESPONSE #[derive(Default, Debug)] pub(crate) struct ChannelResp { - pub error_code: u32, + error_code: u32, fields_present: u16, _reserved: u16, @@ -467,6 +504,10 @@ pub(crate) struct ChannelResp { impl ChannelResp { const FIXED_PART_SIZE: usize = 4 /* error_code */ + 2 /* fields_present */ + 2 /* _reserved */; + + pub(crate) fn error_code(&self) -> u32 { + self.error_code + } } impl Decode<'_> for ChannelResp { @@ -489,9 +530,9 @@ impl Decode<'_> for ChannelResp { } if resp.fields_present & 4 != 0 { ensure_size!(in: src, size: 2); - let len = src.read_u16(); - ensure_size!(in: src, size: len as usize); - resp.authn_cookie = src.read_slice(len as usize).to_vec(); + let len = usize::from(src.read_u16()); + ensure_size!(in: src, size: len); + resp.authn_cookie = src.read_slice(len).to_vec(); } Ok(resp) } @@ -530,10 +571,10 @@ impl Encode for DataPkt<'_> { impl<'a> Decode<'a> for DataPkt<'a> { fn decode(src: &mut ReadCursor<'a>) -> ironrdp_core::DecodeResult { ensure_size!(in: src, size: 2); - let len = src.read_u16(); - ensure_size!(in: src, size: len as usize); + let len = usize::from(src.read_u16()); + ensure_size!(in: src, size: len); Ok(DataPkt { - data: src.read_slice(len as usize), + data: src.read_slice(len), }) } } diff --git a/crates/ironrdp-nscodec/Cargo.toml b/crates/ironrdp-nscodec/Cargo.toml new file mode 100644 index 0000000000..37df3818ca --- /dev/null +++ b/crates/ironrdp-nscodec/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "ironrdp-nscodec" +version = "0.2.0" +readme = "README.md" +description = "NSCodec ([MS-RDPNSC]) implementation for IronRDP" +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +[lib] +doctest = false +# test = false # FIXME: turn off and keep tests in testsuite crates + +[features] +# Encoder is opt-in. Default-disabled because consumers (notably +# `ironrdp-server`) hide NSCodec behind their own feature gate too, so it +# should never be pulled in incidentally. +default = [] +encoder = ["dep:ironrdp-graphics"] + +[dependencies] +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.9", optional = true } # public when `encoder` is on + +[lints] +workspace = true diff --git a/crates/ironrdp-nscodec/LICENSE-APACHE b/crates/ironrdp-nscodec/LICENSE-APACHE new file mode 120000 index 0000000000..1cd601d0a3 --- /dev/null +++ b/crates/ironrdp-nscodec/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/ironrdp-nscodec/LICENSE-MIT b/crates/ironrdp-nscodec/LICENSE-MIT new file mode 120000 index 0000000000..b2cfbdc7b0 --- /dev/null +++ b/crates/ironrdp-nscodec/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/ironrdp-nscodec/README.md b/crates/ironrdp-nscodec/README.md new file mode 100644 index 0000000000..71c483cf40 --- /dev/null +++ b/crates/ironrdp-nscodec/README.md @@ -0,0 +1,30 @@ +# ironrdp-nscodec + +NSCodec ([MS-RDPNSC]) implementation for IronRDP. + +NSCodec is a legacy bitmap codec used in the RDP "Surface Bits" command path. It +predates RemoteFX but remains the only legacy codec advertised by the macOS +Microsoft Remote Desktop / Windows App client's bitmap codec list, so servers +wanting non-raw bitmap delivery to that client need it. + +## Feature Flags + +- **`encoder`** -- Opt-in; pulls in the server-side encoder + (`ironrdp_nscodec::encoder::encode`) and `ironrdp-graphics` for the + `PixelFormat` input enum. + +With no features (`default-features = false`), the crate compiles to an empty +shell — enable `encoder` to get the actual code. + +## Status + +Encoder side only. Implements the codec defined in MS-RDPNSC §3.1.5: + +1. RGB → YCoCg color-space conversion (lossy on chroma when CLL > 0). +2. Per-plane RLE compression (custom MS-RDPNSC byte-level RLE). +3. 20-byte frame header + concatenated Y, Co, Cg, A planes. + +Chroma subsampling (`ChromaSubsamplingLevel = 1`, 4:2:0) is not yet +implemented; the encoder always emits `ChromaSubsamplingLevel = 0`. + +[MS-RDPNSC]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpnsc/68df0993-2c44-4d57-8aef-cdab1c1c43a8 diff --git a/crates/ironrdp-nscodec/src/encoder.rs b/crates/ironrdp-nscodec/src/encoder.rs new file mode 100644 index 0000000000..64808dd91e --- /dev/null +++ b/crates/ironrdp-nscodec/src/encoder.rs @@ -0,0 +1,311 @@ +//! MS-RDPNSC encoder. +//! +//! Implements the codec defined in MS-RDPNSC §3.1.5: +//! 1. RGB → YCoCg color-space conversion (lossy on chroma when CLL > 0). +//! 2. Optional 4:2:0 chroma subsampling — **not implemented**. The encoder +//! always emits `ChromaSubsamplingLevel = 0` (full-resolution chroma). +//! 3. Per-plane RLE compression (custom MS-RDPNSC byte-level RLE). +//! 4. 20-byte frame header + concatenated Y, Co, Cg, A planes. +//! +//! The encoded byte stream is suitable to drop into the `bitmapData` of a +//! `TS_BITMAP_DATA_EX` carried by a `SurfaceBitsPdu` (MS-RDPBCGR §2.2.9.2.1); +//! that PDU plumbing belongs to the consumer (typically `ironrdp-server`). + +use ironrdp_graphics::image_processing::PixelFormat; + +/// 0xFF in the third byte of a run header signals "long run; read u32 LE next." +const RLE_LONG_ESCAPE: u8 = 0xFF; + +/// Encode an in-memory bitmap as an NSCodec frame. +/// +/// # Parameters +/// +/// - `data` — pixel buffer in `format`, with `stride` bytes per row. +/// - `width`, `height` — dimensions in pixels (both must be non-zero). +/// - `stride` — bytes between the start of consecutive rows. Must be at least +/// `width * format.bytes_per_pixel()`. +/// - `format` — one of the eight 32-bpp `PixelFormat` variants. Note that the +/// input pixel alpha is **ignored**: the encoder always emits a fully opaque +/// (`0xFF`) alpha plane regardless of the source alpha byte (desktop captures +/// are opaque, and a zero/premultiplied source alpha would otherwise blend to +/// black on the client). Callers must not rely on alpha being preserved. +/// - `color_loss_level` — must be 1..=7 per MS-RDPNSC. Higher = smaller output +/// but more chroma loss. The value passed here MUST match what was advertised +/// in the `NsCodec` capability set, or the client will decode against the +/// wrong shift and chroma will look wrong. We `debug_assert!` `>= 1`; at +/// CLL=0 the un-shifted Co/Cg values exceed the `i8` plane range and are +/// clamped (see `rgb_to_ycocg`), so the frame still decodes but with severe +/// chroma clipping — callers should either clamp upstream or arrange for the +/// capability advertisement to never send CLL=0. +/// +/// # Panics +/// +/// Debug-asserts `color_loss_level >= 1` and `color_loss_level <= 7`, and that +/// `stride`/`data` are large enough for `width`×`height`. In release builds the +/// function does not panic on a bad CLL: the frame is still emitted, but a +/// non-conformant CLL decodes with visibly incorrect chroma (CLL=0 → heavy +/// chroma clipping; CLL>7 → chroma shifted past zero precision). +pub fn encode( + data: &[u8], + width: u16, + height: u16, + stride: usize, + format: PixelFormat, + color_loss_level: u8, +) -> Vec { + #![allow(clippy::similar_names)] // y_plane / co_plane / cg_plane / a_plane match the spec naming. + + debug_assert!(color_loss_level >= 1, "MS-RDPNSC CLL must be in 1..=7"); + debug_assert!(color_loss_level <= 7, "MS-RDPNSC CLL must be in 1..=7"); + + let w = usize::from(width); + let h = usize::from(height); + let pixels = w * h; + let cll = i32::from(color_loss_level); + let bpp = usize::from(format.bytes_per_pixel()); + + debug_assert!( + stride >= w * bpp, + "stride ({stride}) must be at least width * bytes_per_pixel ({})", + w * bpp + ); + debug_assert!( + data.len() >= h.saturating_sub(1) * stride + w * bpp, + "data ({} bytes) too small for {width}x{height} at stride {stride}", + data.len() + ); + + let mut y_plane = Vec::with_capacity(pixels); + let mut co_plane = Vec::with_capacity(pixels); + let mut cg_plane = Vec::with_capacity(pixels); + let mut a_plane = Vec::with_capacity(pixels); + + // Surface Bits clients consume the bitmap data in bottom-up row order + // (inherited from the legacy compressed bitmap convention in + // MS-RDPBCGR §2.2.9.1.1.3.1.2.2, which `TS_BITMAP_DATA_EX` also follows). + // Top-down inputs (e.g. macOS ScreenCaptureKit) need to be flipped here, + // otherwise each dirty rect is rendered upside-down inside its bounding + // box. + for row in (0..h).rev() { + let row_off = row * stride; + for col in 0..w { + let off = row_off + col * bpp; + let p = &data[off..off + bpp]; + let (r, g, b, _a) = extract_rgba(format, p); + let (y, co, cg) = rgb_to_ycocg(r, g, b, cll); + y_plane.push(y); + co_plane.push(co); + cg_plane.push(cg); + // Desktop captures are always opaque; the source `A` byte can be + // zero on macOS (premultiplied / unused), and NSCodec clients + // treat the alpha plane as actual blending — alpha=0 makes + // everything transparent and the canvas renders as black. + a_plane.push(0xFF); + } + } + + let y_rle = rle_encode(&y_plane); + let co_rle = rle_encode(&co_plane); + let cg_rle = rle_encode(&cg_plane); + let a_rle = rle_encode(&a_plane); + + let plane_len = |rle: &[u8]| -> u32 { + // RLE expansion is bounded by the plane size (worst case is unbounded + // literals = `pixels` bytes plus a constant), which is at most + // `u16::MAX * u16::MAX` = ~4.3 GB — comfortably u32. A u32::MAX cap is + // defensive for the impossible-in-practice overflow case. + u32::try_from(rle.len()).unwrap_or(u32::MAX) + }; + + let body_len = y_rle.len() + co_rle.len() + cg_rle.len() + a_rle.len(); + let mut out = Vec::with_capacity(20 + body_len); + // 20-byte fixed header per MS-RDPNSC §2.2.1.x. + out.extend_from_slice(&plane_len(&y_rle).to_le_bytes()); + out.extend_from_slice(&plane_len(&co_rle).to_le_bytes()); + out.extend_from_slice(&plane_len(&cg_rle).to_le_bytes()); + out.extend_from_slice(&plane_len(&a_rle).to_le_bytes()); + out.push(color_loss_level); + out.push(0); // ChromaSubsamplingLevel = 0 (no chroma subsampling). + out.push(0); // Reserved (2 bytes, MUST be 0). + out.push(0); + out.extend_from_slice(&y_rle); + out.extend_from_slice(&co_rle); + out.extend_from_slice(&cg_rle); + out.extend_from_slice(&a_rle); + + out +} + +/// Pull (R, G, B, A) out of a 4-byte pixel in the given format. +#[inline] +fn extract_rgba(fmt: PixelFormat, p: &[u8]) -> (u8, u8, u8, u8) { + match fmt { + PixelFormat::ARgb32 | PixelFormat::XRgb32 => (p[1], p[2], p[3], p[0]), + PixelFormat::ABgr32 | PixelFormat::XBgr32 => (p[3], p[2], p[1], p[0]), + PixelFormat::BgrA32 | PixelFormat::BgrX32 => (p[2], p[1], p[0], p[3]), + PixelFormat::RgbA32 | PixelFormat::RgbX32 => (p[0], p[1], p[2], p[3]), + } +} + +/// RGB → (Y, Co, Cg) using the FreeRDP formulation (which Microsoft clients +/// decode against). Y is unsigned 0..=253; Co and Cg are signed values stored +/// in the `u8` bit pattern of their `i8` form. +/// +/// Note on Co/Cg storage range: at the advertised CLL=3 typical of real +/// deployments, Co and Cg fit comfortably in `i8`. At CLL<3 they can overflow +/// — see the encoder doc-comment. +#[inline] +fn rgb_to_ycocg(r: u8, g: u8, b: u8, cll: i32) -> (u8, u8, u8) { + #![allow(clippy::similar_names)] // co / cg / co_raw / cg_raw match the spec. + + let ri = i32::from(r); + let gi = i32::from(g); + let bi = i32::from(b); + // y ∈ [0, 253] for r,g,b ∈ [0, 255] — always fits in u8. + let y_i32 = (ri >> 2) + (gi >> 1) + (bi >> 2); + let y = u8::try_from(y_i32.clamp(0, 255)).expect("clamped to [0, 255]"); + // At CLL ≥ 1 (debug-asserted by caller), co and cg ∈ [-128, 127] and fit + // in i8; storing as u8 preserves the bit pattern. + let co_raw = (ri - bi) >> cll; + let cg_raw = (-(ri >> 1) + gi - (bi >> 1)) >> cll; + let co = i8::try_from(co_raw.clamp(i32::from(i8::MIN), i32::from(i8::MAX))) + .expect("clamped to i8 range") + .cast_unsigned(); + let cg = i8::try_from(cg_raw.clamp(i32::from(i8::MIN), i32::from(i8::MAX))) + .expect("clamped to i8 range") + .cast_unsigned(); + (y, co, cg) +} + +/// MS-RDPNSC RLE. +/// +/// A run is introduced by a value byte appearing twice in succession; the +/// third byte is either `runlength - 2` (0..=253, runs of 2..=255) or `0xFF` +/// (long-run escape) followed by a 32-bit LE runlength. A single occurrence +/// of a value is a plain literal. +/// +/// **The last 4 bytes of each plane are copied raw, *not* RLE-encoded** — +/// this matches the FreeRDP reference encoder, which Microsoft NSCodec +/// clients are written against. The decoder unconditionally reads the last +/// 4 bytes of compressed plane data as raw output, so emitting RLE there +/// makes the entire frame undecodable. This convention is implementation- +/// derived, not in the MS-RDPNSC text. +fn rle_encode(plane: &[u8]) -> Vec { + let n = plane.len(); + if n <= 4 { + // Plane too small for any RLE — emit raw. + return plane.to_vec(); + } + let body_end = n - 4; + let mut out = Vec::with_capacity(n); + let mut i = 0; + while i < body_end { + let v = plane[i]; + let mut run = 1usize; + while i + run < body_end && plane[i + run] == v { + run += 1; + // Run length must fit in u32 for the long-run wire encoding. + // Cap here to avoid overflow on the eventual `to_le_bytes()`. + if u32::try_from(run).is_err() { + break; + } + } + if run == 1 { + out.push(v); + } else if run <= 255 { + out.push(v); + out.push(v); + // `run` is in 2..=255 so `run - 2` fits in u8. + out.push(u8::try_from(run - 2).expect("run <= 255 implies run-2 fits in u8")); + } else { + out.push(v); + out.push(v); + out.push(RLE_LONG_ESCAPE); + out.extend_from_slice(&u32::try_from(run).unwrap_or(u32::MAX).to_le_bytes()); + } + i += run; + } + // Last 4 bytes of the plane are copied raw, by spec/convention. + out.extend_from_slice(&plane[body_end..]); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rle_short_input_is_raw() { + // Inputs of <= 4 bytes can't have a raw 4-byte tail AND a body, so + // the whole thing is emitted as-is. + assert_eq!(rle_encode(&[7]), vec![7]); + assert_eq!(rle_encode(&[7, 8]), vec![7, 8]); + assert_eq!(rle_encode(&[1, 2, 3, 4]), vec![1, 2, 3, 4]); + } + + #[test] + fn rle_no_runs_in_body() { + // 5-byte plane: body is 1 byte (plane[0]); tail is 4 bytes raw. + // plane[0]=1 is a literal; remaining 4 bytes copied raw. + assert_eq!(rle_encode(&[1, 2, 2, 2, 2]), vec![1, 2, 2, 2, 2]); + } + + #[test] + fn rle_short_run_in_body_with_raw_tail() { + // 6 bytes of 7 -> body is plane[0..2] = [7, 7] -> short run of 2. + // Tail: plane[2..6] = [7, 7, 7, 7]. + let plane = vec![7u8; 6]; + assert_eq!(rle_encode(&plane), vec![7, 7, 0, 7, 7, 7, 7]); + } + + #[test] + fn rle_long_run_in_body_with_raw_tail() { + // 1000 bytes of 4 -> body 996 bytes of 4 -> long run, then 4 raw. + let plane = vec![4u8; 1000]; + let mut want = vec![4, 4, RLE_LONG_ESCAPE]; + want.extend_from_slice(&996u32.to_le_bytes()); + want.extend_from_slice(&[4, 4, 4, 4]); + assert_eq!(rle_encode(&plane), want); + } + + #[test] + fn ycocg_white_is_white() { + // White (255,255,255) should give Y near 254 and Co/Cg near 0. + let (y, co, cg) = rgb_to_ycocg(255, 255, 255, 3); + assert_eq!(y, 253); + assert_eq!(co, 0); + assert_eq!(cg, 0); + } + + #[test] + fn ycocg_black_is_zero() { + let (y, co, cg) = rgb_to_ycocg(0, 0, 0, 3); + assert_eq!(y, 0); + assert_eq!(co, 0); + assert_eq!(cg, 0); + } + + #[test] + fn encode_emits_expected_header_size() { + #![allow(clippy::similar_names)] // y_len / co_len / cg_len / a_len mirror the plane naming. + + // 2x2 solid red BgrA32. Each plane will be RLE-encoded; verify the + // 20-byte header layout and that the total length is header + sum + // of plane lengths. + let data = vec![0, 0, 255, 0xFF, 0, 0, 255, 0xFF, 0, 0, 255, 0xFF, 0, 0, 255, 0xFF]; + let out = encode(&data, 2, 2, 8, PixelFormat::BgrA32, 3); + assert!(out.len() >= 20, "header at minimum"); + let read_u32 = |slice: &[u8]| -> usize { + usize::try_from(u32::from_le_bytes(slice.try_into().expect("4 bytes"))) + .expect("usize >= u32 on supported targets") + }; + let y_len = read_u32(&out[0..4]); + let co_len = read_u32(&out[4..8]); + let cg_len = read_u32(&out[8..12]); + let a_len = read_u32(&out[12..16]); + assert_eq!(out[16], 3, "CLL stored in header"); + assert_eq!(out[17], 0, "ChromaSubsamplingLevel = 0"); + assert_eq!(&out[18..20], &[0, 0], "reserved = 0"); + assert_eq!(out.len(), 20 + y_len + co_len + cg_len + a_len); + } +} diff --git a/crates/ironrdp-nscodec/src/lib.rs b/crates/ironrdp-nscodec/src/lib.rs new file mode 100644 index 0000000000..574fc2d446 --- /dev/null +++ b/crates/ironrdp-nscodec/src/lib.rs @@ -0,0 +1,5 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] +#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] + +#[cfg(feature = "encoder")] +pub mod encoder; diff --git a/crates/ironrdp-pdu/CHANGELOG.md b/crates/ironrdp-pdu/CHANGELOG.md index 27ab39c6c3..5a240de371 100644 --- a/crates/ironrdp-pdu/CHANGELOG.md +++ b/crates/ironrdp-pdu/CHANGELOG.md @@ -6,6 +6,136 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.8.0...ironrdp-pdu-v0.9.0)] - 2026-07-10 + +### Security + +- [**breaking**] Send NetworkAutoDetect over the MCS message channel ([#1348](https://github.com/Devolutions/IronRDP/issues/1348)) ([8a1fd0118e](https://github.com/Devolutions/IronRDP/commit/8a1fd0118e0bac214c9050b6ca6b36a040046dd3)) + + Corrects Network Auto-Detect framing and routing to match MS-RDPBCGR by + moving it off the I/O channel slow-path Share Data PDUs and onto the MCS + message channel with the required Basic Security Header + (SEC_AUTODETECT_REQ / SEC_AUTODETECT_RSP). This aligns IronRDP with + mstsc/xfreerdp behavior and enables both connect-time and continuous + auto-detection to actually function. + +### Bug Fixes + +- Set COMPRESSION_USED on the FastPath update header when compressed ([#1382](https://github.com/Devolutions/IronRDP/issues/1382)) ([3f96d0029d](https://github.com/Devolutions/IronRDP/commit/3f96d0029d37d3cee84b419bbf4d53b5519e385d)) + +- Propagate caller location through error constructor helpers ([#1392](https://github.com/Devolutions/IronRDP/issues/1392)) ([d6990d81a1](https://github.com/Devolutions/IronRDP/commit/d6990d81a17e8349e52768ad8a82f673b1e1462d)) + + The error constructor helpers in several crates wrap the #[track_caller] + ironrdp_error::Error::new, but were not themselves marked + #[track_caller]. As a result, the captured location pointed at the + helper body instead of the real call site, giving misleading "@ + file:line" info in error reports. + +- Adopt MCS and RDP header utilities relocated from ironrdp-connector ([#1419](https://github.com/Devolutions/IronRDP/issues/1419)) ([5c22f86a71](https://github.com/Devolutions/IronRDP/commit/5c22f86a7150bc10c26a3be39bfaebf84c67d781)) + + Hosts the shared MCS and RDP security-header helpers previously living in ironrdp-connector's legacy modules. + +- Decode MousePdu wheel rotation as two's complement, matching encode ([#1415](https://github.com/Devolutions/IronRDP/issues/1415)) ([9b4d01b403](https://github.com/Devolutions/IronRDP/commit/9b4d01b4038ede1cdd329fd9ea47a5d241480d1d)) + + + +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.7.0...ironrdp-pdu-v0.8.0)] - 2026-05-27 + +### Features + +- Add Initiate Multitransport Request/Response PDU types ([#1091](https://github.com/Devolutions/IronRDP/issues/1091)) ([5a50f4099b](https://github.com/Devolutions/IronRDP/commit/5a50f4099b8f8173c5c067089a0d372402dbb52d)) + + Add MultitransportRequestPdu and MultitransportResponsePdu types for the + sideband UDP transport bootstrapping PDUs defined in MS-RDPBCGR + 2.2.15.1 and 2.2.15.2. Needed to decode/encode the IO channel messages that + initiate UDP transport setup. + +- Add Auto-Detect Request and Response PDU types ([#1168](https://github.com/Devolutions/IronRDP/issues/1168)) ([6e5f08a1b9](https://github.com/Devolutions/IronRDP/commit/6e5f08a1b95f69b9d8182a75298b74aaf829ac39)) + +- [**breaking**] Route auto-detect PDUs through ShareDataPdu dispatch ([#1176](https://github.com/Devolutions/IronRDP/issues/1176)) ([e5f2f36e96](https://github.com/Devolutions/IronRDP/commit/e5f2f36e96dfb2036236c99a1ee83c5a36bf281f)) + + Added Share Data PDU dispatch support for auto-detect PDUs, improving compatibility with Windows servers. + +- Complete pixel format support for bitmap updates ([#1134](https://github.com/Devolutions/IronRDP/issues/1134)) ([a6b41093ce](https://github.com/Devolutions/IronRDP/commit/a6b41093ce4ece081d2538c157f6bc547c3b2607)) + + Wires missing bitmap pixel formats (8/15/24bpp) into the session rendering + pipeline so bitmap updates at those depths are rendered instead of being + dropped, and adds fast-path palette update parsing to support 8bpp indexed + color sessions. + +- Add RemoteFX Progressive codec primitives ([#1196](https://github.com/Devolutions/IronRDP/issues/1196)) ([49099f0c31](https://github.com/Devolutions/IronRDP/commit/49099f0c3136c25b67801fb1b07f78542dc796de)) + + Add wire-format types for RemoteFX Progressive Codec (MS-RDPRFX + Progressive Extension) and the computational primitives required for progressive refinement. + +- Handle slow-path graphics and pointer updates ([#1132](https://github.com/Devolutions/IronRDP/issues/1132)) ([9383380292](https://github.com/Devolutions/IronRDP/commit/938338029290f1be82a7f784d544bb77ac797aeb)) + + Adds support for slow-path graphics and pointer updates to IronRDP, fixing connectivity issues with servers like XRDP that use slow-path output instead of fast-path. The implementation parses slow-path framing headers and routes the inner payload structures through the existing fast-path processing pipeline by extracting shared bitmap and pointer processing methods. + +- Add progressive RFX decode and EGFX integration ([#1197](https://github.com/Devolutions/IronRDP/issues/1197)) ([a142799d1d](https://github.com/Devolutions/IronRDP/commit/a142799d1dcbdcd6546ec6e75173fbfe66f0ea67)) + +- Add ClearCodec bitmap compression codec ([#1174](https://github.com/Devolutions/IronRDP/issues/1174)) ([059ca902a5](https://github.com/Devolutions/IronRDP/commit/059ca902a5518113163042225bc5d2088869933a)) + +### Bug Fixes + +- [**breaking**] Remove unused legacy error types ([#1268](https://github.com/Devolutions/IronRDP/issues/1268)) ([df0bf9c69d](https://github.com/Devolutions/IronRDP/commit/df0bf9c69d88febaf6b82c479fdc7dcafe226567)) + + Remove GccError, McsError, RdpError, SecurityDataError, + ClusterDataError, NetworkDataError, CoreDataError, InputEventError, + ClientInfoError, CapabilitySetsError, SessionError, and ChannelError. + All encode/decode functions had already been migrated to use + DecodeResult/EncodeResult from ironrdp-core, leaving these error types + as dead code. + +- Accept short Server Deactivate All PDU ([485d6c2f8d](https://github.com/Devolutions/IronRDP/commit/485d6c2f8d6f95bb06ca14cbfa4c56a27abbad0e)) + + Some servers (XRDP, older Windows) send a Deactivate All PDU without + the sourceDescriptor field. The decode previously required at least 3 + bytes, which caused a hard failure during deactivation-reactivation + sequences with these servers. + + Treat the sourceDescriptor as optional: if the remaining data is + shorter than the fixed part size, return successfully without + reading the field. FreeRDP handles this the same way. + +- Correct ShareDataHeader uncompressedLength calculation ([#1148](https://github.com/Devolutions/IronRDP/issues/1148)) ([c2688f464d](https://github.com/Devolutions/IronRDP/commit/c2688f464d8cbf239d35e5b43538195b1870eed8)) + +- Replace all from_bits_truncate with from_bits_retain ([#1144](https://github.com/Devolutions/IronRDP/issues/1144)) ([353e30ddfd](https://github.com/Devolutions/IronRDP/commit/353e30ddfdaafc897db10b8663e364ef7775a7fd)) + + from_bits_truncate silently discards unknown bits, which breaks the + encode/decode round-trip property. This matters for fuzzing because a + PDU that decodes and re-encodes should produce identical bytes. + from_bits_retain preserves all bits, including those not yet defined in + our bitflags types, so the round-trip property holds. + +- [**breaking**] Remove ironrdp-egfx duplicates from ironrdp-pdu ([#1303](https://github.com/Devolutions/IronRDP/issues/1303)) ([491b91fd2f](https://github.com/Devolutions/IronRDP/commit/491b91fd2f33235e4b31dea5c4a215e67f734179)) + +- Cover BitmapCacheV3 in CapabilitySet encoder ([#1313](https://github.com/Devolutions/IronRDP/issues/1313)) ([a71567e35e](https://github.com/Devolutions/IronRDP/commit/a71567e35e47a6eba8493c00933e0b66e0c63d5b)) + +### Build + +- Bump the patch group across 1 directory with 2 updates ([#1222](https://github.com/Devolutions/IronRDP/issues/1222)) ([3fe6d157e0](https://github.com/Devolutions/IronRDP/commit/3fe6d157e0b55bddfdac20af290a6cfa6e550576)) + + +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.5.0...ironrdp-pdu-v0.6.0)] - 2025-08-29 + +### Features + +- Implement `Default` trait on `ExtendedClientOptionalInfoBuilder` (#891) ([ae052ed835](https://github.com/Devolutions/IronRDP/commit/ae052ed83598ad1f4ad7038b153e3c5398d2a738)) + +### Bug Fixes + +- [**breaking**] Update timezone info to use i32 bias (#921) ([119c7077c9](https://github.com/Devolutions/IronRDP/commit/119c7077c98e4b43021619378c4f251c1f95ae17)) + + Switches `bias` from an unsigned to a signed integer. + This matches the updated specification from Microsoft. + +### Build + +- Bump thiserror to 2.0 ([b4fb0aa0c7](https://github.com/Devolutions/IronRDP/commit/b4fb0aa0c79aa409d1b6a5f43ab23448eede4e51)) + +- Bump der-parser to 10.0 ([03cac54ada](https://github.com/Devolutions/IronRDP/commit/03cac54ada50fae13d085b855a9b8db37d615ba8)) + ## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.4.0...ironrdp-pdu-v0.5.0)] - 2025-05-27 ### Features @@ -20,7 +150,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 An index bound check was missing in the RFX module. Found by fuzzer. - ## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.3.1...ironrdp-pdu-v0.4.0)] - 2025-03-12 ### Bug Fixes @@ -55,8 +184,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 This fixes random error/disconnect in client. - - ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.1.2...ironrdp-pdu-v0.2.0)] - 2025-01-28 ### Features @@ -67,8 +194,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - - ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-pdu-v0.1.1...ironrdp-pdu-v0.1.2)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-pdu/Cargo.toml b/crates/ironrdp-pdu/Cargo.toml index 241a2a1631..e7865ad39c 100644 --- a/crates/ironrdp-pdu/Cargo.toml +++ b/crates/ironrdp-pdu/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-pdu" -version = "0.5.0" +version = "0.9.0" readme = "README.md" description = "RDP PDU encoding and decoding" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -21,18 +22,19 @@ std = ["alloc", "ironrdp-error/std", "ironrdp-core/std"] alloc = ["ironrdp-core/alloc", "ironrdp-error/alloc"] qoi = [] qoiz = ["qoi"] +arbitrary = ["alloc", "dep:arbitrary", "bitflags/arbitrary"] [dependencies] -bitflags = "2.9" -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["std"] } # public -ironrdp-error = { path = "../ironrdp-error", version = "0.1" } # public +bitflags = "2.11" +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["std"] } # public +ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public +arbitrary = { version = "1", features = ["derive"], optional = true } tap = "1" # TODO: get rid of these dependencies (related code should probably go into another crate) bit_field = "0.10" byteorder = "1.5" # TODO: remove der-parser = "10.0" -thiserror = "2.0" md5 = { package = "md-5", version = "0.10" } num-bigint = "0.4" num-derive.workspace = true # TODO: remove @@ -44,7 +46,6 @@ pkcs1 = "0.7" [dev-dependencies] expect-test.workspace = true -lazy_static.workspace = true # TODO: remove in favor of https://doc.rust-lang.org/std/sync/struct.OnceLock.html [lints] workspace = true diff --git a/crates/ironrdp-pdu/README.md b/crates/ironrdp-pdu/README.md index 256194ed9b..259b7b19d6 100644 --- a/crates/ironrdp-pdu/README.md +++ b/crates/ironrdp-pdu/README.md @@ -468,6 +468,15 @@ are considered to be known and defined. In such cases, `from_bits` also never fa precisely the same as `from_bits_retain`, except it’s less ergonomic because it returns a `Result` which must be needlessly handled. +## Feature Flags + +- `std` (depends on `alloc`): enables `std` integration in `ironrdp-error` and `ironrdp-core`. +- `alloc`: enables allocation-dependent code paths while keeping `no_std`-compatible. +- `qoi`, `qoiz`: optional [QOI image format](https://qoiformat.org/) bitmap codec support. +- `arbitrary` (depends on `alloc`): enables [`arbitrary::Arbitrary`](https://docs.rs/arbitrary) + implementations on PDU types for use under structure-aware fuzzing. See + [`fuzz/README.md`](../../fuzz/README.md) for the fuzz target organization. + This crate is part of the [IronRDP] project. [IronRDP]: https://github.com/Devolutions/IronRDP diff --git a/crates/ironrdp-pdu/src/basic_output/bitmap.rs b/crates/ironrdp-pdu/src/basic_output/bitmap/mod.rs similarity index 82% rename from crates/ironrdp-pdu/src/basic_output/bitmap.rs rename to crates/ironrdp-pdu/src/basic_output/bitmap/mod.rs index 52b5163ad6..d3ec08acb3 100644 --- a/crates/ironrdp-pdu/src/basic_output/bitmap.rs +++ b/crates/ironrdp-pdu/src/basic_output/bitmap/mod.rs @@ -7,8 +7,8 @@ use core::fmt::{self, Debug}; use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, }; use crate::geometry::InclusiveRectangle; @@ -17,6 +17,7 @@ const FIRST_ROW_SIZE_VALUE: u16 = 0; /// TS_UPDATE_BITMAP_DATA #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BitmapUpdateData<'a> { pub rectangles: Vec>, } @@ -41,11 +42,9 @@ impl Encode for BitmapUpdateData<'_> { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - if self.rectangles.len() > u16::MAX as usize { - return Err(invalid_field_err!("numberRectangles", "rectangle count is too big")); - } + let rectangle_count = cast_length!("number of rectangles", self.rectangles.len())?; - Self::encode_header(self.rectangles.len() as u16, dst)?; + Self::encode_header(rectangle_count, dst)?; for bitmap_data in self.rectangles.iter() { bitmap_data.encode(dst)?; @@ -69,15 +68,15 @@ impl<'de> Decode<'de> for BitmapUpdateData<'de> { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let update_type = BitmapFlags::from_bits_truncate(src.read_u16()); + let update_type = BitmapFlags::from_bits_retain(src.read_u16()); if !update_type.contains(BitmapFlags::BITMAP_UPDATE_TYPE) { return Err(invalid_field_err!("updateType", "invalid update type")); } - let rectangles_number = src.read_u16() as usize; - let mut rectangles = Vec::with_capacity(rectangles_number); + let rectangle_count = usize::from(src.read_u16()); + let mut rectangles = Vec::with_capacity(rectangle_count); - for _ in 0..rectangles_number { + for _ in 0..rectangle_count { rectangles.push(BitmapData::decode(src)?); } @@ -87,6 +86,7 @@ impl<'de> Decode<'de> for BitmapUpdateData<'de> { /// TS_BITMAP_DATA #[derive(Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BitmapData<'a> { pub rectangle: InclusiveRectangle, pub width: u16, @@ -111,16 +111,14 @@ impl Encode for BitmapData<'_> { ensure_size!(in: dst, size: self.size()); let encoded_bitmap_data_length = self.encoded_bitmap_data_length(); - if encoded_bitmap_data_length > u16::MAX as usize { - return Err(invalid_field_err!("bitmapLength", "bitmap data length is too big")); - } + let encoded_bitmap_data_length = cast_length!("bitmap data length", encoded_bitmap_data_length)?; self.rectangle.encode(dst)?; dst.write_u16(self.width); dst.write_u16(self.height); dst.write_u16(self.bits_per_pixel); dst.write_u16(self.compression_flags.bits()); - dst.write_u16(encoded_bitmap_data_length as u16); + dst.write_u16(encoded_bitmap_data_length); if let Some(compressed_data_header) = &self.compressed_data_header { compressed_data_header.encode(dst)?; }; @@ -146,29 +144,29 @@ impl<'de> Decode<'de> for BitmapData<'de> { let width = src.read_u16(); let height = src.read_u16(); let bits_per_pixel = src.read_u16(); - let compression_flags = Compression::from_bits_truncate(src.read_u16()); + let compression_flags = Compression::from_bits_retain(src.read_u16()); // A 16-bit, unsigned integer. The size in bytes of the data in the bitmapComprHdr // and bitmapDataStream fields. - let encoded_bitmap_data_length = src.read_u16(); + let encoded_bitmap_data_length = usize::from(src.read_u16()); - ensure_size!(in: src, size: encoded_bitmap_data_length as usize); + ensure_size!(in: src, size: encoded_bitmap_data_length); let (compressed_data_header, buffer_length) = if compression_flags.contains(Compression::BITMAP_COMPRESSION) && !compression_flags.contains(Compression::NO_BITMAP_COMPRESSION_HDR) { // Check if encoded_bitmap_data_length is at least CompressedDataHeader::ENCODED_SIZE - if encoded_bitmap_data_length < CompressedDataHeader::ENCODED_SIZE as u16 { + if encoded_bitmap_data_length < CompressedDataHeader::ENCODED_SIZE { return Err(invalid_field_err!( "cbCompEncodedBitmapDataLength", "length is less than CompressedDataHeader::ENCODED_SIZE" )); } - let buffer_length = encoded_bitmap_data_length as usize - CompressedDataHeader::ENCODED_SIZE; + let buffer_length = encoded_bitmap_data_length - CompressedDataHeader::ENCODED_SIZE; (Some(CompressedDataHeader::decode(src)?), buffer_length) } else { - (None, encoded_bitmap_data_length as usize) + (None, encoded_bitmap_data_length) }; let bitmap_data = src.read_slice(buffer_length); @@ -201,6 +199,7 @@ impl Debug for BitmapData<'_> { /// TS_CD_HEADER #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct CompressedDataHeader { pub main_body_size: u16, pub scan_width: u16, @@ -226,7 +225,7 @@ impl<'de> Decode<'de> for CompressedDataHeader { let main_body_size = src.read_u16(); let scan_width = src.read_u16(); - if scan_width % 4 != 0 { + if !scan_width.is_multiple_of(4) { return Err(invalid_field_err!( "cbScanWidth", "The width of the bitmap must be divisible by 4" @@ -246,7 +245,7 @@ impl Encode for CompressedDataHeader { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - if self.scan_width % 4 != 0 { + if !self.scan_width.is_multiple_of(4) { return Err(invalid_field_err!( "cbScanWidth", "The width of the bitmap must be divisible by 4" @@ -271,15 +270,21 @@ impl Encode for CompressedDataHeader { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BitmapFlags: u16{ const BITMAP_UPDATE_TYPE = 0x0001; + + const _ = !0; } } bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Compression: u16 { const BITMAP_COMPRESSION = 0x0001; const NO_BITMAP_COMPRESSION_HDR = 0x0400; + + const _ = !0; } } diff --git a/crates/ironrdp-pdu/src/basic_output/bitmap/rdp6.rs b/crates/ironrdp-pdu/src/basic_output/bitmap/rdp6.rs index dc8b5a54dc..9a41324b1e 100644 --- a/crates/ironrdp-pdu/src/basic_output/bitmap/rdp6.rs +++ b/crates/ironrdp-pdu/src/basic_output/bitmap/rdp6.rs @@ -1,11 +1,12 @@ use ironrdp_core::{ - ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, + invalid_field_err, }; const NON_RLE_PADDING_SIZE: usize = 1; #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ColorPlaneDefinition { Argb, AYCoCg { @@ -15,6 +16,7 @@ pub enum ColorPlaneDefinition { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BitmapStreamHeader { pub enable_rle_compression: bool, pub use_alpha: bool, @@ -56,7 +58,7 @@ impl Encode for BitmapStreamHeader { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - let mut header = ((self.enable_rle_compression as u8) << 4) | ((!self.use_alpha as u8) << 5); + let mut header = (u8::from(self.enable_rle_compression) << 4) | (u8::from(!self.use_alpha) << 5); match self.color_plane_definition { ColorPlaneDefinition::Argb => { @@ -68,7 +70,7 @@ impl Encode for BitmapStreamHeader { .. } => { // Add cll and cs flags to header - header |= (color_loss_level & 0x07) | ((use_chroma_subsampling as u8) << 3); + header |= (color_loss_level & 0x07) | (u8::from(use_chroma_subsampling) << 3); } } @@ -93,6 +95,7 @@ impl Encode for BitmapStreamHeader { /// Represents `RDP6_BITMAP_STREAM` structure described in [MS-RDPEGDI] 2.2.2.5.1 #[derive(Debug, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BitmapStream<'a> { pub header: BitmapStreamHeader, pub color_planes: &'a [u8], @@ -171,7 +174,7 @@ impl Encode for BitmapStream<'_> { reason = "the lint is disable to not interfere with expect! macro" )] mod tests { - use expect_test::{expect, Expect}; + use expect_test::{Expect, expect}; use super::*; diff --git a/crates/ironrdp-pdu/src/basic_output/bitmap/tests.rs b/crates/ironrdp-pdu/src/basic_output/bitmap/tests.rs index 5adfa0307b..db490f4b47 100644 --- a/crates/ironrdp-pdu/src/basic_output/bitmap/tests.rs +++ b/crates/ironrdp-pdu/src/basic_output/bitmap/tests.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode}; -use lazy_static::lazy_static; use super::*; @@ -31,31 +32,29 @@ const BITMAP_BUFFER: [u8; 114] = [ 0x55, 0xad, 0x10, 0x10, 0xa8, 0xd8, 0x60, 0x12, ]; -lazy_static! { - static ref BITMAP: BitmapUpdateData<'static> = BitmapUpdateData { - rectangles: { - let vec = vec![BitmapData { - rectangle: InclusiveRectangle { - left: 1792, - top: 1024, - right: 1855, - bottom: 1079, - }, - width: 64, - height: 56, - bits_per_pixel: 16, - compression_flags: Compression::BITMAP_COMPRESSION, - compressed_data_header: Some(CompressedDataHeader { - main_body_size: 80, - scan_width: 28, - uncompressed_size: 4, - }), - bitmap_data: &BITMAP_BUFFER[30..], - }]; - vec - } - }; -} +static BITMAP: LazyLock> = LazyLock::new(|| BitmapUpdateData { + rectangles: { + let vec = vec![BitmapData { + rectangle: InclusiveRectangle { + left: 1792, + top: 1024, + right: 1855, + bottom: 1079, + }, + width: 64, + height: 56, + bits_per_pixel: 16, + compression_flags: Compression::BITMAP_COMPRESSION, + compressed_data_header: Some(CompressedDataHeader { + main_body_size: 80, + scan_width: 28, + uncompressed_size: 4, + }), + bitmap_data: &BITMAP_BUFFER[30..], + }]; + vec + }, +}); #[test] fn from_buffer_bitmap_data_parsses_correctly() { diff --git a/crates/ironrdp-pdu/src/basic_output/fast_path.rs b/crates/ironrdp-pdu/src/basic_output/fast_path/mod.rs similarity index 75% rename from crates/ironrdp-pdu/src/basic_output/fast_path.rs rename to crates/ironrdp-pdu/src/basic_output/fast_path/mod.rs index 12a5582a3e..a604f2a684 100644 --- a/crates/ironrdp-pdu/src/basic_output/fast_path.rs +++ b/crates/ironrdp-pdu/src/basic_output/fast_path/mod.rs @@ -4,22 +4,27 @@ mod tests; use bit_field::BitField as _; use bitflags::bitflags; use ironrdp_core::{ - decode_cursor, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeError, DecodeResult, Encode, - EncodeResult, InvalidFieldErr as _, ReadCursor, WriteCursor, + Decode, DecodeError, DecodeResult, Encode, EncodeResult, InvalidFieldErr as _, ReadCursor, WriteCursor, + cast_length, decode_cursor, ensure_fixed_part_size, ensure_size, invalid_field_err, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use super::bitmap::BitmapUpdateData; use super::pointer::PointerUpdateData; -use super::surface_commands::{SurfaceCommand, SURFACE_COMMAND_HEADER_SIZE}; +use super::surface_commands::{SURFACE_COMMAND_HEADER_SIZE, SurfaceCommand}; use crate::per; use crate::rdp::client_info::CompressionType; use crate::rdp::headers::{CompressionFlags, SHARE_DATA_HEADER_COMPRESSION_MASK}; /// Implements the Fast-Path RDP message header PDU. /// TS_FP_UPDATE_PDU +#[expect( + clippy::partial_pub_fields, + reason = "this structure is used in the match expression in the integration tests" +)] #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct FastPathHeader { pub flags: EncryptionFlags, pub data_length: usize, @@ -42,7 +47,7 @@ impl FastPathHeader { // it may then be +2 if > 0x7f let len = self.data_length + Self::FIXED_PART_SIZE + 1; - Self::FIXED_PART_SIZE + per::sizeof_length(len as u16) + Self::FIXED_PART_SIZE + per::sizeof_length(len) } } @@ -56,15 +61,13 @@ impl Encode for FastPathHeader { dst.write_u8(header); let length = self.data_length + self.size(); - if length > u16::MAX as usize { - return Err(invalid_field_err!("length", "fastpath PDU length is too big")); - } + let length = cast_length!("length", length)?; if self.forced_long_length { // Preserve same layout for header as received - per::write_long_length(dst, length as u16); + per::write_long_length(dst, length); } else { - per::write_length(dst, length as u16); + per::write_length(dst, length); } Ok(()) @@ -88,19 +91,20 @@ impl<'de> Decode<'de> for FastPathHeader { ensure_fixed_part_size!(in: src); let header = src.read_u8(); - let flags = EncryptionFlags::from_bits_truncate(header.get_bits(6..8)); + let flags = EncryptionFlags::from_bits_retain(header.get_bits(6..8)); let (length, sizeof_length) = per::read_length(src).map_err(|e| { DecodeError::invalid_field("", "length", "Invalid encoded fast path PDU length").with_source(e) })?; - if (length as usize) < sizeof_length + Self::FIXED_PART_SIZE { + let length = usize::from(length); + if length < sizeof_length + Self::FIXED_PART_SIZE { return Err(invalid_field_err!( "length", "received fastpath PDU length is smaller than header size" )); } - let data_length = length as usize - sizeof_length - Self::FIXED_PART_SIZE; - // Detect case, when received packet has non-optimal packet length packing + let data_length = length - sizeof_length - Self::FIXED_PART_SIZE; + // Detect case, when received packet has non-optimal packet length packing. let forced_long_length = per::sizeof_length(length) != sizeof_length; Ok(FastPathHeader { @@ -113,6 +117,7 @@ impl<'de> Decode<'de> for FastPathHeader { /// TS_FP_UPDATE #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct FastPathUpdatePdu<'a> { pub fragmentation: Fragmentation, pub update_code: UpdateCode, @@ -131,24 +136,26 @@ impl Encode for FastPathUpdatePdu<'_> { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - if self.data.len() > u16::MAX as usize { - return Err(invalid_field_err!("data", "fastpath PDU data is too big")); - } + let data_len = cast_length!("data length", self.data.len())?; let mut header = 0u8; - header.set_bits(0..4, self.update_code.to_u8().unwrap()); - header.set_bits(4..6, self.fragmentation.to_u8().unwrap()); + header.set_bits(0..4, self.update_code.as_u8()); + header.set_bits(4..6, self.fragmentation.as_u8()); + if self.compression_flags.is_some() { + // The COMPRESSION_USED bit must be set on the header byte before it + // is written, so the decoder knows a compression flags byte follows. + header.set_bits(6..8, Compression::COMPRESSION_USED.bits()); + } dst.write_u8(header); if self.compression_flags.is_some() { - header.set_bits(6..8, Compression::COMPRESSION_USED.bits()); - let compression_flags_with_type = self.compression_flags.map(|f| f.bits()).unwrap_or(0) - | self.compression_type.and_then(|f| f.to_u8()).unwrap_or(0); + let compression_flags_with_type = + self.compression_flags.map(|f| f.bits()).unwrap_or(0) | self.compression_type.map_or(0, |f| f.as_u8()); dst.write_u8(compression_flags_with_type); } - dst.write_u16(self.data.len() as u16); + dst.write_u16(data_len); dst.write_slice(self.data); Ok(()) @@ -179,7 +186,7 @@ impl<'de> Decode<'de> for FastPathUpdatePdu<'de> { let fragmentation = Fragmentation::from_u8(fragmentation) .ok_or_else(|| invalid_field_err!("updateHeader", "Invalid fragmentation"))?; - let compression = Compression::from_bits_truncate(header.get_bits(6..8)); + let compression = Compression::from_bits_retain(header.get_bits(6..8)); let (compression_flags, compression_type) = if compression.contains(Compression::COMPRESSION_USED) { let expected_size = 1 /* flags_with_type */ + 2 /* len */; @@ -187,7 +194,7 @@ impl<'de> Decode<'de> for FastPathUpdatePdu<'de> { let compression_flags_with_type = src.read_u8(); let compression_flags = - CompressionFlags::from_bits_truncate(compression_flags_with_type & !SHARE_DATA_HEADER_COMPRESSION_MASK); + CompressionFlags::from_bits_retain(compression_flags_with_type & !SHARE_DATA_HEADER_COMPRESSION_MASK); let compression_type = CompressionType::from_u8(compression_flags_with_type & SHARE_DATA_HEADER_COMPRESSION_MASK) .ok_or_else(|| invalid_field_err!("compressionFlags", "invalid compression type"))?; @@ -200,7 +207,7 @@ impl<'de> Decode<'de> for FastPathUpdatePdu<'de> { (None, None) }; - let data_length = src.read_u16() as usize; + let data_length = usize::from(src.read_u16()); ensure_size!(in: src, size: data_length); let data = src.read_slice(data_length); @@ -216,10 +223,15 @@ impl<'de> Decode<'de> for FastPathUpdatePdu<'de> { /// TS_FP_UPDATE data #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum FastPathUpdate<'a> { SurfaceCommands(Vec>), Bitmap(BitmapUpdateData<'a>), Pointer(PointerUpdateData<'a>), + /// Raw palette update data (TS_UPDATE_PALETTE_DATA). + /// Layout: pad(2) + numberColors(u32) + N x TS_COLOR_QUAD [B, G, R, pad]. + /// See MS-RDPBCGR 2.2.9.1.1.3.1.1. + Palette(&'a [u8]), } impl<'a> FastPathUpdate<'a> { @@ -241,6 +253,11 @@ impl<'a> FastPathUpdate<'a> { Ok(Self::SurfaceCommands(commands)) } UpdateCode::Bitmap => Ok(Self::Bitmap(decode_cursor(src)?)), + UpdateCode::Palette => { + let data = src.remaining(); + src.advance(data.len()); + Ok(Self::Palette(data)) + } UpdateCode::HiddenPointer => Ok(Self::Pointer(PointerUpdateData::SetHidden)), UpdateCode::DefaultPointer => Ok(Self::Pointer(PointerUpdateData::SetDefault)), UpdateCode::PositionPointer => Ok(Self::Pointer(PointerUpdateData::SetPosition(decode_cursor(src)?))), @@ -251,7 +268,7 @@ impl<'a> FastPathUpdate<'a> { UpdateCode::CachedPointer => Ok(Self::Pointer(PointerUpdateData::Cached(decode_cursor(src)?))), UpdateCode::NewPointer => Ok(Self::Pointer(PointerUpdateData::New(decode_cursor(src)?))), UpdateCode::LargePointer => Ok(Self::Pointer(PointerUpdateData::Large(decode_cursor(src)?))), - _ => Err(invalid_field_err!("updateCode", "Invalid fast path update code")), + _ => Err(invalid_field_err!("updateCode", "unsupported fast-path update code")), } } @@ -260,6 +277,7 @@ impl<'a> FastPathUpdate<'a> { Self::SurfaceCommands(_) => "Surface Commands", Self::Bitmap(_) => "Bitmap", Self::Pointer(_) => "Pointer", + Self::Palette(_) => "Palette", } } } @@ -286,6 +304,9 @@ impl Encode for FastPathUpdate<'_> { PointerUpdateData::New(inner) => inner.encode(dst)?, PointerUpdateData::Large(inner) => inner.encode(dst)?, }, + Self::Palette(data) => { + dst.write_slice(data); + } } Ok(()) @@ -299,6 +320,7 @@ impl Encode for FastPathUpdate<'_> { match self { Self::SurfaceCommands(commands) => commands.iter().map(|c| c.size()).sum::(), Self::Bitmap(bitmap) => bitmap.size(), + Self::Palette(data) => data.len(), Self::Pointer(pointer) => match pointer { PointerUpdateData::SetHidden => 0, PointerUpdateData::SetDefault => 0, @@ -312,7 +334,9 @@ impl Encode for FastPathUpdate<'_> { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u8)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum UpdateCode { Orders = 0x0, Bitmap = 0x1, @@ -328,11 +352,22 @@ pub enum UpdateCode { LargePointer = 0xc, } +impl UpdateCode { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u8(self) -> u8 { + self as u8 + } +} + impl From<&FastPathUpdate<'_>> for UpdateCode { fn from(update: &FastPathUpdate<'_>) -> Self { match update { FastPathUpdate::SurfaceCommands(_) => Self::SurfaceCommands, FastPathUpdate::Bitmap(_) => Self::Bitmap, + FastPathUpdate::Palette(_) => Self::Palette, FastPathUpdate::Pointer(action) => match action { PointerUpdateData::SetHidden => Self::HiddenPointer, PointerUpdateData::SetDefault => Self::DefaultPointer, @@ -346,7 +381,9 @@ impl From<&FastPathUpdate<'_>> for UpdateCode { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u8)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum Fragmentation { Single = 0x0, Last = 0x1, @@ -354,17 +391,33 @@ pub enum Fragmentation { Next = 0x3, } +impl Fragmentation { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u8(self) -> u8 { + self as u8 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct EncryptionFlags: u8 { const SECURE_CHECKSUM = 0x1; const ENCRYPTED = 0x2; + + const _ = !0; } } bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Compression: u8 { const COMPRESSION_USED = 0x2; + + const _ = !0; } } diff --git a/crates/ironrdp-pdu/src/basic_output/fast_path/tests.rs b/crates/ironrdp-pdu/src/basic_output/fast_path/tests.rs index 5885aa2d3e..0201ddcae5 100644 --- a/crates/ironrdp-pdu/src/basic_output/fast_path/tests.rs +++ b/crates/ironrdp-pdu/src/basic_output/fast_path/tests.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode}; -use lazy_static::lazy_static; use super::*; @@ -29,15 +30,13 @@ const FAST_PATH_HEADER_WITH_FORCED_LONG_LEN_PDU: FastPathHeader = FastPathHeader forced_long_length: true, }; -lazy_static! { - static ref FAST_PATH_UPDATE_PDU: FastPathUpdatePdu<'static> = FastPathUpdatePdu { - fragmentation: Fragmentation::Single, - update_code: UpdateCode::SurfaceCommands, - compression_flags: None, - compression_type: None, - data: &FAST_PATH_UPDATE_PDU_BUFFER[3..], - }; -} +static FAST_PATH_UPDATE_PDU: LazyLock> = LazyLock::new(|| FastPathUpdatePdu { + fragmentation: Fragmentation::Single, + update_code: UpdateCode::SurfaceCommands, + compression_flags: None, + compression_type: None, + data: &FAST_PATH_UPDATE_PDU_BUFFER[3..], +}); #[test] fn from_buffer_correctly_parses_fast_path_header_with_short_length() { @@ -156,3 +155,64 @@ fn buffer_size_boundary_fast_path_update() { }; assert_eq!(fph.size(), 3); } + +// Minimal palette: pad(2) + numberColors(1) + 1 x TS_COLOR_QUAD [B, G, R, pad] +const PALETTE_PAYLOAD: [u8; 10] = [ + 0x00, 0x00, // pad + 0x01, 0x00, 0x00, 0x00, // numberColors = 1 + 0xFF, 0x00, 0x80, 0x00, // B=0xFF, G=0x00, R=0x80, pad=0x00 +]; + +// header(1) + length(2) + payload(10) +const FAST_PATH_PALETTE_BUFFER: [u8; 13] = [ + 0x02, // updateCode=Palette(0x2), fragmentation=Single(0x0) + 0x0A, 0x00, // data length = 10 (LE) + 0x00, 0x00, // pad + 0x01, 0x00, 0x00, 0x00, // numberColors = 1 + 0xFF, 0x00, 0x80, 0x00, // B=0xFF, G=0x00, R=0x80, pad=0x00 +]; + +#[test] +fn from_buffer_correctly_parses_palette_update() { + let pdu = decode::>(FAST_PATH_PALETTE_BUFFER.as_ref()).unwrap(); + assert_eq!(pdu.update_code, UpdateCode::Palette); + assert_eq!(pdu.fragmentation, Fragmentation::Single); + assert_eq!(pdu.data, PALETTE_PAYLOAD.as_ref()); +} + +#[test] +fn palette_update_round_trips() { + let pdu = decode::>(FAST_PATH_PALETTE_BUFFER.as_ref()).unwrap(); + let mut buffer = vec![0u8; pdu.size()]; + encode(&pdu, buffer.as_mut_slice()).unwrap(); + assert_eq!(FAST_PATH_PALETTE_BUFFER.as_ref(), buffer.as_slice()); +} + +#[test] +fn palette_decode_with_code_returns_palette_variant() { + let update = FastPathUpdate::decode_with_code(&PALETTE_PAYLOAD, UpdateCode::Palette).unwrap(); + match update { + FastPathUpdate::Palette(data) => assert_eq!(data, PALETTE_PAYLOAD.as_ref()), + other => panic!("Expected Palette variant, got: {other:?}"), + } +} + +#[test] +fn compressed_update_round_trips() { + // The encoder must set the COMPRESSION_USED bit on the update header when + // compression flags are present, otherwise the decoder does not consume the + // trailing compression flags byte and misreads the data length. + let data = [0xAAu8; 8]; + let pdu = FastPathUpdatePdu { + fragmentation: Fragmentation::Single, + update_code: UpdateCode::SurfaceCommands, + compression_flags: Some(CompressionFlags::COMPRESSED), + compression_type: Some(CompressionType::K64), + data: &data, + }; + + let mut buffer = vec![0u8; pdu.size()]; + encode(&pdu, buffer.as_mut_slice()).unwrap(); + + assert_eq!(pdu, decode::>(&buffer).unwrap()); +} diff --git a/crates/ironrdp-pdu/src/basic_output.rs b/crates/ironrdp-pdu/src/basic_output/mod.rs similarity index 80% rename from crates/ironrdp-pdu/src/basic_output.rs rename to crates/ironrdp-pdu/src/basic_output/mod.rs index 3e287b77fb..10f55ab39c 100644 --- a/crates/ironrdp-pdu/src/basic_output.rs +++ b/crates/ironrdp-pdu/src/basic_output/mod.rs @@ -1,4 +1,5 @@ pub mod bitmap; pub mod fast_path; pub mod pointer; +pub mod slow_path; pub mod surface_commands; diff --git a/crates/ironrdp-pdu/src/basic_output/pointer.rs b/crates/ironrdp-pdu/src/basic_output/pointer/mod.rs similarity index 84% rename from crates/ironrdp-pdu/src/basic_output/pointer.rs rename to crates/ironrdp-pdu/src/basic_output/pointer/mod.rs index 285a4243e6..eeb0eb96e6 100644 --- a/crates/ironrdp-pdu/src/basic_output/pointer.rs +++ b/crates/ironrdp-pdu/src/basic_output/pointer/mod.rs @@ -1,10 +1,11 @@ use ironrdp_core::{ - ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_int, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, }; // Represents `TS_POINT16` described in [MS-RDPBCGR] 2.2.9.1.1.4.1 #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Point16 { pub x: u16, pub y: u16, @@ -50,6 +51,7 @@ pub type PointerPositionAttribute = Point16; /// Represents `TS_COLORPOINTERATTRIBUTE` described in [MS-RDPBCGR] 2.2.9.1.1.4.4 #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ColorPointerAttribute<'a> { pub cache_index: u16, pub hot_spot: Point16, @@ -69,21 +71,24 @@ macro_rules! check_masks_alignment { ($and_mask:expr, $xor_mask:expr, $pointer_height:expr, $large_ptr:expr) => {{ const AND_MASK_SIZE_FIELD: &str = "lengthAndMask"; const XOR_MASK_SIZE_FIELD: &str = "lengthXorMask"; + const U32_MAX: usize = 0xFFFFFFFF; + + let pointer_height: usize = cast_int!("pointer height", $pointer_height)?; let check_mask = |mask: &[u8], field: &'static str| { if $pointer_height == 0 { return Err(invalid_field_err!(field, "pointer height cannot be zero")); } - if $large_ptr && (mask.len() > u32::MAX as usize) { + if $large_ptr && (mask.len() > U32_MAX) { return Err(invalid_field_err!(field, "pointer mask is too big for u32 size")); } - if !$large_ptr && (mask.len() > u16::MAX as usize) { + if !$large_ptr && (mask.len() > usize::from(u16::MAX)) { return Err(invalid_field_err!(field, "pointer mask is too big for u16 size")); } - if (mask.len() % $pointer_height as usize) != 0 { + if (mask.len() % pointer_height) != 0 { return Err(invalid_field_err!(field, "pointer mask have incomplete scanlines")); } - if (mask.len() / $pointer_height as usize) % 2 != 0 { + if (mask.len() / pointer_height) % 2 != 0 { return Err(invalid_field_err!( field, "pointer mask scanlines should be aligned to 16 bits" @@ -108,8 +113,8 @@ impl Encode for ColorPointerAttribute<'_> { dst.write_u16(self.width); dst.write_u16(self.height); - dst.write_u16(self.and_mask.len() as u16); - dst.write_u16(self.xor_mask.len() as u16); + dst.write_u16(cast_length!("and mask length", self.and_mask.len())?); + dst.write_u16(cast_length!("xor mask length", self.xor_mask.len())?); // Note that masks are written in reverse order. It is not a mistake, that is how the // message is defined in [MS-RDPBCGR] dst.write_slice(self.xor_mask); @@ -135,15 +140,15 @@ impl<'a> Decode<'a> for ColorPointerAttribute<'a> { let hot_spot = Point16::decode(src)?; let width = src.read_u16(); let height = src.read_u16(); - let length_and_mask = src.read_u16(); - let length_xor_mask = src.read_u16(); - // Convert to usize during the addition to prevent overflow and match expected type - let expected_masks_size = (length_and_mask as usize) + (length_xor_mask as usize); + let length_and_mask = usize::from(src.read_u16()); + let length_xor_mask = usize::from(src.read_u16()); + + let expected_masks_size = length_and_mask + length_xor_mask; ensure_size!(in: src, size: expected_masks_size); - let xor_mask = src.read_slice(length_xor_mask as usize); - let and_mask = src.read_slice(length_and_mask as usize); + let xor_mask = src.read_slice(length_xor_mask); + let and_mask = src.read_slice(length_and_mask); check_masks_alignment!(and_mask, xor_mask, height, false)?; @@ -160,6 +165,7 @@ impl<'a> Decode<'a> for ColorPointerAttribute<'a> { /// Represents `TS_POINTERATTRIBUTE` described in [MS-RDPBCGR] 2.2.9.1.1.4.5 #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct PointerAttribute<'a> { pub xor_bpp: u16, pub color_pointer: ColorPointerAttribute<'a>, @@ -202,6 +208,7 @@ impl<'a> Decode<'a> for PointerAttribute<'a> { /// Represents `TS_CACHEDPOINTERATTRIBUTE` described in [MS-RDPBCGR] 2.2.9.1.1.4.6 #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct CachedPointerAttribute { pub cache_index: u16, } @@ -241,6 +248,7 @@ impl Decode<'_> for CachedPointerAttribute { /// Represents `TS_FP_LARGEPOINTERATTRIBUTE` described in [MS-RDPBCGR] 2.2.9.1.2.1.11 #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct LargePointerAttribute<'a> { pub xor_bpp: u16, pub cache_index: u16, @@ -270,8 +278,8 @@ impl Encode for LargePointerAttribute<'_> { dst.write_u16(self.width); dst.write_u16(self.height); - dst.write_u32(self.and_mask.len() as u32); - dst.write_u32(self.xor_mask.len() as u32); + dst.write_u32(cast_length!("and mask length", self.and_mask.len())?); + dst.write_u32(cast_length!("xor mask length", self.xor_mask.len())?); // See comment in `ColorPointerAttribute::encode` about encoding order dst.write_slice(self.xor_mask); dst.write_slice(self.and_mask); @@ -298,8 +306,8 @@ impl<'a> Decode<'a> for LargePointerAttribute<'a> { let width = src.read_u16(); let height = src.read_u16(); // Convert to usize to prevent overflow during addition - let length_and_mask = src.read_u32() as usize; - let length_xor_mask = src.read_u32() as usize; + let length_and_mask = cast_length!("and mask length", src.read_u32())?; + let length_xor_mask = cast_length!("xor mask length", src.read_u32())?; let expected_masks_size = length_and_mask + length_xor_mask; ensure_size!(in: src, size: expected_masks_size); @@ -323,6 +331,7 @@ impl<'a> Decode<'a> for LargePointerAttribute<'a> { /// Pointer-related FastPath update messages (inner FastPath packet data) #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum PointerUpdateData<'a> { SetHidden, SetDefault, diff --git a/crates/ironrdp-pdu/src/basic_output/slow_path.rs b/crates/ironrdp-pdu/src/basic_output/slow_path.rs new file mode 100644 index 0000000000..1caf9238f9 --- /dev/null +++ b/crates/ironrdp-pdu/src/basic_output/slow_path.rs @@ -0,0 +1,136 @@ +// Slow-path graphics and pointer update parsing. +// +// Slow-path updates arrive inside ShareDataPdu::Update (graphics) and +// ShareDataPdu::Pointer, wrapped with a small framing header that differs +// from the fast-path encoding. The inner payload structures are identical +// to their fast-path counterparts. +// +// References: +// [MS-RDPBCGR] 2.2.9.1.1.3 — Slow-Path Graphics Update +// [MS-RDPBCGR] 2.2.9.1.1.4 — Slow-Path Pointer Update + +use ironrdp_core::{Decode as _, DecodeResult, ReadCursor, ensure_size, invalid_field_err}; + +use super::bitmap::{BitmapData, BitmapUpdateData}; +use super::pointer::{ + CachedPointerAttribute, ColorPointerAttribute, LargePointerAttribute, PointerAttribute, PointerPositionAttribute, + PointerUpdateData, +}; + +// --- Graphics updates ([MS-RDPBCGR] 2.2.9.1.1.3.1) --- + +/// `updateType` field in TS_UPDATE_HDR for slow-path graphics updates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[repr(u16)] +pub enum GraphicsUpdateType { + Orders = 0x0000, + Bitmap = 0x0001, + Palette = 0x0002, + Synchronize = 0x0003, +} + +/// Read the `updateType` u16 from the front of a slow-path graphics update. +pub fn read_graphics_update_type(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 2); + let raw = src.read_u16(); + match raw { + 0x0000 => Ok(GraphicsUpdateType::Orders), + 0x0001 => Ok(GraphicsUpdateType::Bitmap), + 0x0002 => Ok(GraphicsUpdateType::Palette), + 0x0003 => Ok(GraphicsUpdateType::Synchronize), + _ => Err(invalid_field_err!( + "updateType", + "unknown slow-path graphics update type" + )), + } +} + +/// Decode a slow-path bitmap update. +/// +/// The cursor must be positioned right after the `updateType` field +/// (i.e. already consumed by [`read_graphics_update_type`]). +pub fn decode_slow_path_bitmap<'a>(src: &mut ReadCursor<'a>) -> DecodeResult> { + // Read numberRectangles directly; updateType was already consumed by the dispatcher. + ensure_size!(in: src, size: 2); + let rectangle_count = usize::from(src.read_u16()); + let mut rectangles = Vec::with_capacity(rectangle_count); + for _ in 0..rectangle_count { + rectangles.push(BitmapData::decode(src)?); + } + Ok(BitmapUpdateData { rectangles }) +} + +// --- Pointer updates ([MS-RDPBCGR] 2.2.9.1.1.4) --- + +/// `messageType` values for slow-path pointer updates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[repr(u16)] +pub enum PointerMessageType { + System = 0x0001, + Position = 0x0003, + Color = 0x0006, + Cached = 0x0007, + /// TS_POINTERATTRIBUTE (new pointer with xor_bpp) + Pointer = 0x0008, + Large = 0x0009, +} + +/// `systemPointerType` values used when `messageType == System`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[repr(u32)] +pub enum SystemPointerType { + /// SYSPTR_NULL — hide the pointer + Null = 0x0000_0000, + /// SYSPTR_DEFAULT — show the OS default pointer + Default = 0x0000_7F00, +} + +/// Decode a complete slow-path pointer update from its raw payload. +/// +/// The payload starts with `messageType(u16)` + `pad2Octets(u16)`, +/// followed by the type-specific data. +pub fn decode_slow_path_pointer<'a>(src: &mut ReadCursor<'a>) -> DecodeResult> { + ensure_size!(in: src, size: 4); + let message_type = src.read_u16(); + let _pad = src.read_u16(); + + match message_type { + 0x0001 => { + // System pointer: the body is a single u32 indicating which system pointer. + ensure_size!(in: src, size: 4); + let system_type = src.read_u32(); + match system_type { + 0x0000_0000 => Ok(PointerUpdateData::SetHidden), + 0x0000_7F00 => Ok(PointerUpdateData::SetDefault), + _ => Err(invalid_field_err!("systemPointerType", "unknown system pointer type")), + } + } + 0x0003 => { + let pos = PointerPositionAttribute::decode(src)?; + Ok(PointerUpdateData::SetPosition(pos)) + } + 0x0006 => { + let color = ColorPointerAttribute::decode(src)?; + Ok(PointerUpdateData::Color(color)) + } + 0x0007 => { + let cached = CachedPointerAttribute::decode(src)?; + Ok(PointerUpdateData::Cached(cached)) + } + 0x0008 => { + let attr = PointerAttribute::decode(src)?; + Ok(PointerUpdateData::New(attr)) + } + 0x0009 => { + let large = LargePointerAttribute::decode(src)?; + Ok(PointerUpdateData::Large(large)) + } + _ => Err(invalid_field_err!( + "messageType", + "unknown slow-path pointer message type" + )), + } +} diff --git a/crates/ironrdp-pdu/src/basic_output/surface_commands.rs b/crates/ironrdp-pdu/src/basic_output/surface_commands/mod.rs similarity index 85% rename from crates/ironrdp-pdu/src/basic_output/surface_commands.rs rename to crates/ironrdp-pdu/src/basic_output/surface_commands/mod.rs index 2da0461f29..2c59463701 100644 --- a/crates/ironrdp-pdu/src/basic_output/surface_commands.rs +++ b/crates/ironrdp-pdu/src/basic_output/surface_commands/mod.rs @@ -3,11 +3,11 @@ mod tests; use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use crate::geometry::ExclusiveRectangle; @@ -15,6 +15,7 @@ pub const SURFACE_COMMAND_HEADER_SIZE: usize = 2; // TS_SURFCMD #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum SurfaceCommand<'a> { SetSurfaceBits(SurfaceBitsPdu<'a>), FrameMarker(FrameMarkerPdu), @@ -31,7 +32,7 @@ impl Encode for SurfaceCommand<'_> { ensure_size!(in: dst, size: self.size()); let cmd_type = SurfaceCommandType::from(self); - dst.write_u16(cmd_type.to_u16().unwrap()); + dst.write_u16(cmd_type.as_u16()); match self { Self::SetSurfaceBits(pdu) | Self::StreamSurfaceBits(pdu) => pdu.encode(dst), @@ -72,6 +73,7 @@ impl<'de> Decode<'de> for SurfaceCommand<'de> { // TS_SURFCMD_STREAM_SURF_BITS and TS_SURFCMD_SET_SURF_BITS #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SurfaceBitsPdu<'a> { pub destination: ExclusiveRectangle, pub extended_bitmap_data: ExtendedBitmapDataPdu<'a>, @@ -112,6 +114,7 @@ impl<'de> Decode<'de> for SurfaceBitsPdu<'de> { // TS_FRAME_MARKER #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct FrameMarkerPdu { pub frame_action: FrameAction, pub frame_id: Option, @@ -126,7 +129,7 @@ impl Encode for FrameMarkerPdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u16(self.frame_action as u16); + dst.write_u16(self.frame_action.as_u16()); dst.write_u32(self.frame_id.unwrap_or(0)); Ok(()) @@ -166,6 +169,7 @@ impl<'de> Decode<'de> for FrameMarkerPdu { // TS_BITMAP_DATA_EX #[derive(Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ExtendedBitmapDataPdu<'a> { pub bpp: u8, pub codec_id: u8, @@ -197,9 +201,7 @@ impl Encode for ExtendedBitmapDataPdu<'_> { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - if self.data.len() > u32::MAX as usize { - return Err(invalid_field_err!("bitmapDataLength", "bitmap data is too big")); - } + let data_len = cast_length!("bitmap data length", self.data.len())?; dst.write_u8(self.bpp); let flags = if self.header.is_some() { @@ -212,7 +214,7 @@ impl Encode for ExtendedBitmapDataPdu<'_> { dst.write_u8(self.codec_id); dst.write_u16(self.width); dst.write_u16(self.height); - dst.write_u32(self.data.len() as u32); + dst.write_u32(data_len); if let Some(header) = &self.header { header.encode(dst)?; } @@ -235,12 +237,12 @@ impl<'de> Decode<'de> for ExtendedBitmapDataPdu<'de> { ensure_fixed_part_size!(in: src); let bpp = src.read_u8(); - let flags = BitmapDataFlags::from_bits_truncate(src.read_u8()); + let flags = BitmapDataFlags::from_bits_retain(src.read_u8()); let _reserved = src.read_u8(); let codec_id = src.read_u8(); let width = src.read_u16(); let height = src.read_u16(); - let data_length = src.read_u32() as usize; + let data_length = cast_length!("bitmap data length", src.read_u32())?; let expected_remaining_size = if flags.contains(BitmapDataFlags::COMPRESSED_BITMAP_HEADER_PRESENT) { data_length + BitmapDataHeader::ENCODED_SIZE @@ -271,6 +273,7 @@ impl<'de> Decode<'de> for ExtendedBitmapDataPdu<'de> { // TS_COMPRESSED_BITMAP_HEADER_EX #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BitmapDataHeader { pub high_unique_id: u32, pub low_unique_id: u32, @@ -324,7 +327,7 @@ impl Decode<'_> for BitmapDataHeader { } } -#[derive(Debug, Copy, Clone, PartialEq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, FromPrimitive)] #[repr(u16)] enum SurfaceCommandType { SetSurfaceBits = 0x01, @@ -332,6 +335,16 @@ enum SurfaceCommandType { StreamSurfaceBits = 0x06, } +impl SurfaceCommandType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + impl From<&SurfaceCommand<'_>> for SurfaceCommandType { fn from(command: &SurfaceCommand<'_>) -> Self { match command { @@ -342,16 +355,29 @@ impl From<&SurfaceCommand<'_>> for SurfaceCommandType { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[repr(u16)] pub enum FrameAction { Begin = 0x00, End = 0x01, } +impl FrameAction { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u16(self) -> u16 { + self as u16 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] struct BitmapDataFlags: u8 { const COMPRESSED_BITMAP_HEADER_PRESENT = 0x01; + + const _ = !0; } } diff --git a/crates/ironrdp-pdu/src/basic_output/surface_commands/tests.rs b/crates/ironrdp-pdu/src/basic_output/surface_commands/tests.rs index fd2e6830f2..db1db71b52 100644 --- a/crates/ironrdp-pdu/src/basic_output/surface_commands/tests.rs +++ b/crates/ironrdp-pdu/src/basic_output/surface_commands/tests.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode}; -use lazy_static::lazy_static; use super::*; @@ -75,8 +76,8 @@ const FRAME_MARKER_PDU: SurfaceCommand<'_> = SurfaceCommand::FrameMarker(FrameMa frame_id: Some(5), }); -lazy_static! { - static ref SURFACE_BITS_PDU: SurfaceCommand<'static> = SurfaceCommand::StreamSurfaceBits(SurfaceBitsPdu { +static SURFACE_BITS_PDU: LazyLock> = LazyLock::new(|| { + SurfaceCommand::StreamSurfaceBits(SurfaceBitsPdu { destination: ExclusiveRectangle { left: 0, top: 0, @@ -91,8 +92,8 @@ lazy_static! { header: None, data: &SURFACE_BITS_BUFFER[22..], }, - }); -} + }) +}); #[test] fn from_buffer_correctly_parses_surface_command_frame_marker() { diff --git a/crates/ironrdp-pdu/src/ber.rs b/crates/ironrdp-pdu/src/ber.rs index b01b49ab24..1154b03e92 100644 --- a/crates/ironrdp-pdu/src/ber.rs +++ b/crates/ironrdp-pdu/src/ber.rs @@ -1,15 +1,27 @@ -use ironrdp_core::{cast_length, ensure_size, invalid_field_err, ReadCursor, WriteCursor}; +use ironrdp_core::{ReadCursor, WriteCursor, cast_length, ensure_size, invalid_field_err}; use crate::{DecodeResult, EncodeResult}; #[repr(u8)] +#[derive(Copy, Clone)] pub(crate) enum Pc { Primitive = 0x00, Construct = 0x20, } +impl Pc { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + #[repr(u8)] #[expect(unused)] +#[derive(Copy, Clone)] enum Class { Universal = 0x00, Application = 0x40, @@ -17,8 +29,19 @@ enum Class { Private = 0xC0, } +impl Class { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + #[repr(u8)] #[expect(unused)] +#[derive(Copy, Clone)] enum Tag { Mask = 0x1F, Boolean = 0x01, @@ -30,6 +53,16 @@ enum Tag { Sequence = 0x10, } +impl Tag { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + pub(crate) const SIZEOF_ENUMERATED: usize = 3; pub(crate) const SIZEOF_BOOL: usize = 3; @@ -46,7 +79,7 @@ pub(crate) fn sizeof_sequence_tag(length: u16) -> usize { } pub(crate) fn sizeof_octet_string(length: u16) -> usize { - 1 + sizeof_length(length) + length as usize + 1 + sizeof_length(length) + usize::from(length) } pub(crate) fn sizeof_integer(value: u32) -> usize { @@ -71,7 +104,7 @@ pub(crate) fn read_sequence_tag(stream: &mut ReadCursor<'_>) -> DecodeResult, tagnum: u8, le ensure_size!(in: stream, size: sizeof_application_tag(tagnum, length)); let taglen = if tagnum > 0x1E { - stream.write_u8(Class::Application as u8 | Pc::Construct as u8 | TAG_MASK); + stream.write_u8(Class::Application.as_u8() | Pc::Construct.as_u8() | TAG_MASK); stream.write_u8(tagnum); 2 } else { - stream.write_u8(Class::Application as u8 | Pc::Construct as u8 | (TAG_MASK & tagnum)); + stream.write_u8(Class::Application.as_u8() | Pc::Construct.as_u8() | (TAG_MASK & tagnum)); 1 }; @@ -98,14 +131,14 @@ pub(crate) fn read_application_tag(stream: &mut ReadCursor<'_>, tagnum: u8) -> D let identifier = stream.read_u8(); if tagnum > 0x1E { - if identifier != Class::Application as u8 | Pc::Construct as u8 | TAG_MASK { + if identifier != Class::Application.as_u8() | Pc::Construct.as_u8() | TAG_MASK { return Err(invalid_field_err!("identifier", "invalid application tag identifier")); } ensure_size!(in: stream, size: 1); if stream.read_u8() != tagnum { return Err(invalid_field_err!("tagnum", "invalid application tag identifier")); } - } else if identifier != Class::Application as u8 | Pc::Construct as u8 | (TAG_MASK & tagnum) { + } else if identifier != Class::Application.as_u8() | Pc::Construct.as_u8() | (TAG_MASK & tagnum) { return Err(invalid_field_err!("identifier", "invalid application tag identifier")); } @@ -146,20 +179,22 @@ pub(crate) fn write_integer(stream: &mut WriteCursor<'_>, value: u32) -> EncodeR if value < 0x0000_0080 { write_length(stream, 1)?; ensure_size!(in: stream, size: 1); - stream.write_u8(value as u8); + stream.write_u8(u8::try_from(value).expect("value is guaranteed to fit into u8 due to the prior check")); Ok(3) } else if value < 0x0000_8000 { write_length(stream, 2)?; ensure_size!(in: stream, size: 2); - stream.write_u16_be(value as u16); + stream.write_u16_be(u16::try_from(value).expect("value is guaranteed to fit into u16 due to the prior check")); Ok(4) } else if value < 0x0080_0000 { write_length(stream, 3)?; ensure_size!(in: stream, size: 3); - stream.write_u8((value >> 16) as u8); - stream.write_u16_be((value & 0xFFFF) as u16); + stream.write_u8(u8::try_from(value >> 16).expect("value is guaranteed to fit into u8 due to the prior check")); + stream.write_u16_be( + u16::try_from(value & 0xFFFF).expect("masking with 0xFFFF ensures that the value fits into u16"), + ); Ok(5) } else { @@ -251,7 +286,7 @@ pub(crate) fn read_octet_string_tag(stream: &mut ReadCursor<'_>) -> DecodeResult fn write_universal_tag(stream: &mut WriteCursor<'_>, tag: Tag, pc: Pc) -> EncodeResult { ensure_size!(in: stream, size: 1); - let identifier = Class::Universal as u8 | pc as u8 | (TAG_MASK & tag as u8); + let identifier = Class::Universal.as_u8() | pc.as_u8() | (TAG_MASK & tag.as_u8()); stream.write_u8(identifier); Ok(1) @@ -262,7 +297,7 @@ fn read_universal_tag(stream: &mut ReadCursor<'_>, tag: Tag, pc: Pc) -> DecodeRe let identifier = stream.read_u8(); - if identifier != Class::Universal as u8 | pc as u8 | (TAG_MASK & tag as u8) { + if identifier != Class::Universal.as_u8() | pc.as_u8() | (TAG_MASK & tag.as_u8()) { Err(invalid_field_err!("identifier", "invalid universal tag identifier")) } else { Ok(()) @@ -279,11 +314,11 @@ fn write_length(stream: &mut WriteCursor<'_>, length: u16) -> EncodeResult 0x7F { stream.write_u8(0x80 ^ 0x1); - stream.write_u8(length as u8); + stream.write_u8(u8::try_from(length).expect("length is guaranteed to fit into u8 due to the prior check")); Ok(2) } else { - stream.write_u8(length as u8); + stream.write_u8(u8::try_from(length).expect("length is guaranteed to fit into u8 due to the prior check")); Ok(1) } diff --git a/crates/ironrdp-pdu/src/codecs.rs b/crates/ironrdp-pdu/src/codecs.rs deleted file mode 100644 index 6fb4906b54..0000000000 --- a/crates/ironrdp-pdu/src/codecs.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod rfx; diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/bands.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/bands.rs new file mode 100644 index 0000000000..863ba12241 --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/bands.rs @@ -0,0 +1,251 @@ +//! ClearCodec Layer 2: Bands (V-Bar Cached Columns) ([MS-RDPEGFX] 2.2.4.1.1.2). +//! +//! Bands encode rectangular strips of a bitmap using cached vertical column +//! data ("V-bars"). Each band covers a horizontal extent and contains one +//! V-bar per x-coordinate column. V-bars reference a two-level cache +//! (full V-bar storage + short V-bar storage) to exploit recurring vertical +//! column patterns typical of text glyphs. + +use ironrdp_core::{DecodeResult, ReadCursor, ensure_size, invalid_field_err}; + +/// Maximum band height per the spec. +pub const MAX_BAND_HEIGHT: u16 = 52; + +/// Number of entries in the full V-bar storage. +pub const VBAR_CACHE_SIZE: usize = 32_768; + +/// Number of entries in the short V-bar storage. +pub const SHORT_VBAR_CACHE_SIZE: usize = 16_384; + +/// A decoded band structure. +#[derive(Debug, Clone)] +pub struct Band<'a> { + pub x_start: u16, + pub x_end: u16, + pub y_start: u16, + pub y_end: u16, + /// Background color (BGR). + pub blue_bkg: u8, + pub green_bkg: u8, + pub red_bkg: u8, + /// One V-bar per column from x_start to x_end (inclusive). + pub vbars: Vec>, +} + +impl Band<'_> { + const NAME: &'static str = "ClearCodecBand"; + /// Band header: 4 x u16 + 3 x u8 = 11 bytes. + const HEADER_SIZE: usize = 11; +} + +/// A V-bar reference within a band. +/// +/// Discriminated by the top 2 bits of the first u16 word: +/// - `1x` (bit 15 set): full V-bar cache hit (15-bit index) +/// - `01` (bits 15:14 = 01): short V-bar cache hit (14-bit index + yOn offset) +/// - `00` (bits 15:14 = 00): short V-bar cache miss (inline pixel data) +#[derive(Debug, Clone)] +pub enum VBar<'a> { + /// Full V-bar cache hit. Index into V-Bar Storage (0..32767). + CacheHit { index: u16 }, + /// Short V-bar cache hit. Index into Short V-Bar Storage (0..16383) + /// plus a `yOn` offset byte for vertical positioning. + ShortCacheHit { index: u16, y_on: u8 }, + /// Short V-bar cache miss. Contains inline pixel data. + ShortCacheMiss(ShortVBarCacheMiss<'a>), +} + +/// Inline short V-bar data from a cache miss. +#[derive(Debug, Clone)] +pub struct ShortVBarCacheMiss<'a> { + /// First pixel row within the band where color data starts (shortVBarYOn). + pub y_on: u8, + /// Number of pixel rows with color data (`shortVBarYOff - shortVBarYOn`). + pub y_off_delta: u8, + /// Raw BGR pixel data: `y_off_delta * 3` bytes. + pub pixel_data: &'a [u8], +} + +/// Decode all bands from the bands layer data. +pub fn decode_bands_layer<'a>(data: &'a [u8]) -> DecodeResult>> { + let mut bands = Vec::new(); + let mut src = ReadCursor::new(data); + + while src.len() >= Band::HEADER_SIZE { + let band = decode_single_band(&mut src)?; + bands.push(band); + } + + Ok(bands) +} + +fn decode_single_band<'a>(src: &mut ReadCursor<'a>) -> DecodeResult> { + ensure_size!(ctx: Band::NAME, in: src, size: Band::HEADER_SIZE); + + let x_start = src.read_u16(); + let x_end = src.read_u16(); + let y_start = src.read_u16(); + let y_end = src.read_u16(); + let blue_bkg = src.read_u8(); + let green_bkg = src.read_u8(); + let red_bkg = src.read_u8(); + + // Validate band height + let height = y_end + .checked_sub(y_start) + .and_then(|h| h.checked_add(1)) + .ok_or_else(|| invalid_field_err!("yEnd", "yEnd < yStart"))?; + + if height > MAX_BAND_HEIGHT { + return Err(invalid_field_err!("bandHeight", "band height exceeds 52")); + } + + if x_end < x_start { + return Err(invalid_field_err!("xEnd", "xEnd < xStart")); + } + + // `x_end - x_start` is at most u16::MAX (when x_end = u16::MAX and + // x_start = 0), so the `+ 1` would overflow u16. Cast to usize first. + let column_count = usize::from(x_end - x_start) + 1; + let mut vbars = Vec::with_capacity(column_count); + + for _ in 0..column_count { + let vbar = decode_vbar(src, height)?; + vbars.push(vbar); + } + + Ok(Band { + x_start, + x_end, + y_start, + y_end, + blue_bkg, + green_bkg, + red_bkg, + vbars, + }) +} + +fn decode_vbar<'a>(src: &mut ReadCursor<'a>, band_height: u16) -> DecodeResult> { + ensure_size!(ctx: "VBar", in: src, size: 2); + let first_word = src.read_u16(); + + // Top bit set: full V-bar cache hit + if first_word & 0x8000 != 0 { + let index = first_word & 0x7FFF; + return Ok(VBar::CacheHit { index }); + } + + // Bit 14 set (bit 15 clear): short V-bar cache hit + if first_word & 0x4000 != 0 { + let index = first_word & 0x3FFF; + ensure_size!(ctx: "ShortVBarCacheHit", in: src, size: 1); + let y_on = src.read_u8(); + return Ok(VBar::ShortCacheHit { index, y_on }); + } + + // Both top bits clear: short V-bar cache miss + // Per MS-RDPEGFX 2.2.4.1.1.2.1.1.3 (SHORT_VBAR_CACHE_MISS): + // bits 13:6 = shortVBarYOn (8 bits): row where Short V-Bar begins + // bits 5:0 = shortVBarYOff (6 bits): row where Short V-Bar ends + // Pixel count = shortVBarYOff - shortVBarYOn + let y_on = u8::try_from(first_word >> 6).expect("top 2 bits are clear, so shifted value fits in u8"); + let y_off = u8::try_from(first_word & 0x3F).expect("masked to 6 bits, always fits in u8"); + + if y_off < y_on { + return Err(invalid_field_err!("shortVBarCacheMiss", "shortVBarYOff < shortVBarYOn")); + } + + if u16::from(y_off) > band_height { + return Err(invalid_field_err!( + "shortVBarCacheMiss", + "shortVBarYOff exceeds band height" + )); + } + + let pixel_count = y_off - y_on; + let pixel_byte_count = usize::from(pixel_count) * 3; + ensure_size!(ctx: "ShortVBarCacheMiss", in: src, size: pixel_byte_count); + let pixel_data = src.read_slice(pixel_byte_count); + + Ok(VBar::ShortCacheMiss(ShortVBarCacheMiss { + y_on, + y_off_delta: pixel_count, + pixel_data, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_vbar_cache_hit() { + // Bit 15 set, index = 42 + let data = (0x8000u16 | 42).to_le_bytes(); + let mut cursor = ReadCursor::new(&data); + let vbar = decode_vbar(&mut cursor, 10).unwrap(); + match vbar { + VBar::CacheHit { index } => assert_eq!(index, 42), + _ => panic!("expected CacheHit"), + } + } + + #[test] + fn decode_vbar_short_cache_hit() { + // Bit 14 set, bit 15 clear, index = 100, yOn = 5 + let mut data = Vec::new(); + data.extend_from_slice(&(0x4000u16 | 100).to_le_bytes()); + data.push(5); // yOn + let mut cursor = ReadCursor::new(&data); + let vbar = decode_vbar(&mut cursor, 10).unwrap(); + match vbar { + VBar::ShortCacheHit { index, y_on } => { + assert_eq!(index, 100); + assert_eq!(y_on, 5); + } + _ => panic!("expected ShortCacheHit"), + } + } + + #[test] + fn decode_vbar_short_cache_miss() { + // Both top bits clear: y_on=2, y_off=5, pixel_count = y_off - y_on = 3 + let y_on: u16 = 2; + let y_off: u16 = 5; + let first_word = (y_on << 6) | y_off; + let mut data = Vec::new(); + data.extend_from_slice(&first_word.to_le_bytes()); + // 3 pixels * 3 bytes = 9 bytes BGR data + data.extend_from_slice(&[0xFF, 0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00, 0xFF]); + let mut cursor = ReadCursor::new(&data); + let vbar = decode_vbar(&mut cursor, 10).unwrap(); + match vbar { + VBar::ShortCacheMiss(miss) => { + assert_eq!(miss.y_on, 2); + assert_eq!(miss.y_off_delta, 3); // pixel_count = y_off - y_on = 5 - 2 = 3 + assert_eq!(miss.pixel_data.len(), 9); + } + _ => panic!("expected ShortCacheMiss"), + } + } + + #[test] + fn decode_band_validates_height() { + // Band with height > 52 should fail + let mut data = Vec::new(); + data.extend_from_slice(&0u16.to_le_bytes()); // x_start + data.extend_from_slice(&0u16.to_le_bytes()); // x_end = 0 (1 column) + data.extend_from_slice(&0u16.to_le_bytes()); // y_start + data.extend_from_slice(&52u16.to_le_bytes()); // y_end = 52, height = 53 > MAX + data.extend_from_slice(&[0, 0, 0]); // bkg BGR + let result = decode_bands_layer(&data); + assert!(result.is_err()); + } + + #[test] + fn decode_empty_bands_layer() { + let bands = decode_bands_layer(&[]).unwrap(); + assert!(bands.is_empty()); + } +} diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/mod.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/mod.rs new file mode 100644 index 0000000000..a94e61a9b2 --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/mod.rs @@ -0,0 +1,203 @@ +//! ClearCodec bitmap compression codec (MS-RDPEGFX 2.2.4.1). +//! +//! ClearCodec is a mandatory lossless codec for all EGFX versions (V8-V10.7). +//! It uses a three-layer composite architecture: residual (BGR RLE), bands +//! (V-bar cached columns), and subcodec (raw / NSCodec / RLEX). +//! +//! The codec is transported inside `WireToSurface1Pdu` with `codecId = 0x0008`. + +mod bands; +mod residual; +mod rlex; +mod subcodec; + +use ironrdp_core::{DecodeResult, ReadCursor, cast_length, ensure_size, invalid_field_err}; + +pub use self::bands::{ + Band, MAX_BAND_HEIGHT, SHORT_VBAR_CACHE_SIZE, ShortVBarCacheMiss, VBAR_CACHE_SIZE, VBar, decode_bands_layer, +}; +pub use self::residual::{RgbRunSegment, decode_residual_layer, encode_residual_layer}; +pub use self::rlex::{MAX_PALETTE_COUNT, RlexData, RlexSegment, decode_rlex}; +pub use self::subcodec::{Subcodec, SubcodecId, decode_subcodec_layer}; + +// --- Flag constants --- + +/// `glyphIndex` field is present (bitmap area <= 1024 pixels). +pub const FLAG_GLYPH_INDEX: u8 = 0x01; +/// Use cached glyph at `glyphIndex`; no composite payload follows. +pub const FLAG_GLYPH_HIT: u8 = 0x02; +/// Reset V-Bar and Short V-Bar storage cursors to 0. +pub const FLAG_CACHE_RESET: u8 = 0x04; + +// --- Top-level bitmap stream --- + +/// Decoded ClearCodec bitmap stream ([MS-RDPEGFX] 2.2.4.1). +#[derive(Debug, Clone)] +pub struct ClearCodecBitmapStream<'a> { + /// Combination of `FLAG_GLYPH_INDEX`, `FLAG_GLYPH_HIT`, `FLAG_CACHE_RESET`. + pub flags: u8, + /// Sequence number (wraps 0xFF -> 0x00). + pub seq_number: u8, + /// Glyph cache index, present when `FLAG_GLYPH_INDEX` is set. + pub glyph_index: Option, + /// Composite payload (three layers), absent when `FLAG_GLYPH_HIT` is set. + pub composite: Option>, +} + +impl<'a> ClearCodecBitmapStream<'a> { + const NAME: &'static str = "ClearCodecBitmapStream"; + + /// Decode the complete bitmap stream from raw bytes. + pub fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_size!(ctx: Self::NAME, in: src, size: 2); + let flags = src.read_u8(); + let seq_number = src.read_u8(); + + let glyph_index = if flags & FLAG_GLYPH_INDEX != 0 { + ensure_size!(ctx: Self::NAME, in: src, size: 2); + Some(src.read_u16()) + } else { + None + }; + + // GLYPH_HIT means use cached glyph; no payload follows. + let composite = if flags & FLAG_GLYPH_HIT != 0 { + None + } else if src.is_empty() { + // No composite payload (valid for cache reset only messages) + None + } else { + Some(CompositePayload::decode(src)?) + }; + + Ok(Self { + flags, + seq_number, + glyph_index, + composite, + }) + } + + pub fn has_glyph_index(&self) -> bool { + self.flags & FLAG_GLYPH_INDEX != 0 + } + + pub fn is_glyph_hit(&self) -> bool { + self.flags & FLAG_GLYPH_HIT != 0 + } + + pub fn is_cache_reset(&self) -> bool { + self.flags & FLAG_CACHE_RESET != 0 + } +} + +// --- Composite payload (3 layers) --- + +/// The three-layer composite payload ([MS-RDPEGFX] 2.2.4.1.1). +/// +/// Layers are applied in order: residual -> bands -> subcodec. +/// Each layer composites on top of the previous result. +#[derive(Debug, Clone)] +pub struct CompositePayload<'a> { + /// Raw bytes for the residual (BGR RLE) layer. + pub residual_data: &'a [u8], + /// Raw bytes for the bands (V-bar cached columns) layer. + pub bands_data: &'a [u8], + /// Raw bytes for the subcodec layer. + pub subcodec_data: &'a [u8], +} + +impl<'a> CompositePayload<'a> { + const NAME: &'static str = "CompositePayload"; + + /// Header: 3 x u32 byte counts. + const HEADER_SIZE: usize = 12; + + pub fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { + ensure_size!(ctx: Self::NAME, in: src, size: Self::HEADER_SIZE); + + let residual_byte_count: usize = cast_length!("residualByteCount", src.read_u32())?; + let bands_byte_count: usize = cast_length!("bandsByteCount", src.read_u32())?; + let subcodec_byte_count: usize = cast_length!("subcodecByteCount", src.read_u32())?; + + let total = residual_byte_count + .checked_add(bands_byte_count) + .and_then(|s| s.checked_add(subcodec_byte_count)) + .ok_or_else(|| invalid_field_err!("byteCount", "layer byte counts overflow"))?; + + ensure_size!(ctx: Self::NAME, in: src, size: total); + + let residual_data = src.read_slice(residual_byte_count); + let bands_data = src.read_slice(bands_byte_count); + let subcodec_data = src.read_slice(subcodec_byte_count); + + Ok(Self { + residual_data, + bands_data, + subcodec_data, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_glyph_hit() { + // flags=0x03 (GLYPH_INDEX | GLYPH_HIT), seq=0x05, glyphIndex=0x0042 + let data = [0x03, 0x05, 0x42, 0x00]; + let mut cursor = ReadCursor::new(&data); + let stream = ClearCodecBitmapStream::decode(&mut cursor).unwrap(); + assert!(stream.has_glyph_index()); + assert!(stream.is_glyph_hit()); + assert!(!stream.is_cache_reset()); + assert_eq!(stream.seq_number, 5); + assert_eq!(stream.glyph_index, Some(0x0042)); + assert!(stream.composite.is_none()); + } + + #[test] + fn decode_cache_reset_only() { + // flags=0x04 (CACHE_RESET), seq=0x00, no glyph, no composite + let data = [0x04, 0x00]; + let mut cursor = ReadCursor::new(&data); + let stream = ClearCodecBitmapStream::decode(&mut cursor).unwrap(); + assert!(stream.is_cache_reset()); + assert!(!stream.has_glyph_index()); + assert!(stream.composite.is_none()); + } + + #[test] + fn decode_composite_payload_empty_layers() { + // flags=0x00, seq=0x01, composite with all-zero byte counts + let data = [ + 0x00, 0x01, // flags, seq + 0x00, 0x00, 0x00, 0x00, // residualByteCount = 0 + 0x00, 0x00, 0x00, 0x00, // bandsByteCount = 0 + 0x00, 0x00, 0x00, 0x00, // subcodecByteCount = 0 + ]; + let mut cursor = ReadCursor::new(&data); + let stream = ClearCodecBitmapStream::decode(&mut cursor).unwrap(); + let composite = stream.composite.unwrap(); + assert!(composite.residual_data.is_empty()); + assert!(composite.bands_data.is_empty()); + assert!(composite.subcodec_data.is_empty()); + } + + #[test] + fn decode_composite_with_residual_data() { + // flags=0x00, seq=0x02, residual=4 bytes, bands=0, subcodec=0 + let data = [ + 0x00, 0x02, // flags, seq + 0x04, 0x00, 0x00, 0x00, // residualByteCount = 4 + 0x00, 0x00, 0x00, 0x00, // bandsByteCount = 0 + 0x00, 0x00, 0x00, 0x00, // subcodecByteCount = 0 + 0xFF, 0x00, 0x00, 0x01, // 4 bytes of residual data + ]; + let mut cursor = ReadCursor::new(&data); + let stream = ClearCodecBitmapStream::decode(&mut cursor).unwrap(); + let composite = stream.composite.unwrap(); + assert_eq!(composite.residual_data, &[0xFF, 0x00, 0x00, 0x01]); + } +} diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/residual.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/residual.rs new file mode 100644 index 0000000000..5fdca05631 --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/residual.rs @@ -0,0 +1,199 @@ +//! ClearCodec Layer 1: Residual (BGR RLE) ([MS-RDPEGFX] 2.2.4.1.1.1). +//! +//! The residual layer encodes the background of the bitmap as a sequence of +//! run-length-encoded BGR pixel runs. This forms the base layer onto which +//! bands and subcodec regions are composited. + +use ironrdp_core::{DecodeResult, ReadCursor, ensure_size}; + +/// A single BGR run-length segment. +/// +/// The run length uses a variable-length encoding: +/// - `factor1 < 0xFF`: run = factor1 +/// - `factor1 == 0xFF && factor2 < 0xFFFF`: run = factor2 +/// - `factor1 == 0xFF && factor2 == 0xFFFF`: run = factor3 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RgbRunSegment { + pub blue: u8, + pub green: u8, + pub red: u8, + pub run_length: u32, +} + +impl RgbRunSegment { + const NAME: &'static str = "RgbRunSegment"; + + /// Minimum segment size: 3 bytes color + 1 byte factor1. + const MIN_SIZE: usize = 4; +} + +/// Decode all residual run segments from the residual layer data. +/// +/// Returns the sequence of run segments. The caller is responsible for +/// expanding them into a pixel buffer of `width * height` pixels. +pub fn decode_residual_layer(data: &[u8]) -> DecodeResult> { + let mut segments = Vec::new(); + let mut src = ReadCursor::new(data); + + while src.len() >= RgbRunSegment::MIN_SIZE { + let blue = src.read_u8(); + let green = src.read_u8(); + let red = src.read_u8(); + let factor1 = src.read_u8(); + + let run_length = if factor1 < 0xFF { + u32::from(factor1) + } else { + ensure_size!(ctx: RgbRunSegment::NAME, in: src, size: 2); + let factor2 = src.read_u16(); + if factor2 < 0xFFFF { + u32::from(factor2) + } else { + ensure_size!(ctx: RgbRunSegment::NAME, in: src, size: 4); + src.read_u32() + } + }; + + segments.push(RgbRunSegment { + blue, + green, + red, + run_length, + }); + } + + Ok(segments) +} + +/// Encode residual layer data from a sequence of BGR run segments. +/// +/// Writes the variable-length encoded run segments into a Vec. +/// +/// # Panics +/// +/// Cannot panic. Internal `expect()` calls are guarded by range checks. +pub fn encode_residual_layer(segments: &[RgbRunSegment]) -> Vec { + let mut buf = Vec::with_capacity(segments.len() * 4); + + for seg in segments { + buf.push(seg.blue); + buf.push(seg.green); + buf.push(seg.red); + + if seg.run_length < 0xFF { + buf.push(u8::try_from(seg.run_length).expect("guarded by < 0xFF check")); + } else if seg.run_length < 0xFFFF { + buf.push(0xFF); + buf.extend_from_slice( + &u16::try_from(seg.run_length) + .expect("guarded by < 0xFFFF check") + .to_le_bytes(), + ); + } else { + buf.push(0xFF); + buf.extend_from_slice(&0xFFFFu16.to_le_bytes()); + buf.extend_from_slice(&seg.run_length.to_le_bytes()); + } + } + + buf +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_single_short_run() { + // Blue=0x10, Green=0x20, Red=0x30, run=5 + let data = [0x10, 0x20, 0x30, 0x05]; + let segments = decode_residual_layer(&data).unwrap(); + assert_eq!(segments.len(), 1); + assert_eq!( + segments[0], + RgbRunSegment { + blue: 0x10, + green: 0x20, + red: 0x30, + run_length: 5 + } + ); + } + + #[test] + fn decode_medium_run() { + // run_length = 300 (0x012C), needs factor2 + let data = [0x00, 0x00, 0x00, 0xFF, 0x2C, 0x01]; + let segments = decode_residual_layer(&data).unwrap(); + assert_eq!(segments[0].run_length, 300); + } + + #[test] + fn decode_long_run() { + // run_length = 70000 (0x00011170), needs factor3 + let data = [0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x70, 0x11, 0x01, 0x00]; + let segments = decode_residual_layer(&data).unwrap(); + assert_eq!(segments[0].run_length, 70000); + } + + #[test] + fn decode_multiple_segments() { + // Two short runs + let data = [ + 0xFF, 0x00, 0x00, 0x03, // blue pixel, run=3 + 0x00, 0xFF, 0x00, 0x02, // green pixel, run=2 + ]; + let segments = decode_residual_layer(&data).unwrap(); + assert_eq!(segments.len(), 2); + assert_eq!(segments[0].run_length, 3); + assert_eq!(segments[1].run_length, 2); + } + + #[test] + fn round_trip_short() { + let original = vec![ + RgbRunSegment { + blue: 0xAA, + green: 0xBB, + red: 0xCC, + run_length: 42, + }, + RgbRunSegment { + blue: 0x00, + green: 0x00, + red: 0x00, + run_length: 0, + }, + ]; + let encoded = encode_residual_layer(&original); + let decoded = decode_residual_layer(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn round_trip_all_sizes() { + let original = vec![ + RgbRunSegment { + blue: 0, + green: 0, + red: 0, + run_length: 100, + }, // short + RgbRunSegment { + blue: 0, + green: 0, + red: 0, + run_length: 1000, + }, // medium + RgbRunSegment { + blue: 0, + green: 0, + red: 0, + run_length: 100_000, + }, // long + ]; + let encoded = encode_residual_layer(&original); + let decoded = decode_residual_layer(&encoded).unwrap(); + assert_eq!(decoded, original); + } +} diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/rlex.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/rlex.rs new file mode 100644 index 0000000000..c2a5d77017 --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/rlex.rs @@ -0,0 +1,214 @@ +//! ClearCodec RLEX subcodec ([MS-RDPEGFX] 2.2.4.1.1.3.1.3). +//! +//! RLEX is a palette-indexed RLE codec with gradient "suite" encoding. +//! It encodes each pixel as a pair: a "run" of repeated color followed +//! by a "suite" (sequential palette walk from startIndex to stopIndex). + +use ironrdp_core::{DecodeResult, ReadCursor, ensure_size, invalid_field_err}; + +/// Maximum palette size per spec. +pub const MAX_PALETTE_COUNT: u8 = 127; + +/// A decoded RLEX segment (run + suite). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RlexSegment { + /// Palette index to repeat for the run portion. + pub start_index: u8, + /// Last palette index in the suite walk. + pub stop_index: u8, + /// Number of pixels in the run (repeated start color). + pub run_length: u32, +} + +/// Decoded RLEX data: palette + segments. +#[derive(Debug, Clone)] +pub struct RlexData { + /// BGR palette entries (3 bytes each). + pub palette: Vec<[u8; 3]>, + /// Sequence of run+suite segments. + pub segments: Vec, +} + +/// Decode RLEX subcodec data. +/// +/// The data format: +/// ```text +/// paletteCount(u8) | paletteEntries[paletteCount * 3 bytes BGR] +/// segments[]: packed bit fields +/// ``` +/// +/// Bit widths derived from palette count: +/// - `stop_index_bits = floor(log2(palette_count - 1)) + 1` +/// - `suite_depth_bits = 8 - stop_index_bits` +pub fn decode_rlex(data: &[u8]) -> DecodeResult { + let mut src = ReadCursor::new(data); + + ensure_size!(ctx: "RlexPalette", in: src, size: 1); + let palette_count = src.read_u8(); + + if palette_count == 0 { + return Err(invalid_field_err!("paletteCount", "palette count is 0")); + } + + if palette_count > MAX_PALETTE_COUNT { + return Err(invalid_field_err!("paletteCount", "palette count exceeds 127")); + } + + let palette_byte_count = usize::from(palette_count) * 3; + ensure_size!(ctx: "RlexPalette", in: src, size: palette_byte_count); + + let mut palette = Vec::with_capacity(usize::from(palette_count)); + for _ in 0..palette_count { + let b = src.read_u8(); + let g = src.read_u8(); + let r = src.read_u8(); + palette.push([b, g, r]); + } + + // Compute bit widths + let stop_index_bits = if palette_count <= 1 { + // Edge case: only 1 palette entry + 0 + } else { + bit_length(u32::from(palette_count - 1)) + }; + let suite_depth_bits = 8u8.saturating_sub(stop_index_bits); + + // Decode segments from remaining bytes + let mut segments = Vec::new(); + let remaining = src.len(); + + if stop_index_bits == 0 { + // Single palette entry: no stop/suite bits, only run lengths + // Each byte is a run length factor for palette[0] + decode_single_palette_segments(&mut src, &mut segments)?; + } else { + decode_multi_palette_segments(remaining, &mut src, stop_index_bits, suite_depth_bits, &mut segments)?; + } + + Ok(RlexData { palette, segments }) +} + +fn decode_single_palette_segments(src: &mut ReadCursor<'_>, segments: &mut Vec) -> DecodeResult<()> { + while !src.is_empty() { + let run_length = decode_run_length(src)?; + segments.push(RlexSegment { + start_index: 0, + stop_index: 0, + run_length, + }); + } + Ok(()) +} + +fn decode_multi_palette_segments( + _remaining: usize, + src: &mut ReadCursor<'_>, + stop_index_bits: u8, + suite_depth_bits: u8, + segments: &mut Vec, +) -> DecodeResult<()> { + let stop_mask = (1u8 << stop_index_bits) - 1; + let depth_mask = (1u8 << suite_depth_bits) - 1; + + while !src.is_empty() { + let packed = src.read_u8(); + let stop_index = packed & stop_mask; + let suite_depth = (packed >> stop_index_bits) & depth_mask; + + let start_index = stop_index.saturating_sub(suite_depth); + + let run_length = decode_run_length(src)?; + + segments.push(RlexSegment { + start_index, + stop_index, + run_length, + }); + } + + Ok(()) +} + +/// Decode a variable-length run length value. +/// Uses the same variable-length scheme as the residual layer. +fn decode_run_length(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(ctx: "RlexRunLength", in: src, size: 1); + let factor1 = src.read_u8(); + + if factor1 < 0xFF { + return Ok(u32::from(factor1)); + } + + ensure_size!(ctx: "RlexRunLength", in: src, size: 2); + let factor2 = src.read_u16(); + + if factor2 < 0xFFFF { + return Ok(u32::from(factor2)); + } + + ensure_size!(ctx: "RlexRunLength", in: src, size: 4); + Ok(src.read_u32()) +} + +/// Compute the number of bits needed to represent a value (floor(log2(n)) + 1). +fn bit_length(n: u32) -> u8 { + if n == 0 { + return 0; + } + // Result is 1..=32 for non-zero n, always fits in u8 + u8::try_from(32 - n.leading_zeros()).expect("bit length of u32 always fits in u8") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bit_length_values() { + assert_eq!(bit_length(0), 0); + assert_eq!(bit_length(1), 1); + assert_eq!(bit_length(2), 2); + assert_eq!(bit_length(3), 2); + assert_eq!(bit_length(4), 3); + assert_eq!(bit_length(7), 3); + assert_eq!(bit_length(126), 7); + } + + #[test] + fn decode_rlex_two_palette() { + // palette_count=2, palette=[black, white] + // stop_index_bits = bit_length(1) = 1 + // suite_depth_bits = 8 - 1 = 7 + let mut data = Vec::new(); + data.push(2); // palette_count + data.extend_from_slice(&[0x00, 0x00, 0x00]); // black BGR + data.extend_from_slice(&[0xFF, 0xFF, 0xFF]); // white BGR + // Segment: packed byte, stop_index=0 (1 bit), suite_depth=0 (7 bits), run=5 + data.push(0x00); // packed: stop=0, depth=0 + data.push(5); // run_length=5 + // Segment: stop_index=1, suite_depth=0, run=3 + data.push(0x01); // packed: stop=1, depth=0 + data.push(3); // run_length=3 + + let rlex = decode_rlex(&data).unwrap(); + assert_eq!(rlex.palette.len(), 2); + assert_eq!(rlex.segments.len(), 2); + assert_eq!(rlex.segments[0].stop_index, 0); + assert_eq!(rlex.segments[0].run_length, 5); + assert_eq!(rlex.segments[1].stop_index, 1); + assert_eq!(rlex.segments[1].run_length, 3); + } + + #[test] + fn reject_zero_palette() { + let data = [0x00]; // palette_count = 0 + assert!(decode_rlex(&data).is_err()); + } + + #[test] + fn reject_too_large_palette() { + let data = [128]; // palette_count = 128 > 127 + assert!(decode_rlex(&data).is_err()); + } +} diff --git a/crates/ironrdp-pdu/src/codecs/clearcodec/subcodec.rs b/crates/ironrdp-pdu/src/codecs/clearcodec/subcodec.rs new file mode 100644 index 0000000000..fcad5eeff5 --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/clearcodec/subcodec.rs @@ -0,0 +1,180 @@ +//! ClearCodec Layer 3: Subcodecs ([MS-RDPEGFX] 2.2.4.1.1.3). +//! +//! The subcodec layer encodes rectangular regions using one of three methods: +//! raw BGR pixels, NSCodec, or RLEX. Each subcodec region specifies its +//! position, dimensions, and the codec used to compress its bitmap data. + +use ironrdp_core::{DecodeResult, ReadCursor, cast_length, ensure_size, invalid_field_err}; + +/// Subcodec identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum SubcodecId { + /// Uncompressed BGR pixels. + Raw = 0x00, + /// NSCodec bitmap compression (MS-RDPNSC). + NsCodec = 0x01, + /// Palette-indexed RLE with gradient suite encoding. + Rlex = 0x02, +} + +impl SubcodecId { + fn from_u8(val: u8) -> DecodeResult { + match val { + 0x00 => Ok(Self::Raw), + 0x01 => Ok(Self::NsCodec), + 0x02 => Ok(Self::Rlex), + _ => Err(invalid_field_err!("subCodecId", "unknown subcodec ID")), + } + } +} + +/// A decoded subcodec region. +#[derive(Debug, Clone)] +pub struct Subcodec<'a> { + pub x_start: u16, + pub y_start: u16, + pub width: u16, + pub height: u16, + pub codec_id: SubcodecId, + /// Raw bitmap data for this region, interpreted according to `codec_id`. + pub bitmap_data: &'a [u8], +} + +impl Subcodec<'_> { + const NAME: &'static str = "ClearCodecSubcodec"; + + /// Header: 4 x u16 + u32 + u8 = 13 bytes. + const HEADER_SIZE: usize = 13; +} + +/// Decode all subcodec regions from the subcodec layer data. +pub fn decode_subcodec_layer<'a>(data: &'a [u8]) -> DecodeResult>> { + let mut regions = Vec::new(); + let mut src = ReadCursor::new(data); + + while src.len() >= Subcodec::HEADER_SIZE { + let region = decode_single_subcodec(&mut src)?; + regions.push(region); + } + + Ok(regions) +} + +fn decode_single_subcodec<'a>(src: &mut ReadCursor<'a>) -> DecodeResult> { + ensure_size!(ctx: Subcodec::NAME, in: src, size: Subcodec::HEADER_SIZE); + + let x_start = src.read_u16(); + let y_start = src.read_u16(); + let width = src.read_u16(); + let height = src.read_u16(); + let bitmap_data_byte_count: usize = cast_length!("bitmapDataByteCount", src.read_u32())?; + let codec_id_raw = src.read_u8(); + let codec_id = SubcodecId::from_u8(codec_id_raw)?; + + if width == 0 || height == 0 { + return Err(invalid_field_err!("dimensions", "subcodec region has zero dimension")); + } + + ensure_size!(ctx: Subcodec::NAME, in: src, size: bitmap_data_byte_count); + let bitmap_data = src.read_slice(bitmap_data_byte_count); + + Ok(Subcodec { + x_start, + y_start, + width, + height, + codec_id, + bitmap_data, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_raw_subcodec() { + // Region at (10, 20), 2x2 pixels, raw BGR = 12 bytes + let mut data = Vec::new(); + data.extend_from_slice(&10u16.to_le_bytes()); // x_start + data.extend_from_slice(&20u16.to_le_bytes()); // y_start + data.extend_from_slice(&2u16.to_le_bytes()); // width + data.extend_from_slice(&2u16.to_le_bytes()); // height + data.extend_from_slice(&12u32.to_le_bytes()); // bitmapDataByteCount = 2*2*3 = 12 + data.push(0x00); // subCodecId = Raw + // 4 pixels BGR + data.extend_from_slice(&[0xFF, 0x00, 0x00]); // blue + data.extend_from_slice(&[0x00, 0xFF, 0x00]); // green + data.extend_from_slice(&[0x00, 0x00, 0xFF]); // red + data.extend_from_slice(&[0xFF, 0xFF, 0xFF]); // white + + let regions = decode_subcodec_layer(&data).unwrap(); + assert_eq!(regions.len(), 1); + assert_eq!(regions[0].x_start, 10); + assert_eq!(regions[0].y_start, 20); + assert_eq!(regions[0].width, 2); + assert_eq!(regions[0].height, 2); + assert_eq!(regions[0].codec_id, SubcodecId::Raw); + assert_eq!(regions[0].bitmap_data.len(), 12); + } + + #[test] + fn reject_zero_dimensions() { + let mut data = Vec::new(); + data.extend_from_slice(&0u16.to_le_bytes()); // x_start + data.extend_from_slice(&0u16.to_le_bytes()); // y_start + data.extend_from_slice(&0u16.to_le_bytes()); // width = 0 (invalid) + data.extend_from_slice(&1u16.to_le_bytes()); // height + data.extend_from_slice(&0u32.to_le_bytes()); // bitmapDataByteCount + data.push(0x00); // subCodecId + assert!(decode_subcodec_layer(&data).is_err()); + } + + #[test] + fn reject_unknown_subcodec() { + let mut data = Vec::new(); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&0u32.to_le_bytes()); + data.push(0x03); // unknown subcodec + assert!(decode_subcodec_layer(&data).is_err()); + } + + #[test] + fn decode_multiple_subcodecs() { + let mut data = Vec::new(); + // First region: 1x1 raw + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&0u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&3u32.to_le_bytes()); + data.push(0x00); // Raw + data.extend_from_slice(&[0xFF, 0xFF, 0xFF]); + + // Second region: 1x1 RLEX (minimal: palette_count=1 + run) + data.extend_from_slice(&5u16.to_le_bytes()); + data.extend_from_slice(&5u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&1u16.to_le_bytes()); + data.extend_from_slice(&5u32.to_le_bytes()); + data.push(0x02); // RLEX + data.push(1); // palette_count + data.extend_from_slice(&[0x00, 0x00, 0x00]); // palette entry + data.push(1); // run_length + + let regions = decode_subcodec_layer(&data).unwrap(); + assert_eq!(regions.len(), 2); + assert_eq!(regions[0].codec_id, SubcodecId::Raw); + assert_eq!(regions[1].codec_id, SubcodecId::Rlex); + } + + #[test] + fn decode_empty_layer() { + let regions = decode_subcodec_layer(&[]).unwrap(); + assert!(regions.is_empty()); + } +} diff --git a/crates/ironrdp-pdu/src/codecs/mod.rs b/crates/ironrdp-pdu/src/codecs/mod.rs new file mode 100644 index 0000000000..df6e592c6f --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/mod.rs @@ -0,0 +1,2 @@ +pub mod clearcodec; +pub mod rfx; diff --git a/crates/ironrdp-pdu/src/codecs/rfx/data_messages.rs b/crates/ironrdp-pdu/src/codecs/rfx/data_messages.rs index 8e33da9a37..a9c2e9343f 100644 --- a/crates/ironrdp-pdu/src/codecs/rfx/data_messages.rs +++ b/crates/ironrdp-pdu/src/codecs/rfx/data_messages.rs @@ -1,11 +1,13 @@ +use core::iter; + use bit_field::BitField as _; use bitflags::bitflags; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, - ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use crate::codecs::rfx::Block; @@ -26,6 +28,7 @@ const RECTANGLE_SIZE: usize = 8; /// /// [2.2.2.2.4]: https://learn.microsoft.com/pt-br/openspecs/windows_protocols/ms-rdprfx/bde1ce78-5d9e-44c1-8a15-5843fa12270a #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ContextPdu { pub flags: OperatingMode, pub entropy_algorithm: EntropyAlgorithm, @@ -48,7 +51,7 @@ impl Encode for ContextPdu { properties.set_bits(0..3, self.flags.bits()); properties.set_bits(3..5, COLOR_CONVERSION_ICT); properties.set_bits(5..9, CLW_XFORM_DWT_53_A); - properties.set_bits(9..13, self.entropy_algorithm.to_u16().unwrap()); + properties.set_bits(9..13, self.entropy_algorithm.as_u16()); properties.set_bits(13..15, SCALAR_QUANTIZATION); properties.set_bit(15, false); // reserved dst.write_u16(properties); @@ -80,7 +83,7 @@ impl<'de> Decode<'de> for ContextPdu { } let properties = src.read_u16(); - let flags = OperatingMode::from_bits_truncate(properties.get_bits(0..3)); + let flags = OperatingMode::from_bits_retain(properties.get_bits(0..3)); let color_conversion_transform = properties.get_bits(3..5); if color_conversion_transform != COLOR_CONVERSION_ICT { return Err(invalid_field_err!("cct", "Invalid color conversion transform")); @@ -113,6 +116,7 @@ impl<'de> Decode<'de> for ContextPdu { /// /// [2.2.2.3.1]: https://learn.microsoft.com/pt-br/openspecs/windows_protocols/ms-rdprfx/7a938a26-3fc2-436b-bc84-09dfff59b5e7 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct FrameBeginPdu { pub index: u32, pub number_of_regions: i16, @@ -161,6 +165,7 @@ impl<'de> Decode<'de> for FrameBeginPdu { /// /// [2.2.2.3.1]: https://learn.microsoft.com/pt-br/openspecs/windows_protocols/ms-rdprfx/b4cb2676-0268-450b-ad32-72f66d0598e8 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct FrameEndPdu; impl FrameEndPdu { @@ -197,6 +202,7 @@ impl<'de> Decode<'de> for FrameEndPdu { /// /// [2.2.2.3.3]: https://learn.microsoft.com/pt-br/openspecs/windows_protocols/ms-rdprfx/23d2a1d6-1be0-4357-83eb-998b66ddd4d9 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct RegionPdu { pub rectangles: Vec, } @@ -249,8 +255,8 @@ impl<'de> Decode<'de> for RegionPdu { ensure_size!(in: src, size: number_of_rectangles * RECTANGLE_SIZE); - let rectangles = (0..number_of_rectangles) - .map(|_| RfxRectangle::decode(src)) + let rectangles = iter::repeat_with(|| RfxRectangle::decode(src)) + .take(number_of_rectangles) .collect::, _>>()?; ensure_size!(in: src, size: 4); @@ -273,6 +279,7 @@ impl<'de> Decode<'de> for RegionPdu { /// /// [2.2.2.3.4] https://learn.microsoft.com/pt-br/openspecs/windows_protocols/ms-rdprfx/7c926114-4bea-4c69-a9a1-caa6e88847a6 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct TileSetPdu<'a> { pub entropy_algorithm: EntropyAlgorithm, pub quants: Vec, @@ -297,12 +304,12 @@ impl Encode for TileSetPdu<'_> { properties.set_bits(1..4, OperatingMode::empty().bits()); // The decoder MUST ignore this flag properties.set_bits(4..6, COLOR_CONVERSION_ICT); properties.set_bits(6..10, CLW_XFORM_DWT_53_A); - properties.set_bits(10..14, self.entropy_algorithm.to_u16().unwrap()); + properties.set_bits(10..14, self.entropy_algorithm.as_u16()); properties.set_bits(14..16, SCALAR_QUANTIZATION); dst.write_u16(properties); dst.write_u8(cast_length!("numQuant", self.quants.len())?); - dst.write_u8(TILE_SIZE as u8); + dst.write_u8(u8::try_from(TILE_SIZE).expect("TILE_SIZE value fits into u8")); dst.write_u16(cast_length!("numTiles", self.tiles.len())?); let tiles_data_size = self.tiles.iter().map(|t| Block::Tile(t.clone()).size()).sum::(); @@ -381,15 +388,15 @@ impl<'de> Decode<'de> for TileSetPdu<'de> { return Err(invalid_field_err!("tile_size", "Invalid tile size")); } - let number_of_tiles = src.read_u16(); - let _tiles_data_size = src.read_u32() as usize; + let number_of_tiles = usize::from(src.read_u16()); + let _tiles_data_size = src.read_u32(); - let quants = (0..number_of_quants) - .map(|_| Quant::decode(src)) + let quants = iter::repeat_with(|| Quant::decode(src)) + .take(number_of_quants) .collect::, _>>()?; - let tiles = (0..number_of_tiles) - .map(|_| Block::decode(src)) + let tiles = iter::repeat_with(|| Block::decode(src)) + .take(number_of_tiles) .collect::, _>>()?; let tiles = tiles @@ -411,6 +418,7 @@ impl<'de> Decode<'de> for TileSetPdu<'de> { /// /// [2.2.2.1.6]: https://learn.microsoft.com/pt-br/openspecs/windows_protocols/ms-rdprfx/26eb819a-955b-4b08-b3a0-997231170059 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct RfxRectangle { pub x: u16, pub y: u16, @@ -462,6 +470,7 @@ impl<'de> Decode<'de> for RfxRectangle { /// /// [2.2.2.1.5]: https://learn.microsoft.com/pt-br/openspecs/windows_protocols/ms-rdprfx/3e9c8af4-7539-4c9d-95de-14b1558b902c #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Quant { pub ll3: u8, pub lh3: u8, @@ -542,24 +551,32 @@ impl Encode for Quant { impl<'de> Decode<'de> for Quant { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - #![allow(clippy::similar_names)] // It’s hard to do better than ll3, lh3, etc without going overly verbose. + #![allow( + clippy::similar_names, + reason = "it’s hard to do better than ll3, lh3, etc without going overly verbose" + )] + ensure_fixed_part_size!(in: src); - let level3 = src.read_u16(); - let ll3 = level3.get_bits(0..4) as u8; - let lh3 = level3.get_bits(4..8) as u8; - let hl3 = level3.get_bits(8..12) as u8; - let hh3 = level3.get_bits(12..16) as u8; + let ll3lh3 = src.read_u8(); + let ll3 = ll3lh3.get_bits(0..4); + let lh3 = ll3lh3.get_bits(4..8); + + let hl3hh3 = src.read_u8(); + let hl3 = hl3hh3.get_bits(0..4); + let hh3 = hl3hh3.get_bits(4..8); + + let lh2hl2 = src.read_u8(); + let lh2 = lh2hl2.get_bits(0..4); + let hl2 = lh2hl2.get_bits(4..8); - let level2_with_lh1 = src.read_u16(); - let lh2 = level2_with_lh1.get_bits(0..4) as u8; - let hl2 = level2_with_lh1.get_bits(4..8) as u8; - let hh2 = level2_with_lh1.get_bits(8..12) as u8; - let lh1 = level2_with_lh1.get_bits(12..16) as u8; + let hh2lh1 = src.read_u8(); + let hh2 = hh2lh1.get_bits(0..4); + let lh1 = hh2lh1.get_bits(4..8); - let level1 = src.read_u8(); - let hl1 = level1.get_bits(0..4); - let hh1 = level1.get_bits(4..8); + let hl1hh1 = src.read_u8(); + let hl1 = hl1hh1.get_bits(0..4); + let hh1 = hl1hh1.get_bits(4..8); Ok(Self { ll3, @@ -579,6 +596,7 @@ impl<'de> Decode<'de> for Quant { /// /// [2.2.2.3.4.1]: https://learn.microsoft.com/pt-br/openspecs/windows_protocols/ms-rdprfx/89e669ed-b6dd-4591-a267-73a72bc6d84e #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Tile<'a> { pub y_quant_index: u8, pub cb_quant_index: u8, @@ -666,16 +684,30 @@ impl<'de> Decode<'de> for Tile<'de> { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[repr(u16)] pub enum EntropyAlgorithm { Rlgr1 = 0x01, Rlgr3 = 0x04, } +impl EntropyAlgorithm { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct OperatingMode: u16 { const IMAGE_MODE = 0x02; // if not set, the codec is operating in video mode + + const _ = !0; } } diff --git a/crates/ironrdp-pdu/src/codecs/rfx/header_messages.rs b/crates/ironrdp-pdu/src/codecs/rfx/header_messages.rs index cc0841b1e4..f27cbbeb5d 100644 --- a/crates/ironrdp-pdu/src/codecs/rfx/header_messages.rs +++ b/crates/ironrdp-pdu/src/codecs/rfx/header_messages.rs @@ -1,6 +1,6 @@ use ironrdp_core::{ - cast_length, ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + invalid_field_err, }; const SYNC_MAGIC: u32 = 0xCACC_ACCA; @@ -14,6 +14,7 @@ const CHANNEL_ID: u8 = 0; // // [2.2.2.2.1]: https://learn.microsoft.com/pt-br/openspecs/windows_protocols/ms-rdprfx/f01b81b6-1a8f-49fd-9543-081fbc8e1831 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SyncPdu; impl SyncPdu { @@ -62,6 +63,7 @@ impl<'de> Decode<'de> for SyncPdu { /// /// [2.2.2.2.2]: https://learn.microsoft.com/pt-br/openspecs/windows_protocols/ms-rdprfx/2650e6c2-faf7-4858-b169-828db842b663 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct CodecVersionsPdu; impl CodecVersionsPdu { @@ -108,6 +110,7 @@ impl<'de> Decode<'de> for CodecVersionsPdu { /// /// [2.2.2.2.3]: https://learn.microsoft.com/pt-br/openspecs/windows_protocols/ms-rdprfx/c6efba0b-f59e-4d8e-8d76-840c41edce5b #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ChannelsPdu(pub Vec); impl ChannelsPdu { @@ -141,9 +144,9 @@ impl<'de> Decode<'de> for ChannelsPdu { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let num_channels = src.read_u8(); - let channels = (0..num_channels) - .map(|_| RfxChannel::decode(src)) + let num_channels = usize::from(src.read_u8()); + let channels = core::iter::repeat_with(|| RfxChannel::decode(src)) + .take(num_channels) .collect::>>()?; Ok(Self(channels)) @@ -154,6 +157,7 @@ impl<'de> Decode<'de> for ChannelsPdu { /// /// [2.2.2.1.3]: https://learn.microsoft.com/pt-br/openspecs/windows_protocols/ms-rdprfx/4060f07e-9d73-454d-841e-131a93aca675 #[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct RfxChannel { pub width: i16, pub height: i16, diff --git a/crates/ironrdp-pdu/src/codecs/rfx.rs b/crates/ironrdp-pdu/src/codecs/rfx/mod.rs similarity index 90% rename from crates/ironrdp-pdu/src/codecs/rfx.rs rename to crates/ironrdp-pdu/src/codecs/rfx/mod.rs index 32a279d006..fec0a4b747 100644 --- a/crates/ironrdp-pdu/src/codecs/rfx.rs +++ b/crates/ironrdp-pdu/src/codecs/rfx/mod.rs @@ -1,12 +1,13 @@ mod data_messages; mod header_messages; +pub mod progressive; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, - ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use crate::rdp::capability_sets::{RfxCaps, RfxCapset}; @@ -22,6 +23,7 @@ const CHANNEL_ID_FOR_CONTEXT: u8 = 0xFF; const CHANNEL_ID_FOR_OTHER_VALUES: u8 = 0x00; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum Block<'a> { Tile(Tile<'a>), Caps(RfxCaps), @@ -62,7 +64,7 @@ impl Encode for Block<'_> { let data_length = self.size(); BlockHeader { ty, data_length }.encode(dst)?; - if let Block::CodecChannel(ref c) = self { + if let Block::CodecChannel(c) = self { let channel_id = c.channel_id(); CodecChannelHeader { channel_id }.encode(dst)?; } @@ -150,6 +152,7 @@ impl<'de> Decode<'de> for Block<'de> { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum CodecChannel<'a> { Context(ContextPdu), FrameBegin(FrameBeginPdu), @@ -172,6 +175,7 @@ impl CodecChannel<'_> { /// /// [2.2.2.1.1]: https://learn.microsoft.com/pt-br/openspecs/windows_protocols/ms-rdprfx/1e1b69a9-c2aa-4b13-bd44-23dcf96d4a74 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BlockHeader { pub ty: BlockType, pub data_length: usize, @@ -187,7 +191,7 @@ impl Encode for BlockHeader { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u16(self.ty.to_u16().unwrap()); + dst.write_u16(self.ty.as_u16()); dst.write_u32(cast_length!("data len", self.data_length)?); Ok(()) @@ -208,7 +212,7 @@ impl<'de> Decode<'de> for BlockHeader { let ty = src.read_u16(); let ty = BlockType::from_u16(ty).ok_or_else(|| invalid_field_err!("blockType", "Invalid block type"))?; - let data_length = src.read_u32() as usize; + let data_length: usize = cast_length!("block length", src.read_u32())?; data_length .checked_sub(Self::FIXED_PART_SIZE) .ok_or_else(|| invalid_field_err!("blockLen", "Invalid block length"))?; @@ -221,6 +225,7 @@ impl<'de> Decode<'de> for BlockHeader { /// /// [2.2.2.1.2]: https://learn.microsoft.com/pt-br/openspecs/windows_protocols/ms-rdprfx/56b78b0c-6eef-40cc-b9da-96d21f197c14 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct CodecChannelHeader { channel_id: u8, } @@ -269,6 +274,7 @@ impl Decode<'_> for CodecChannelHeader { /// /// [2.2.3.1]: https://learn.microsoft.com/pt-br/openspecs/windows_protocols/ms-rdprfx/24364aa2-9a7f-4d86-bcfb-67f5a6c19064 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct FrameAcknowledgePdu { pub frame_id: u32, } @@ -307,7 +313,8 @@ impl<'de> Decode<'de> for FrameAcknowledgePdu { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[repr(u16)] pub enum BlockType { Tile = 0xCAC3, @@ -330,4 +337,12 @@ impl BlockType { BlockType::Context | BlockType::FrameBegin | BlockType::FrameEnd | BlockType::Region | BlockType::Extension ) } + + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } } diff --git a/crates/ironrdp-pdu/src/codecs/rfx/progressive.rs b/crates/ironrdp-pdu/src/codecs/rfx/progressive.rs new file mode 100644 index 0000000000..271fa94502 --- /dev/null +++ b/crates/ironrdp-pdu/src/codecs/rfx/progressive.rs @@ -0,0 +1,1409 @@ +//! RemoteFX Progressive Codec wire types ([MS-RDPEGFX] 2.2.4.2). +//! +//! The progressive codec delivers multi-pass bitmap updates via +//! `WireToSurface2Pdu` (codecId 0x0009). Tiles start at coarse quality +//! and refine over successive upgrade passes. +//! +//! Block types share a 6-byte header: `blockType(u16) + blockLen(u32)`. +//! The payload of a `WireToSurface2Pdu.bitmapData` is a sequence of these +//! blocks forming a progressive bitmap stream. + +use core::iter; + +use ironrdp_core::{ + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, +}; + +use super::RfxRectangle; + +// Wire constants +const SYNC_MAGIC: u32 = 0xCACCACCA; +const SYNC_VERSION: u16 = 0x0100; +const TILE_SIZE: u16 = 0x0040; +/// Block header size as u32 for checked_sub arithmetic (avoids `as` cast). +const BLOCK_HEADER_SIZE_U32: u32 = 6; + +/// Progressive block type discriminator. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[repr(u16)] +pub enum ProgressiveBlockType { + Sync = 0xCCC0, + FrameBegin = 0xCCC1, + FrameEnd = 0xCCC2, + Context = 0xCCC3, + Region = 0xCCC4, + TileSimple = 0xCCC5, + TileFirst = 0xCCC6, + TileUpgrade = 0xCCC7, +} + +impl ProgressiveBlockType { + fn from_u16(val: u16) -> Option { + match val { + 0xCCC0 => Some(Self::Sync), + 0xCCC1 => Some(Self::FrameBegin), + 0xCCC2 => Some(Self::FrameEnd), + 0xCCC3 => Some(Self::Context), + 0xCCC4 => Some(Self::Region), + 0xCCC5 => Some(Self::TileSimple), + 0xCCC6 => Some(Self::TileFirst), + 0xCCC7 => Some(Self::TileUpgrade), + _ => None, + } + } + + #[expect( + clippy::as_conversions, + reason = "repr(u16) discriminant cast is the canonical pattern" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + +/// 6-byte block header shared by all progressive blocks. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct ProgressiveBlockHeader { + pub block_type: ProgressiveBlockType, + pub block_len: u32, +} + +impl ProgressiveBlockHeader { + const NAME: &'static str = "ProgressiveBlockHeader"; + pub const SIZE: usize = 6; + const FIXED_PART_SIZE: usize = Self::SIZE; +} + +impl Encode for ProgressiveBlockHeader { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + dst.write_u16(self.block_type.as_u16()); + dst.write_u32(self.block_len); + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::SIZE + } +} + +impl Decode<'_> for ProgressiveBlockHeader { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + let raw = src.read_u16(); + let block_type = ProgressiveBlockType::from_u16(raw) + .ok_or_else(|| invalid_field_err!("blockType", "unknown progressive block type"))?; + let block_len = src.read_u32(); + Ok(Self { block_type, block_len }) + } +} + +// --------------------------------------------------------------------------- +// Quantization types (progressive nibble order) +// --------------------------------------------------------------------------- + +/// Per-component quantization values with the progressive nibble packing. +/// +/// The progressive codec swaps HL/LH at each level compared to classic RFX: +/// Classic: LL3, LH3, HL3, HH3, LH2, HL2, HH2, LH1, HL1, HH1 +/// Progressive: LL3, HL3, LH3, HH3, HL2, LH2, HH2, HL1, LH1, HH1 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct ComponentCodecQuant { + pub ll3: u8, + pub hl3: u8, + pub lh3: u8, + pub hh3: u8, + pub hl2: u8, + pub lh2: u8, + pub hh2: u8, + pub hl1: u8, + pub lh1: u8, + pub hh1: u8, +} + +impl ComponentCodecQuant { + const NAME: &'static str = "ComponentCodecQuant"; + /// 10 nibbles packed into 5 bytes. + pub const SIZE: usize = 5; + const FIXED_PART_SIZE: usize = Self::SIZE; + + /// All-zero quant (no extra quantization, full quality). + pub const LOSSLESS: Self = Self { + ll3: 0, + hl3: 0, + lh3: 0, + hh3: 0, + hl2: 0, + lh2: 0, + hh2: 0, + hl1: 0, + lh1: 0, + hh1: 0, + }; + + /// Return the quantization value for a given subband index (0..9). + /// Band order: HL1, LH1, HH1, HL2, LH2, HH2, HL3, LH3, HH3, LL3 + pub fn for_band(&self, band_idx: usize) -> u8 { + match band_idx { + 0 => self.hl1, + 1 => self.lh1, + 2 => self.hh1, + 3 => self.hl2, + 4 => self.lh2, + 5 => self.hh2, + 6 => self.hl3, + 7 => self.lh3, + 8 => self.hh3, + 9 => self.ll3, + _ => 0, + } + } +} + +impl Encode for ComponentCodecQuant { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + // Progressive nibble order: LL3|HL3, LH3|HH3, HL2|LH2, HH2|HL1, LH1|HH1 + dst.write_u8(self.ll3 | (self.hl3 << 4)); + dst.write_u8(self.lh3 | (self.hh3 << 4)); + dst.write_u8(self.hl2 | (self.lh2 << 4)); + dst.write_u8(self.hh2 | (self.hl1 << 4)); + dst.write_u8(self.lh1 | (self.hh1 << 4)); + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::SIZE + } +} + +impl Decode<'_> for ComponentCodecQuant { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + let b0 = src.read_u8(); + let b1 = src.read_u8(); + let b2 = src.read_u8(); + let b3 = src.read_u8(); + let b4 = src.read_u8(); + Ok(Self { + ll3: b0 & 0x0F, + hl3: b0 >> 4, + lh3: b1 & 0x0F, + hh3: b1 >> 4, + hl2: b2 & 0x0F, + lh2: b2 >> 4, + hh2: b3 & 0x0F, + hl1: b3 >> 4, + lh1: b4 & 0x0F, + hh1: b4 >> 4, + }) + } +} + +/// Per-quality-level progressive quantization: quality byte + 3 component quants. +/// +/// `quality` ranges from 0 (minimum) to 0xFF (full quality / no extra quantization). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct ProgressiveCodecQuant { + pub quality: u8, + pub y_quant: ComponentCodecQuant, + pub cb_quant: ComponentCodecQuant, + pub cr_quant: ComponentCodecQuant, +} + +impl ProgressiveCodecQuant { + const NAME: &'static str = "ProgressiveCodecQuant"; + /// 1 byte quality + 3 x 5 bytes = 16 bytes. + pub const SIZE: usize = 16; + const FIXED_PART_SIZE: usize = Self::SIZE; +} + +impl Encode for ProgressiveCodecQuant { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + dst.write_u8(self.quality); + self.y_quant.encode(dst)?; + self.cb_quant.encode(dst)?; + self.cr_quant.encode(dst)?; + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::SIZE + } +} + +impl Decode<'_> for ProgressiveCodecQuant { + #[expect(clippy::similar_names, reason = "y/cb/cr quant names follow spec terminology")] + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + let quality = src.read_u8(); + let y_quant = ComponentCodecQuant::decode(src)?; + let cb_quant = ComponentCodecQuant::decode(src)?; + let cr_quant = ComponentCodecQuant::decode(src)?; + Ok(Self { + quality, + y_quant, + cb_quant, + cr_quant, + }) + } +} + +// --------------------------------------------------------------------------- +// Individual block types +// --------------------------------------------------------------------------- + +/// RFX_PROGRESSIVE_SYNC: magic 0xCACCACCA + version 0x0100. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct ProgressiveSyncPdu; + +impl ProgressiveSyncPdu { + const NAME: &'static str = "ProgressiveSync"; + const FIXED_PART_SIZE: usize = 4 /* magic */ + 2 /* version */; +} + +impl Encode for ProgressiveSyncPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + dst.write_u32(SYNC_MAGIC); + dst.write_u16(SYNC_VERSION); + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl Decode<'_> for ProgressiveSyncPdu { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + let magic = src.read_u32(); + if magic != SYNC_MAGIC { + return Err(invalid_field_err!("magic", "invalid progressive sync magic")); + } + let version = src.read_u16(); + if version != SYNC_VERSION { + return Err(invalid_field_err!("version", "unsupported progressive version")); + } + Ok(Self) + } +} + +/// RFX_PROGRESSIVE_FRAME_BEGIN. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct ProgressiveFrameBeginPdu { + pub frame_index: u32, + pub region_count: u16, +} + +impl ProgressiveFrameBeginPdu { + const NAME: &'static str = "ProgressiveFrameBegin"; + const FIXED_PART_SIZE: usize = 4 /* frameIndex */ + 2 /* regionCount */; +} + +impl Encode for ProgressiveFrameBeginPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + dst.write_u32(self.frame_index); + dst.write_u16(self.region_count); + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl Decode<'_> for ProgressiveFrameBeginPdu { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + let frame_index = src.read_u32(); + let region_count = src.read_u16(); + Ok(Self { + frame_index, + region_count, + }) + } +} + +/// RFX_PROGRESSIVE_FRAME_END (empty body). +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct ProgressiveFrameEndPdu; + +impl ProgressiveFrameEndPdu { + const NAME: &'static str = "ProgressiveFrameEnd"; + const FIXED_PART_SIZE: usize = 0; +} + +impl Encode for ProgressiveFrameEndPdu { + fn encode(&self, _dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl Decode<'_> for ProgressiveFrameEndPdu { + fn decode(_src: &mut ReadCursor<'_>) -> DecodeResult { + Ok(Self) + } +} + +/// RFX_PROGRESSIVE_CONTEXT. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct ProgressiveContextPdu { + pub context_id: u8, + pub tile_size: u16, + pub flags: u8, +} + +/// Bit 0 of context flags: use reduce-extrapolate DWT. +pub const FLAG_DWT_REDUCE_EXTRAPOLATE: u8 = 0x01; + +impl ProgressiveContextPdu { + const NAME: &'static str = "ProgressiveContext"; + const FIXED_PART_SIZE: usize = 1 /* ctxId */ + 2 /* tileSize */ + 1 /* flags */; + + /// Whether the reduce-extrapolate DWT variant is selected. + pub fn uses_reduce_extrapolate(&self) -> bool { + self.flags & FLAG_DWT_REDUCE_EXTRAPOLATE != 0 + } +} + +impl Encode for ProgressiveContextPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + dst.write_u8(self.context_id); + dst.write_u16(self.tile_size); + dst.write_u8(self.flags); + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl Decode<'_> for ProgressiveContextPdu { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + let context_id = src.read_u8(); + let tile_size = src.read_u16(); + if tile_size != TILE_SIZE { + return Err(invalid_field_err!("tileSize", "only 64x64 tiles supported")); + } + let flags = src.read_u8(); + Ok(Self { + context_id, + tile_size, + flags, + }) + } +} + +// --------------------------------------------------------------------------- +// Tile blocks +// --------------------------------------------------------------------------- + +/// TILE_SIMPLE: non-progressive full-quality tile (single pass). +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct TileSimple<'a> { + pub quant_idx_y: u8, + pub quant_idx_cb: u8, + pub quant_idx_cr: u8, + pub x_idx: u16, + pub y_idx: u16, + pub flags: u8, + pub y_data: &'a [u8], + pub cb_data: &'a [u8], + pub cr_data: &'a [u8], + pub tail_data: &'a [u8], +} + +impl TileSimple<'_> { + const NAME: &'static str = "TileSimple"; + /// Fixed header: 3 quant idx + 2 x_idx + 2 y_idx + 1 flags + 4x2 lengths = 16 bytes. + const HEADER_SIZE: usize = 3 + 2 + 2 + 1 + 8; +} + +impl Encode for TileSimple<'_> { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + dst.write_u8(self.quant_idx_y); + dst.write_u8(self.quant_idx_cb); + dst.write_u8(self.quant_idx_cr); + dst.write_u16(self.x_idx); + dst.write_u16(self.y_idx); + dst.write_u8(self.flags); + dst.write_u16(cast_length!("yLen", self.y_data.len())?); + dst.write_u16(cast_length!("cbLen", self.cb_data.len())?); + dst.write_u16(cast_length!("crLen", self.cr_data.len())?); + dst.write_u16(cast_length!("tailLen", self.tail_data.len())?); + dst.write_slice(self.y_data); + dst.write_slice(self.cb_data); + dst.write_slice(self.cr_data); + dst.write_slice(self.tail_data); + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::HEADER_SIZE + self.y_data.len() + self.cb_data.len() + self.cr_data.len() + self.tail_data.len() + } +} + +impl<'de> Decode<'de> for TileSimple<'de> { + #[expect(clippy::similar_names, reason = "y/cb/cr quant and length names follow spec")] + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_size!(ctx: Self::NAME, in: src, size: Self::HEADER_SIZE); + let quant_idx_y = src.read_u8(); + let quant_idx_cb = src.read_u8(); + let quant_idx_cr = src.read_u8(); + let x_idx = src.read_u16(); + let y_idx = src.read_u16(); + let flags = src.read_u8(); + let y_len = usize::from(src.read_u16()); + let cb_len = usize::from(src.read_u16()); + let cr_len = usize::from(src.read_u16()); + let tail_len = usize::from(src.read_u16()); + + let total = y_len + cb_len + cr_len + tail_len; + ensure_size!(ctx: Self::NAME, in: src, size: total); + let y_data = src.read_slice(y_len); + let cb_data = src.read_slice(cb_len); + let cr_data = src.read_slice(cr_len); + let tail_data = src.read_slice(tail_len); + + Ok(Self { + quant_idx_y, + quant_idx_cb, + quant_idx_cr, + x_idx, + y_idx, + flags, + y_data, + cb_data, + cr_data, + tail_data, + }) + } +} + +/// TILE_FIRST: first progressive pass (coarse quality). +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct TileFirst<'a> { + pub quant_idx_y: u8, + pub quant_idx_cb: u8, + pub quant_idx_cr: u8, + pub x_idx: u16, + pub y_idx: u16, + pub flags: u8, + pub quality: u8, + pub y_data: &'a [u8], + pub cb_data: &'a [u8], + pub cr_data: &'a [u8], + pub tail_data: &'a [u8], +} + +impl TileFirst<'_> { + const NAME: &'static str = "TileFirst"; + /// Same as TileSimple + 1 byte for quality = 17 bytes. + const HEADER_SIZE: usize = 3 + 2 + 2 + 1 + 1 + 8; +} + +impl Encode for TileFirst<'_> { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + dst.write_u8(self.quant_idx_y); + dst.write_u8(self.quant_idx_cb); + dst.write_u8(self.quant_idx_cr); + dst.write_u16(self.x_idx); + dst.write_u16(self.y_idx); + dst.write_u8(self.flags); + dst.write_u8(self.quality); + dst.write_u16(cast_length!("yLen", self.y_data.len())?); + dst.write_u16(cast_length!("cbLen", self.cb_data.len())?); + dst.write_u16(cast_length!("crLen", self.cr_data.len())?); + dst.write_u16(cast_length!("tailLen", self.tail_data.len())?); + dst.write_slice(self.y_data); + dst.write_slice(self.cb_data); + dst.write_slice(self.cr_data); + dst.write_slice(self.tail_data); + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::HEADER_SIZE + self.y_data.len() + self.cb_data.len() + self.cr_data.len() + self.tail_data.len() + } +} + +impl<'de> Decode<'de> for TileFirst<'de> { + #[expect(clippy::similar_names, reason = "y/cb/cr quant and length names follow spec")] + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_size!(ctx: Self::NAME, in: src, size: Self::HEADER_SIZE); + let quant_idx_y = src.read_u8(); + let quant_idx_cb = src.read_u8(); + let quant_idx_cr = src.read_u8(); + let x_idx = src.read_u16(); + let y_idx = src.read_u16(); + let flags = src.read_u8(); + let quality = src.read_u8(); + let y_len = usize::from(src.read_u16()); + let cb_len = usize::from(src.read_u16()); + let cr_len = usize::from(src.read_u16()); + let tail_len = usize::from(src.read_u16()); + + let total = y_len + cb_len + cr_len + tail_len; + ensure_size!(ctx: Self::NAME, in: src, size: total); + let y_data = src.read_slice(y_len); + let cb_data = src.read_slice(cb_len); + let cr_data = src.read_slice(cr_len); + let tail_data = src.read_slice(tail_len); + + Ok(Self { + quant_idx_y, + quant_idx_cb, + quant_idx_cr, + x_idx, + y_idx, + flags, + quality, + y_data, + cb_data, + cr_data, + tail_data, + }) + } +} + +/// TILE_UPGRADE: progressive refinement pass. +/// +/// Each component has separate SRL and raw data streams. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct TileUpgrade<'a> { + pub quant_idx_y: u8, + pub quant_idx_cb: u8, + pub quant_idx_cr: u8, + pub x_idx: u16, + pub y_idx: u16, + pub quality: u8, + pub y_srl_data: &'a [u8], + pub y_raw_data: &'a [u8], + pub cb_srl_data: &'a [u8], + pub cb_raw_data: &'a [u8], + pub cr_srl_data: &'a [u8], + pub cr_raw_data: &'a [u8], +} + +impl TileUpgrade<'_> { + const NAME: &'static str = "TileUpgrade"; + /// 3 quant + 2 x + 2 y + 1 quality + 6x2 lengths = 20 bytes. + const HEADER_SIZE: usize = 3 + 2 + 2 + 1 + 12; +} + +impl Encode for TileUpgrade<'_> { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + dst.write_u8(self.quant_idx_y); + dst.write_u8(self.quant_idx_cb); + dst.write_u8(self.quant_idx_cr); + dst.write_u16(self.x_idx); + dst.write_u16(self.y_idx); + dst.write_u8(self.quality); + dst.write_u16(cast_length!("ySrlLen", self.y_srl_data.len())?); + dst.write_u16(cast_length!("yRawLen", self.y_raw_data.len())?); + dst.write_u16(cast_length!("cbSrlLen", self.cb_srl_data.len())?); + dst.write_u16(cast_length!("cbRawLen", self.cb_raw_data.len())?); + dst.write_u16(cast_length!("crSrlLen", self.cr_srl_data.len())?); + dst.write_u16(cast_length!("crRawLen", self.cr_raw_data.len())?); + dst.write_slice(self.y_srl_data); + dst.write_slice(self.y_raw_data); + dst.write_slice(self.cb_srl_data); + dst.write_slice(self.cb_raw_data); + dst.write_slice(self.cr_srl_data); + dst.write_slice(self.cr_raw_data); + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::HEADER_SIZE + + self.y_srl_data.len() + + self.y_raw_data.len() + + self.cb_srl_data.len() + + self.cb_raw_data.len() + + self.cr_srl_data.len() + + self.cr_raw_data.len() + } +} + +impl<'de> Decode<'de> for TileUpgrade<'de> { + #[expect(clippy::similar_names, reason = "SRL/raw per component is inherently similar")] + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_size!(ctx: Self::NAME, in: src, size: Self::HEADER_SIZE); + let quant_idx_y = src.read_u8(); + let quant_idx_cb = src.read_u8(); + let quant_idx_cr = src.read_u8(); + let x_idx = src.read_u16(); + let y_idx = src.read_u16(); + let quality = src.read_u8(); + let y_srl_len = usize::from(src.read_u16()); + let y_raw_len = usize::from(src.read_u16()); + let cb_srl_len = usize::from(src.read_u16()); + let cb_raw_len = usize::from(src.read_u16()); + let cr_srl_len = usize::from(src.read_u16()); + let cr_raw_len = usize::from(src.read_u16()); + + let total = y_srl_len + y_raw_len + cb_srl_len + cb_raw_len + cr_srl_len + cr_raw_len; + ensure_size!(ctx: Self::NAME, in: src, size: total); + let y_srl_data = src.read_slice(y_srl_len); + let y_raw_data = src.read_slice(y_raw_len); + let cb_srl_data = src.read_slice(cb_srl_len); + let cb_raw_data = src.read_slice(cb_raw_len); + let cr_srl_data = src.read_slice(cr_srl_len); + let cr_raw_data = src.read_slice(cr_raw_len); + + Ok(Self { + quant_idx_y, + quant_idx_cb, + quant_idx_cr, + x_idx, + y_idx, + quality, + y_srl_data, + y_raw_data, + cb_srl_data, + cb_raw_data, + cr_srl_data, + cr_raw_data, + }) + } +} + +// --------------------------------------------------------------------------- +// Region container +// --------------------------------------------------------------------------- + +/// A progressive tile: one of the three tile block types. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub enum ProgressiveTile<'a> { + Simple(TileSimple<'a>), + First(TileFirst<'a>), + Upgrade(TileUpgrade<'a>), +} + +impl ProgressiveTile<'_> { + pub fn x_idx(&self) -> u16 { + match self { + Self::Simple(t) => t.x_idx, + Self::First(t) => t.x_idx, + Self::Upgrade(t) => t.x_idx, + } + } + + pub fn y_idx(&self) -> u16 { + match self { + Self::Simple(t) => t.y_idx, + Self::First(t) => t.y_idx, + Self::Upgrade(t) => t.y_idx, + } + } +} + +/// RFX_PROGRESSIVE_REGION: the main container holding rects, quant tables, and tiles. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct ProgressiveRegion<'a> { + pub tile_size: u8, + pub rects: Vec, + pub quant_vals: Vec, + pub quant_prog_vals: Vec, + pub flags: u8, + pub tiles: Vec>, +} + +impl ProgressiveRegion<'_> { + const NAME: &'static str = "ProgressiveRegion"; + /// tileSize(1) + numRects(2) + numQuant(1) + numProgQuant(1) + /// + flags(1) + numTiles(2) + tileDataSize(4) = 12 bytes. + const HEADER_SIZE: usize = 12; + + /// Whether this region uses the reduce-extrapolate DWT variant. + pub fn uses_reduce_extrapolate(&self) -> bool { + self.flags & FLAG_DWT_REDUCE_EXTRAPOLATE != 0 + } +} + +impl Encode for ProgressiveRegion<'_> { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u8(self.tile_size); + dst.write_u16(cast_length!("numRects", self.rects.len())?); + dst.write_u8(cast_length!("numQuant", self.quant_vals.len())?); + dst.write_u8(cast_length!("numProgQuant", self.quant_prog_vals.len())?); + dst.write_u8(self.flags); + dst.write_u16(cast_length!("numTiles", self.tiles.len())?); + + // Compute tile data size (sum of block header + tile body for each tile) + let tile_data_size: usize = self + .tiles + .iter() + .map(|t| { + ProgressiveBlockHeader::SIZE + + match t { + ProgressiveTile::Simple(s) => s.size(), + ProgressiveTile::First(f) => f.size(), + ProgressiveTile::Upgrade(u) => u.size(), + } + }) + .sum(); + dst.write_u32(cast_length!("tileDataSize", tile_data_size)?); + + for rect in &self.rects { + rect.encode(dst)?; + } + for qv in &self.quant_vals { + qv.encode(dst)?; + } + for qpv in &self.quant_prog_vals { + qpv.encode(dst)?; + } + + // Each tile is wrapped in a block header + for tile in &self.tiles { + let (block_type, body_size) = match tile { + ProgressiveTile::Simple(s) => (ProgressiveBlockType::TileSimple, s.size()), + ProgressiveTile::First(f) => (ProgressiveBlockType::TileFirst, f.size()), + ProgressiveTile::Upgrade(u) => (ProgressiveBlockType::TileUpgrade, u.size()), + }; + let block_len: u32 = cast_length!("tileBlockLen", ProgressiveBlockHeader::SIZE + body_size)?; + let header = ProgressiveBlockHeader { block_type, block_len }; + header.encode(dst)?; + match tile { + ProgressiveTile::Simple(s) => s.encode(dst)?, + ProgressiveTile::First(f) => f.encode(dst)?, + ProgressiveTile::Upgrade(u) => u.encode(dst)?, + } + } + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + let rect_size: usize = self.rects.iter().map(Encode::size).sum(); + let quant_size = self.quant_vals.len() * ComponentCodecQuant::SIZE; + let prog_quant_size = self.quant_prog_vals.len() * ProgressiveCodecQuant::SIZE; + let tile_data_size: usize = self + .tiles + .iter() + .map(|t| { + ProgressiveBlockHeader::SIZE + + match t { + ProgressiveTile::Simple(s) => s.size(), + ProgressiveTile::First(f) => f.size(), + ProgressiveTile::Upgrade(u) => u.size(), + } + }) + .sum(); + Self::HEADER_SIZE + rect_size + quant_size + prog_quant_size + tile_data_size + } +} + +impl<'de> Decode<'de> for ProgressiveRegion<'de> { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_size!(ctx: Self::NAME, in: src, size: Self::HEADER_SIZE); + + let tile_size = src.read_u8(); + if tile_size != 0x40 { + return Err(invalid_field_err!("tileSize", "only 64x64 tiles supported")); + } + + let num_rects = usize::from(src.read_u16()); + let num_quant = usize::from(src.read_u8()); + let num_prog_quant = usize::from(src.read_u8()); + let flags = src.read_u8(); + + if num_rects == 0 { + return Err(invalid_field_err!( + "numRects", + "region must contain at least one rectangle" + )); + } + if num_quant > 7 { + return Err(invalid_field_err!("numQuant", "quant count exceeds maximum of 7")); + } + let num_tiles = usize::from(src.read_u16()); + let _tile_data_size = src.read_u32(); + + // Rectangles (4 x u16 = 8 bytes each) + const RFX_RECT_SIZE: usize = 8; + ensure_size!(ctx: Self::NAME, in: src, size: num_rects * RFX_RECT_SIZE); + let rects = iter::repeat_with(|| RfxRectangle::decode(src)) + .take(num_rects) + .collect::, _>>()?; + + // Base quantization values + ensure_size!(ctx: Self::NAME, in: src, size: num_quant * ComponentCodecQuant::SIZE); + let quant_vals = iter::repeat_with(|| ComponentCodecQuant::decode(src)) + .take(num_quant) + .collect::, _>>()?; + + // Progressive quantization values + ensure_size!(ctx: Self::NAME, in: src, size: num_prog_quant * ProgressiveCodecQuant::SIZE); + let quant_prog_vals = iter::repeat_with(|| ProgressiveCodecQuant::decode(src)) + .take(num_prog_quant) + .collect::, _>>()?; + + // Tile blocks (each preceded by a block header) + let mut tiles = Vec::with_capacity(num_tiles); + for _ in 0..num_tiles { + let header = ProgressiveBlockHeader::decode(src)?; + let body_len = header + .block_len + .checked_sub(BLOCK_HEADER_SIZE_U32) + .ok_or_else(|| invalid_field_err!("blockLen", "tile block length too small"))?; + let body_len: usize = cast_length!("tileBodyLen", body_len)?; + ensure_size!(ctx: Self::NAME, in: src, size: body_len); + let tile_src = &mut ReadCursor::new(src.read_slice(body_len)); + + let tile = match header.block_type { + ProgressiveBlockType::TileSimple => ProgressiveTile::Simple(TileSimple::decode(tile_src)?), + ProgressiveBlockType::TileFirst => ProgressiveTile::First(TileFirst::decode(tile_src)?), + ProgressiveBlockType::TileUpgrade => ProgressiveTile::Upgrade(TileUpgrade::decode(tile_src)?), + _ => { + return Err(invalid_field_err!("blockType", "expected tile block inside region")); + } + }; + tiles.push(tile); + } + + let quant_count = quant_vals.len(); + for tile in &tiles { + let indices = match tile { + ProgressiveTile::Simple(t) => [t.quant_idx_y, t.quant_idx_cb, t.quant_idx_cr], + ProgressiveTile::First(t) => [t.quant_idx_y, t.quant_idx_cb, t.quant_idx_cr], + ProgressiveTile::Upgrade(t) => [t.quant_idx_y, t.quant_idx_cb, t.quant_idx_cr], + }; + if indices.iter().any(|&i| usize::from(i) >= quant_count) { + return Err(invalid_field_err!("quantIdx", "tile quant index out of range")); + } + } + + Ok(Self { + tile_size, + rects, + quant_vals, + quant_prog_vals, + flags, + tiles, + }) + } +} + +// --------------------------------------------------------------------------- +// Top-level block enum + stream parser +// --------------------------------------------------------------------------- + +/// A progressive block in the bitmap stream. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub enum ProgressiveBlock<'a> { + Sync(ProgressiveSyncPdu), + FrameBegin(ProgressiveFrameBeginPdu), + FrameEnd(ProgressiveFrameEndPdu), + Context(ProgressiveContextPdu), + Region(ProgressiveRegion<'a>), +} + +/// Parse a progressive bitmap stream (the `bitmapData` from `WireToSurface2Pdu`). +/// +/// Returns the sequence of progressive blocks. The stream always starts with +/// SYNC + CONTEXT, followed by FRAME_BEGIN, one or more REGION blocks (containing +/// tiles), and FRAME_END. +pub fn decode_progressive_stream<'a>(data: &'a [u8]) -> DecodeResult>> { + let mut blocks = Vec::new(); + let mut src = ReadCursor::new(data); + + while src.len() >= ProgressiveBlockHeader::SIZE { + let header = ProgressiveBlockHeader::decode(&mut src)?; + let body_len = header + .block_len + .checked_sub(BLOCK_HEADER_SIZE_U32) + .ok_or_else(|| invalid_field_err!("blockLen", "block length too small"))?; + let body_len: usize = cast_length!("bodyLen", body_len)?; + ensure_size!(ctx: "ProgressiveStream", in: src, size: body_len); + + // Fixed-size blocks have normative blockLen values (MS-RDPEGFX 2.2.4.2.1) + let expected_body: Option = match header.block_type { + ProgressiveBlockType::Sync => Some(ProgressiveSyncPdu::FIXED_PART_SIZE), + ProgressiveBlockType::FrameBegin => Some(ProgressiveFrameBeginPdu::FIXED_PART_SIZE), + ProgressiveBlockType::FrameEnd => Some(ProgressiveFrameEndPdu::FIXED_PART_SIZE), + ProgressiveBlockType::Context => Some(ProgressiveContextPdu::FIXED_PART_SIZE), + _ => None, + }; + if let Some(expected) = expected_body { + if body_len != expected { + return Err(invalid_field_err!("blockLen", "unexpected size for fixed-size block")); + } + } + + let body_src = &mut ReadCursor::new(src.read_slice(body_len)); + + let block = match header.block_type { + ProgressiveBlockType::Sync => ProgressiveBlock::Sync(ProgressiveSyncPdu::decode(body_src)?), + ProgressiveBlockType::FrameBegin => { + ProgressiveBlock::FrameBegin(ProgressiveFrameBeginPdu::decode(body_src)?) + } + ProgressiveBlockType::FrameEnd => ProgressiveBlock::FrameEnd(ProgressiveFrameEndPdu::decode(body_src)?), + ProgressiveBlockType::Context => ProgressiveBlock::Context(ProgressiveContextPdu::decode(body_src)?), + ProgressiveBlockType::Region => ProgressiveBlock::Region(ProgressiveRegion::decode(body_src)?), + // Tile blocks should only appear inside regions; skip at top level + ProgressiveBlockType::TileSimple | ProgressiveBlockType::TileFirst | ProgressiveBlockType::TileUpgrade => { + return Err(invalid_field_err!("blockType", "tile block outside of region")); + } + }; + blocks.push(block); + } + + Ok(blocks) +} + +/// Encode a progressive bitmap stream into bytes. +/// +pub fn encode_progressive_stream(blocks: &[ProgressiveBlock<'_>]) -> EncodeResult> { + let total_size: usize = blocks + .iter() + .map(|b| { + ProgressiveBlockHeader::SIZE + + match b { + ProgressiveBlock::Sync(s) => s.size(), + ProgressiveBlock::FrameBegin(f) => f.size(), + ProgressiveBlock::FrameEnd(f) => f.size(), + ProgressiveBlock::Context(c) => c.size(), + ProgressiveBlock::Region(r) => r.size(), + } + }) + .sum(); + + let mut buf = vec![0u8; total_size]; + let mut dst = WriteCursor::new(&mut buf); + + for block in blocks { + let (block_type, body_size) = match block { + ProgressiveBlock::Sync(s) => (ProgressiveBlockType::Sync, s.size()), + ProgressiveBlock::FrameBegin(f) => (ProgressiveBlockType::FrameBegin, f.size()), + ProgressiveBlock::FrameEnd(f) => (ProgressiveBlockType::FrameEnd, f.size()), + ProgressiveBlock::Context(c) => (ProgressiveBlockType::Context, c.size()), + ProgressiveBlock::Region(r) => (ProgressiveBlockType::Region, r.size()), + }; + let block_len: u32 = cast_length!("blockLen", ProgressiveBlockHeader::SIZE + body_size)?; + ProgressiveBlockHeader { block_type, block_len }.encode(&mut dst)?; + + match block { + ProgressiveBlock::Sync(s) => s.encode(&mut dst)?, + ProgressiveBlock::FrameBegin(f) => f.encode(&mut dst)?, + ProgressiveBlock::FrameEnd(f) => f.encode(&mut dst)?, + ProgressiveBlock::Context(c) => c.encode(&mut dst)?, + ProgressiveBlock::Region(r) => r.encode(&mut dst)?, + } + } + + Ok(buf) +} + +#[cfg(test)] +#[expect(clippy::similar_names, reason = "y/cb/cr test variables follow spec terminology")] +mod tests { + use super::*; + + #[test] + fn component_codec_quant_round_trip() { + let original = ComponentCodecQuant { + ll3: 6, + hl3: 7, + lh3: 8, + hh3: 9, + hl2: 10, + lh2: 11, + hh2: 12, + hl1: 13, + lh1: 14, + hh1: 15, + }; + let mut buf = [0u8; ComponentCodecQuant::SIZE]; + original.encode(&mut WriteCursor::new(&mut buf)).unwrap(); + let decoded = ComponentCodecQuant::decode(&mut ReadCursor::new(&buf)).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn progressive_codec_quant_round_trip() { + let original = ProgressiveCodecQuant { + quality: 0x80, + y_quant: ComponentCodecQuant { + ll3: 1, + hl3: 2, + lh3: 3, + hh3: 4, + hl2: 5, + lh2: 6, + hh2: 7, + hl1: 8, + lh1: 9, + hh1: 10, + }, + cb_quant: ComponentCodecQuant::LOSSLESS, + cr_quant: ComponentCodecQuant::LOSSLESS, + }; + let mut buf = [0u8; ProgressiveCodecQuant::SIZE]; + original.encode(&mut WriteCursor::new(&mut buf)).unwrap(); + let decoded = ProgressiveCodecQuant::decode(&mut ReadCursor::new(&buf)).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn sync_round_trip() { + let original = ProgressiveSyncPdu; + let mut buf = [0u8; 64]; + let header_size = ProgressiveBlockHeader::SIZE; + let body_size = original.size(); + let block_len = u32::try_from(header_size + body_size).unwrap(); + let mut dst = WriteCursor::new(&mut buf); + ProgressiveBlockHeader { + block_type: ProgressiveBlockType::Sync, + block_len, + } + .encode(&mut dst) + .unwrap(); + original.encode(&mut dst).unwrap(); + let written = header_size + body_size; + let blocks = decode_progressive_stream(&buf[..written]).unwrap(); + assert_eq!(blocks.len(), 1); + assert!(matches!(blocks[0], ProgressiveBlock::Sync(_))); + } + + #[test] + fn context_pdu_round_trip() { + let original = ProgressiveContextPdu { + context_id: 0, + tile_size: 0x0040, + flags: FLAG_DWT_REDUCE_EXTRAPOLATE, + }; + assert!(original.uses_reduce_extrapolate()); + + let mut buf = [0u8; ProgressiveContextPdu::FIXED_PART_SIZE]; + original.encode(&mut WriteCursor::new(&mut buf)).unwrap(); + let decoded = ProgressiveContextPdu::decode(&mut ReadCursor::new(&buf)).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn tile_simple_round_trip() { + let y_data = &[1, 2, 3, 4, 5]; + let cb_data = &[6, 7]; + let cr_data = &[8, 9, 10]; + let tail_data = &[]; + + let original = TileSimple { + quant_idx_y: 0, + quant_idx_cb: 0, + quant_idx_cr: 0, + x_idx: 3, + y_idx: 7, + flags: 0, + y_data, + cb_data, + cr_data, + tail_data, + }; + let mut buf = vec![0u8; original.size()]; + original.encode(&mut WriteCursor::new(&mut buf)).unwrap(); + let decoded = TileSimple::decode(&mut ReadCursor::new(&buf)).unwrap(); + assert_eq!(decoded.x_idx, 3); + assert_eq!(decoded.y_idx, 7); + assert_eq!(decoded.y_data, y_data); + assert_eq!(decoded.cb_data, cb_data); + assert_eq!(decoded.cr_data, cr_data); + } + + #[test] + fn tile_first_round_trip() { + let original = TileFirst { + quant_idx_y: 0, + quant_idx_cb: 1, + quant_idx_cr: 0, + x_idx: 0, + y_idx: 0, + flags: 0, + quality: 0x40, + y_data: &[10, 20], + cb_data: &[30], + cr_data: &[40], + tail_data: &[], + }; + let mut buf = vec![0u8; original.size()]; + original.encode(&mut WriteCursor::new(&mut buf)).unwrap(); + let decoded = TileFirst::decode(&mut ReadCursor::new(&buf)).unwrap(); + assert_eq!(decoded.quality, 0x40); + assert_eq!(decoded.quant_idx_cb, 1); + } + + #[test] + fn tile_upgrade_round_trip() { + let original = TileUpgrade { + quant_idx_y: 0, + quant_idx_cb: 0, + quant_idx_cr: 0, + x_idx: 1, + y_idx: 2, + quality: 0x80, + y_srl_data: &[1, 2, 3], + y_raw_data: &[4, 5], + cb_srl_data: &[6], + cb_raw_data: &[], + cr_srl_data: &[7, 8], + cr_raw_data: &[9], + }; + let mut buf = vec![0u8; original.size()]; + original.encode(&mut WriteCursor::new(&mut buf)).unwrap(); + let decoded = TileUpgrade::decode(&mut ReadCursor::new(&buf)).unwrap(); + assert_eq!(decoded.quality, 0x80); + assert_eq!(decoded.y_srl_data, &[1, 2, 3]); + assert_eq!(decoded.cr_raw_data, &[9]); + } + + #[test] + fn full_stream_round_trip() { + let quant = ComponentCodecQuant { + ll3: 6, + hl3: 6, + lh3: 6, + hh3: 6, + hl2: 7, + lh2: 7, + hh2: 8, + hl1: 8, + lh1: 8, + hh1: 9, + }; + let prog_quant = ProgressiveCodecQuant { + quality: 0x40, + y_quant: quant, + cb_quant: quant, + cr_quant: quant, + }; + + let region = ProgressiveRegion { + tile_size: 0x40, + rects: vec![RfxRectangle { + x: 0, + y: 0, + width: 64, + height: 64, + }], + quant_vals: vec![quant], + quant_prog_vals: vec![prog_quant], + flags: FLAG_DWT_REDUCE_EXTRAPOLATE, + tiles: vec![ProgressiveTile::First(TileFirst { + quant_idx_y: 0, + quant_idx_cb: 0, + quant_idx_cr: 0, + x_idx: 0, + y_idx: 0, + flags: 0, + quality: 0x40, + y_data: &[0xAA; 50], + cb_data: &[0xBB; 30], + cr_data: &[0xCC; 20], + tail_data: &[], + })], + }; + + let blocks = vec![ + ProgressiveBlock::Sync(ProgressiveSyncPdu), + ProgressiveBlock::Context(ProgressiveContextPdu { + context_id: 0, + tile_size: 0x0040, + flags: FLAG_DWT_REDUCE_EXTRAPOLATE, + }), + ProgressiveBlock::FrameBegin(ProgressiveFrameBeginPdu { + frame_index: 0, + region_count: 1, + }), + ProgressiveBlock::Region(region), + ProgressiveBlock::FrameEnd(ProgressiveFrameEndPdu), + ]; + + let encoded = encode_progressive_stream(&blocks).unwrap(); + let decoded = decode_progressive_stream(&encoded).unwrap(); + + assert_eq!(decoded.len(), 5); + assert!(matches!(decoded[0], ProgressiveBlock::Sync(_))); + assert!(matches!(decoded[1], ProgressiveBlock::Context(_))); + assert!(matches!(decoded[2], ProgressiveBlock::FrameBegin(_))); + assert!(matches!(decoded[4], ProgressiveBlock::FrameEnd(_))); + + if let ProgressiveBlock::Region(r) = &decoded[3] { + assert_eq!(r.rects.len(), 1); + assert_eq!(r.quant_vals.len(), 1); + assert_eq!(r.quant_prog_vals.len(), 1); + assert_eq!(r.tiles.len(), 1); + assert!(r.uses_reduce_extrapolate()); + if let ProgressiveTile::First(t) = &r.tiles[0] { + assert_eq!(t.quality, 0x40); + assert_eq!(t.y_data.len(), 50); + } else { + panic!("expected TileFirst"); + } + } else { + panic!("expected Region"); + } + } + + #[test] + fn nibble_order_differs_from_classic() { + // Verify that progressive ComponentCodecQuant has HL/LH swapped vs classic + let prog = ComponentCodecQuant { + ll3: 1, + hl3: 2, + lh3: 3, + hh3: 4, + hl2: 5, + lh2: 6, + hh2: 7, + hl1: 8, + lh1: 9, + hh1: 10, + }; + let mut buf = [0u8; 5]; + prog.encode(&mut WriteCursor::new(&mut buf)).unwrap(); + + // First byte: low nibble = LL3(1), high nibble = HL3(2) → 0x21 + assert_eq!(buf[0], 0x21); + // Second byte: low nibble = LH3(3), high nibble = HH3(4) → 0x43 + assert_eq!(buf[1], 0x43); + // Third byte: low nibble = HL2(5), high nibble = LH2(6) → 0x65 + assert_eq!(buf[2], 0x65); + + // Compare: classic Quant would have LL3|LH3, HL3|HH3 order + // So our first byte would be 0x31 (not 0x21) in classic order + } + + #[test] + fn reject_tile_block_at_top_level() { + // A tile block at the top level should be rejected + let mut buf = [0u8; 64]; + let mut dst = WriteCursor::new(&mut buf); + ProgressiveBlockHeader { + block_type: ProgressiveBlockType::TileSimple, + block_len: 6 + 16, // header + minimal body + } + .encode(&mut dst) + .unwrap(); + // Fill minimal tile body + let tile = TileSimple { + quant_idx_y: 0, + quant_idx_cb: 0, + quant_idx_cr: 0, + x_idx: 0, + y_idx: 0, + flags: 0, + y_data: &[], + cb_data: &[], + cr_data: &[], + tail_data: &[], + }; + tile.encode(&mut dst).unwrap(); + let written = 6 + tile.size(); + let result = decode_progressive_stream(&buf[..written]); + assert!(result.is_err()); + } + + #[test] + fn empty_stream() { + let blocks = decode_progressive_stream(&[]).unwrap(); + assert!(blocks.is_empty()); + } + + #[test] + fn component_codec_quant_for_band() { + let q = ComponentCodecQuant { + ll3: 10, + hl3: 1, + lh3: 2, + hh3: 3, + hl2: 4, + lh2: 5, + hh2: 6, + hl1: 7, + lh1: 8, + hh1: 9, + }; + // Band order: HL1, LH1, HH1, HL2, LH2, HH2, HL3, LH3, HH3, LL3 + assert_eq!(q.for_band(0), 7); // HL1 + assert_eq!(q.for_band(1), 8); // LH1 + assert_eq!(q.for_band(2), 9); // HH1 + assert_eq!(q.for_band(3), 4); // HL2 + assert_eq!(q.for_band(9), 10); // LL3 + } +} diff --git a/crates/ironrdp-pdu/src/crypto.rs b/crates/ironrdp-pdu/src/crypto/mod.rs similarity index 100% rename from crates/ironrdp-pdu/src/crypto.rs rename to crates/ironrdp-pdu/src/crypto/mod.rs diff --git a/crates/ironrdp-pdu/src/crypto/rc4.rs b/crates/ironrdp-pdu/src/crypto/rc4.rs index 3123e197ba..abcafcf069 100644 --- a/crates/ironrdp-pdu/src/crypto/rc4.rs +++ b/crates/ironrdp-pdu/src/crypto/rc4.rs @@ -11,12 +11,12 @@ impl Rc4 { pub(crate) fn new(key: &[u8]) -> Self { // key scheduling let mut state = State::default(); - for (i, item) in state.iter_mut().enumerate().take(256) { - *item = i as u8; + for (i, item) in (0..=255).zip(state.iter_mut()) { + *item = i; } let mut j = 0usize; for i in 0..256 { - j = (j + state[i] as usize + key[i % key.len()] as usize) % 256; + j = (j + usize::from(state[i]) + usize::from(key[i % key.len()])) % 256; state.swap(i, j); } @@ -28,9 +28,9 @@ impl Rc4 { let mut output = Vec::with_capacity(message.len()); while output.capacity() > output.len() { self.i = (self.i + 1) % 256; - self.j = (self.j + self.state[self.i] as usize) % 256; + self.j = (self.j + usize::from(self.state[self.i])) % 256; self.state.swap(self.i, self.j); - let idx_k = (self.state[self.i] as usize + self.state[self.j] as usize) % 256; + let idx_k = (usize::from(self.state[self.i]) + usize::from(self.state[self.j])) % 256; let k = self.state[idx_k]; let idx_msg = output.len(); output.push(k ^ message[idx_msg]); diff --git a/crates/ironrdp-pdu/src/gcc/cluster_data.rs b/crates/ironrdp-pdu/src/gcc/cluster_data.rs index cc94e44749..88b87dd48c 100644 --- a/crates/ironrdp-pdu/src/gcc/cluster_data.rs +++ b/crates/ironrdp-pdu/src/gcc/cluster_data.rs @@ -1,12 +1,9 @@ -use std::io; - use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, invalid_field_err, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; -use thiserror::Error; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; const REDIRECTION_VERSION_MASK: u32 = 0x0000_003C; @@ -14,6 +11,7 @@ const FLAGS_SIZE: usize = 4; const REDIRECTED_SESSION_ID_SIZE: usize = 4; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientClusterData { pub flags: RedirectionFlags, pub redirection_version: RedirectionVersion, @@ -30,7 +28,7 @@ impl Encode for ClientClusterData { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - let flags_with_version = self.flags.bits() | (self.redirection_version.to_u32().unwrap() << 2); + let flags_with_version = self.flags.bits() | (self.redirection_version.as_u32() << 2); dst.write_u32(flags_with_version); dst.write_u32(self.redirected_session_id); @@ -56,9 +54,8 @@ impl<'de> Decode<'de> for ClientClusterData { let flags = RedirectionFlags::from_bits(flags_with_version & !REDIRECTION_VERSION_MASK) .ok_or_else(|| invalid_field_err!("flags", "invalid redirection flags"))?; - let redirection_version = - RedirectionVersion::from_u8(((flags_with_version & REDIRECTION_VERSION_MASK) >> 2) as u8) - .ok_or_else(|| invalid_field_err!("redirVersion", "invalid redirection version"))?; + let redirection_version = RedirectionVersion::from_u32((flags_with_version & REDIRECTION_VERSION_MASK) >> 2) + .ok_or_else(|| invalid_field_err!("redirVersion", "invalid redirection version"))?; Ok(Self { flags, @@ -70,6 +67,7 @@ impl<'de> Decode<'de> for ClientClusterData { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct RedirectionFlags: u32 { const REDIRECTION_SUPPORTED = 0x0000_0001; const REDIRECTED_SESSION_FIELD_VALID = 0x0000_0002; @@ -77,7 +75,9 @@ bitflags! { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u32)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum RedirectionVersion { V1 = 0, V2 = 1, @@ -87,10 +87,12 @@ pub enum RedirectionVersion { V6 = 5, } -#[derive(Debug, Error)] -pub enum ClusterDataError { - #[error("IO error")] - IOError(#[from] io::Error), - #[error("invalid redirection flags field")] - InvalidRedirectionFlags, +impl RedirectionVersion { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } } diff --git a/crates/ironrdp-pdu/src/gcc/conference_create.rs b/crates/ironrdp-pdu/src/gcc/conference_create.rs index e3d764f61d..e7afbbade7 100644 --- a/crates/ironrdp-pdu/src/gcc/conference_create.rs +++ b/crates/ironrdp-pdu/src/gcc/conference_create.rs @@ -1,6 +1,6 @@ use ironrdp_core::{ - cast_length, ensure_size, invalid_field_err, other_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_size, invalid_field_err, + other_err, }; use super::{ClientGccBlocks, ServerGccBlocks}; @@ -26,11 +26,45 @@ const CONFERENCE_NAME: &[u8] = b"1"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct ConferenceCreateRequest { - pub gcc_blocks: ClientGccBlocks, + /// INVARIANT: `gcc_blocks.size() <= u16::MAX - CONFERENCE_REQUEST_CONNECT_PDU_SIZE` + gcc_blocks: ClientGccBlocks, +} + +// Hand-rolled because the gcc_blocks size invariant cannot be expressed via +// `derive(Arbitrary)`. Without it, encode() panics via u16::try_from on +// overflowing gcc_blocks under fuzz. +#[cfg(feature = "arbitrary")] +impl<'a> arbitrary::Arbitrary<'a> for ConferenceCreateRequest { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + let gcc_blocks = ClientGccBlocks::arbitrary(u)?; + Self::new(gcc_blocks).map_err(|_| arbitrary::Error::IncorrectFormat) + } } impl ConferenceCreateRequest { const NAME: &'static str = "ConferenceCreateRequest"; + + pub fn new(gcc_blocks: ClientGccBlocks) -> DecodeResult { + // Ensure the invariant on gcc_blocks.size() is respected. + check_invariant(gcc_blocks.size() <= usize::from(u16::MAX) - CONFERENCE_REQUEST_CONNECT_PDU_SIZE).ok_or_else( + || { + invalid_field_err!( + "gcc_blocks", + "gcc_blocks.size() + CONFERENCE_REQUEST_CONNECT_PDU_SIZE > u16::MAX" + ) + }, + )?; + + Ok(Self { gcc_blocks }) + } + + pub fn gcc_blocks(&self) -> &ClientGccBlocks { + &self.gcc_blocks + } + + pub fn into_gcc_blocks(self) -> ClientGccBlocks { + self.gcc_blocks + } } impl Encode for ConferenceCreateRequest { @@ -84,16 +118,16 @@ impl Encode for ConferenceCreateRequest { fn size(&self) -> usize { let gcc_blocks_buffer_length = self.gcc_blocks.size(); - let req_length: DecodeResult = cast_length!( - "gccBlocksLen", - CONFERENCE_REQUEST_CONNECT_PDU_SIZE + gcc_blocks_buffer_length - ); - let length: DecodeResult = cast_length!("gccBlocksLen", gcc_blocks_buffer_length); + let req_length = u16::try_from(CONFERENCE_REQUEST_CONNECT_PDU_SIZE + gcc_blocks_buffer_length) + .expect("per the invariant on self.gcc_blocks, this cast is infallible"); + let length = u16::try_from(gcc_blocks_buffer_length) + .expect("per the invariant on self.gcc_blocks, this cast is infallible"); + per::CHOICE_SIZE + CONFERENCE_REQUEST_OBJECT_ID.len() - + per::sizeof_length(req_length.unwrap()) + + per::sizeof_length(usize::from(req_length)) + CONFERENCE_REQUEST_CONNECT_PDU_SIZE - + per::sizeof_length(length.unwrap()) + + per::sizeof_length(usize::from(length)) + gcc_blocks_buffer_length } } @@ -169,18 +203,52 @@ impl<'de> Decode<'de> for ConferenceCreateRequest { let (_gcc_blocks_buffer_length, _) = per::read_length(src).map_err(|e| other_err!("len", source: e))?; let gcc_blocks = ClientGccBlocks::decode(src)?; - Ok(Self { gcc_blocks }) + Self::new(gcc_blocks) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ConferenceCreateResponse { - pub user_id: u16, - pub gcc_blocks: ServerGccBlocks, + user_id: u16, + /// INVARIANT: `gcc_blocks.size() <= u16::MAX - CONFERENCE_RESPONSE_CONNECT_PDU_SIZE` + gcc_blocks: ServerGccBlocks, +} + +// Hand-rolled for the same reason as `ConferenceCreateRequest` above: the +// gcc_blocks size invariant cannot be expressed via `derive(Arbitrary)`. +#[cfg(feature = "arbitrary")] +impl<'a> arbitrary::Arbitrary<'a> for ConferenceCreateResponse { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + let user_id = u16::arbitrary(u)?; + let gcc_blocks = ServerGccBlocks::arbitrary(u)?; + Self::new(user_id, gcc_blocks).map_err(|_| arbitrary::Error::IncorrectFormat) + } } impl ConferenceCreateResponse { const NAME: &'static str = "ConferenceCreateResponse"; + + pub fn new(user_id: u16, gcc_blocks: ServerGccBlocks) -> DecodeResult { + // Ensure the invariant on gcc_blocks.size() is respected. + check_invariant(gcc_blocks.size() <= usize::from(u16::MAX) - CONFERENCE_RESPONSE_CONNECT_PDU_SIZE).ok_or_else( + || { + invalid_field_err!( + "gcc_blocks", + "gcc_blocks.size() + CONFERENCE_REQUEST_CONNECT_PDU_SIZE > u16::MAX" + ) + }, + )?; + + Ok(Self { user_id, gcc_blocks }) + } + + pub fn gcc_blocks(&self) -> &ServerGccBlocks { + &self.gcc_blocks + } + + pub fn into_gcc_blocks(self) -> ServerGccBlocks { + self.gcc_blocks + } } impl Encode for ConferenceCreateResponse { @@ -197,6 +265,8 @@ impl Encode for ConferenceCreateResponse { dst, cast_length!( "gccBlocksLen", + // FIXME: It seems that the addition of 1 here is a bug. + // The fuzzing is not failing because this length is ignored. gcc_blocks_buffer_length + CONFERENCE_RESPONSE_CONNECT_PDU_SIZE + 1 )?, ); @@ -219,7 +289,7 @@ impl Encode for ConferenceCreateResponse { ) .map_err(|e| other_err!("server-to-client", source: e))?; // H221NonStandardIdentifier (octet string) - per::write_length(dst, gcc_blocks_buffer_length as u16); + per::write_length(dst, cast_length!("gccBlocksLen", gcc_blocks_buffer_length)?); self.gcc_blocks.encode(dst)?; Ok(()) @@ -231,16 +301,16 @@ impl Encode for ConferenceCreateResponse { fn size(&self) -> usize { let gcc_blocks_buffer_length = self.gcc_blocks.size(); - let req_length: DecodeResult = cast_length!( - "gccBlocksLen", - CONFERENCE_RESPONSE_CONNECT_PDU_SIZE + gcc_blocks_buffer_length - ); - let length: DecodeResult = cast_length!("gccBlocksLen", gcc_blocks_buffer_length); + let req_length = u16::try_from(CONFERENCE_RESPONSE_CONNECT_PDU_SIZE + gcc_blocks_buffer_length) + .expect("per the invariant on self.gcc_blocks, this cast is infallible"); + let length = u16::try_from(gcc_blocks_buffer_length) + .expect("per the invariant on self.gcc_blocks, this cast is infallible"); + per::CHOICE_SIZE + CONFERENCE_REQUEST_OBJECT_ID.len() - + per::sizeof_length(req_length.unwrap()) + + per::sizeof_length(usize::from(req_length)) + CONFERENCE_RESPONSE_CONNECT_PDU_SIZE - + per::sizeof_length(length.unwrap()) + + per::sizeof_length(usize::from(length)) + gcc_blocks_buffer_length } } @@ -315,6 +385,13 @@ impl<'de> Decode<'de> for ConferenceCreateResponse { let (_gcc_blocks_buffer_length, _) = per::read_length(src).map_err(|e| other_err!("len", source: e))?; let gcc_blocks = ServerGccBlocks::decode(src)?; - Ok(Self { user_id, gcc_blocks }) + Self::new(user_id, gcc_blocks) } } + +/// Use this when establishing invariants. +#[inline] +#[must_use] +fn check_invariant(condition: bool) -> Option<()> { + condition.then_some(()) +} diff --git a/crates/ironrdp-pdu/src/gcc/core_data/client.rs b/crates/ironrdp-pdu/src/gcc/core_data/client.rs index e207086b1e..e787bde9cf 100644 --- a/crates/ironrdp-pdu/src/gcc/core_data/client.rs +++ b/crates/ironrdp-pdu/src/gcc/core_data/client.rs @@ -1,10 +1,10 @@ use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, ensure_size, invalid_field_err, write_padding, Decode, DecodeResult, Encode, EncodeResult, - ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, + invalid_field_err, write_padding, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use tap::Pipe as _; use super::{RdpVersion, VERSION_SIZE}; @@ -44,6 +44,7 @@ const DEVICE_SCALE_FACTOR_SIZE: usize = 4; /// /// [2.2.1.3.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/00f1da4a-ee9c-421a-852f-c19f92343d73 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientCoreData { pub version: RdpVersion, pub desktop_width: u16, @@ -108,13 +109,13 @@ impl Encode for ClientCoreData { dst.write_u32(self.version.0); dst.write_u16(self.desktop_width); dst.write_u16(self.desktop_height); - dst.write_u16(self.color_depth.to_u16().unwrap()); - dst.write_u16(self.sec_access_sequence.to_u16().unwrap()); + dst.write_u16(self.color_depth.as_u16()); + dst.write_u16(self.sec_access_sequence.as_u16()); dst.write_u32(self.keyboard_layout); dst.write_u32(self.client_build); dst.write_slice(client_name_dst.as_ref()); dst.write_u16(0); // client name UTF-16 null terminator - dst.write_u32(self.keyboard_type.to_u32().unwrap()); + dst.write_u32(self.keyboard_type.as_u32()); dst.write_u32(self.keyboard_subtype); dst.write_u32(self.keyboard_functional_keys_count); dst.write_slice(ime_file_name_dst.as_ref()); @@ -194,6 +195,7 @@ impl<'de> Decode<'de> for ClientCoreData { /// /// [2.2.1.3.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/00f1da4a-ee9c-421a-852f-c19f92343d73 #[derive(Debug, Clone, Default, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientCoreOptionalData { /// The requested color depth. Values in this field MUST be ignored if the highColorDepth field is present. pub post_beta2_color_depth: Option, @@ -223,7 +225,7 @@ impl Encode for ClientCoreOptionalData { ensure_size!(in: dst, size: self.size()); if let Some(value) = self.post_beta2_color_depth { - dst.write_u16(value.to_u16().unwrap()); + dst.write_u16(value.as_u16()); } if let Some(value) = self.client_product_id { @@ -247,7 +249,7 @@ impl Encode for ClientCoreOptionalData { if self.serial_number.is_none() { return Err(invalid_field_err!("serialNumber", "serialNumber must be present")); } - dst.write_u16(value.to_u16().unwrap()); + dst.write_u16(value.as_u16()); } if let Some(value) = self.supported_color_depths { @@ -285,7 +287,7 @@ impl Encode for ClientCoreOptionalData { if self.dig_product_id.is_none() { return Err(invalid_field_err!("digProductId", "digProductId must be present")); } - dst.write_u8(value.to_u8().unwrap()); + dst.write_u8(value.as_u8()); write_padding!(dst, 1); } @@ -472,6 +474,7 @@ impl<'de> Decode<'de> for ClientCoreOptionalData { } #[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ClientColorDepth { Bpp4, Bpp8, @@ -506,7 +509,8 @@ impl From for ClientColorDepth { } #[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ColorDepth { Bpp4 = 0xCA00, Bpp8 = 0xCA01, @@ -515,8 +519,19 @@ pub enum ColorDepth { Bpp24 = 0xCA04, } +impl ColorDepth { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + #[repr(u16)] -#[derive(Debug, Copy, Clone, FromPrimitive, ToPrimitive, Eq, Ord, PartialEq, PartialOrd)] +#[derive(Debug, Copy, Clone, FromPrimitive, Eq, Ord, PartialEq, PartialOrd)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum HighColorDepth { Bpp4 = 0x0004, Bpp8 = 0x0008, @@ -525,13 +540,36 @@ pub enum HighColorDepth { Bpp24 = 0x0018, } +impl HighColorDepth { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + #[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum SecureAccessSequence { Del = 0xAA03, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +impl SecureAccessSequence { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + +#[repr(u32)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum KeyboardType { IbmPcXt = 1, OlivettiIco = 2, @@ -542,8 +580,19 @@ pub enum KeyboardType { Japanese = 7, } +impl KeyboardType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u32(self) -> u32 { + self as u32 + } +} + #[repr(u8)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ConnectionType { NotUsed = 0, // not used as ClientEarlyCapabilityFlags::VALID_CONNECTION_TYPE not set Modem = 1, @@ -555,8 +604,19 @@ pub enum ConnectionType { Autodetect = 7, } +impl ConnectionType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SupportedColorDepths: u16 { const BPP24 = 1; const BPP16 = 2; @@ -567,6 +627,7 @@ bitflags! { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientEarlyCapabilityFlags: u16 { const SUPPORT_ERR_INFO_PDU = 0x0001; const WANT_32_BPP_SESSION = 0x0002; diff --git a/crates/ironrdp-pdu/src/gcc/core_data.rs b/crates/ironrdp-pdu/src/gcc/core_data/mod.rs similarity index 50% rename from crates/ironrdp-pdu/src/gcc/core_data.rs rename to crates/ironrdp-pdu/src/gcc/core_data/mod.rs index 5e8978bed5..ad260dcd69 100644 --- a/crates/ironrdp-pdu/src/gcc/core_data.rs +++ b/crates/ironrdp-pdu/src/gcc/core_data/mod.rs @@ -1,16 +1,11 @@ pub(crate) mod client; pub(crate) mod server; -use std::io; - -use thiserror::Error; - -use crate::PduError; - const VERSION_SIZE: usize = 4; #[repr(transparent)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct RdpVersion(pub u32); impl From for RdpVersion { @@ -42,37 +37,3 @@ impl RdpVersion { pub const V10_11: Self = Self(0x0008_0010); pub const V10_12: Self = Self(0x0008_0011); } - -#[derive(Debug, Error)] -pub enum CoreDataError { - #[error("IO error")] - IOError(#[from] io::Error), - #[error("invalid version field")] - InvalidVersion, - #[error("invalid color depth field")] - InvalidColorDepth, - #[error("invalid post beta color depth field")] - InvalidPostBetaColorDepth, - #[error("invalid high color depth field")] - InvalidHighColorDepth, - #[error("invalid supported color depths field")] - InvalidSupportedColorDepths, - #[error("invalid secure access sequence field")] - InvalidSecureAccessSequence, - #[error("invalid keyboard type field")] - InvalidKeyboardType, - #[error("invalid early capability flags field")] - InvalidEarlyCapabilityFlags, - #[error("invalid connection type field")] - InvalidConnectionType, - #[error("invalid server security protocol field")] - InvalidServerSecurityProtocol, - #[error("PDU error: {0}")] - Pdu(PduError), -} - -impl From for CoreDataError { - fn from(e: PduError) -> Self { - Self::Pdu(e) - } -} diff --git a/crates/ironrdp-pdu/src/gcc/core_data/server.rs b/crates/ironrdp-pdu/src/gcc/core_data/server.rs index 676df2e228..575afc9b34 100644 --- a/crates/ironrdp-pdu/src/gcc/core_data/server.rs +++ b/crates/ironrdp-pdu/src/gcc/core_data/server.rs @@ -1,7 +1,7 @@ use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, + invalid_field_err, }; use tap::Pipe as _; @@ -12,6 +12,7 @@ const CLIENT_REQUESTED_PROTOCOL_SIZE: usize = 4; const EARLY_CAPABILITY_FLAGS_SIZE: usize = 4; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ServerCoreData { pub version: RdpVersion, pub optional_data: ServerCoreOptionalData, @@ -52,6 +53,7 @@ impl<'de> Decode<'de> for ServerCoreData { } #[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ServerCoreOptionalData { pub client_requested_protocols: Option, pub early_capability_flags: Option, @@ -123,6 +125,7 @@ impl<'de> Decode<'de> for ServerCoreOptionalData { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ServerEarlyCapabilityFlags: u32 { const EDGE_ACTIONS_SUPPORTED_V1 = 0x0000_0001; const DYNAMIC_DST_SUPPORTED = 0x0000_0002; diff --git a/crates/ironrdp-pdu/src/gcc/message_channel_data.rs b/crates/ironrdp-pdu/src/gcc/message_channel_data.rs index b01a27c603..dece1cc29f 100644 --- a/crates/ironrdp-pdu/src/gcc/message_channel_data.rs +++ b/crates/ironrdp-pdu/src/gcc/message_channel_data.rs @@ -1,9 +1,10 @@ -use ironrdp_core::{ensure_fixed_part_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor}; +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size}; const CLIENT_FLAGS_SIZE: usize = 4; const SERVER_MCS_MESSAGE_CHANNEL_ID_SIZE: usize = 2; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientMessageChannelData; impl ClientMessageChannelData { @@ -41,6 +42,7 @@ impl<'de> Decode<'de> for ClientMessageChannelData { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ServerMessageChannelData { pub mcs_message_channel_id: u16, } diff --git a/crates/ironrdp-pdu/src/gcc.rs b/crates/ironrdp-pdu/src/gcc/mod.rs similarity index 78% rename from crates/ironrdp-pdu/src/gcc.rs rename to crates/ironrdp-pdu/src/gcc/mod.rs index b7c8cc5d7e..2156a613b9 100644 --- a/crates/ironrdp-pdu/src/gcc.rs +++ b/crates/ironrdp-pdu/src/gcc/mod.rs @@ -1,14 +1,9 @@ -use std::io; - use ironrdp_core::{ - cast_length, decode, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeErrorKind, DecodeResult, - Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeErrorKind, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, decode, + ensure_fixed_part_size, ensure_size, invalid_field_err, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive, ToPrimitive}; -use thiserror::Error; - -use crate::PduError; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive; pub mod conference_create; @@ -21,26 +16,22 @@ mod multi_transport_channel_data; mod network_data; mod security_data; -pub use self::cluster_data::{ClientClusterData, ClusterDataError, RedirectionFlags, RedirectionVersion}; +pub use self::cluster_data::{ClientClusterData, RedirectionFlags, RedirectionVersion}; pub use self::conference_create::{ConferenceCreateRequest, ConferenceCreateResponse}; +pub use self::core_data::RdpVersion; pub use self::core_data::client::{ ClientColorDepth, ClientCoreData, ClientCoreOptionalData, ClientEarlyCapabilityFlags, ColorDepth, ConnectionType, - HighColorDepth, KeyboardType, SecureAccessSequence, SupportedColorDepths, IME_FILE_NAME_SIZE, + HighColorDepth, IME_FILE_NAME_SIZE, KeyboardType, SecureAccessSequence, SupportedColorDepths, }; pub use self::core_data::server::{ServerCoreData, ServerCoreOptionalData, ServerEarlyCapabilityFlags}; -pub use self::core_data::{CoreDataError, RdpVersion}; pub use self::message_channel_data::{ClientMessageChannelData, ServerMessageChannelData}; pub use self::monitor_data::{ - ClientMonitorData, Monitor, MonitorFlags, MONITOR_COUNT_SIZE, MONITOR_FLAGS_SIZE, MONITOR_SIZE, + ClientMonitorData, MONITOR_COUNT_SIZE, MONITOR_FLAGS_SIZE, MONITOR_SIZE, Monitor, MonitorFlags, }; pub use self::monitor_extended_data::{ClientMonitorExtendedData, ExtendedMonitorInfo, MonitorOrientation}; pub use self::multi_transport_channel_data::{MultiTransportChannelData, MultiTransportFlags}; -pub use self::network_data::{ - ChannelDef, ChannelName, ChannelOptions, ClientNetworkData, NetworkDataError, ServerNetworkData, -}; -pub use self::security_data::{ - ClientSecurityData, EncryptionLevel, EncryptionMethod, SecurityDataError, ServerSecurityData, -}; +pub use self::network_data::{ChannelDef, ChannelName, ChannelOptions, ClientNetworkData, ServerNetworkData}; +pub use self::security_data::{ClientSecurityData, EncryptionLevel, EncryptionMethod, ServerSecurityData}; macro_rules! user_header_try { ($e:expr) => { @@ -58,6 +49,7 @@ const USER_DATA_HEADER_SIZE: usize = 4; /// /// [2.2.1.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/db6713ee-1c0e-4064-a3b3-0fac30b4037b #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientGccBlocks { pub core: ClientCoreData, pub security: ClientSecurityData, @@ -86,26 +78,30 @@ impl Encode for ClientGccBlocks { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - UserDataHeader::encode(dst, ClientGccType::CoreData, &self.core)?; - UserDataHeader::encode(dst, ClientGccType::SecurityData, &self.security)?; + UserDataHeader::encode(dst, ClientGccType::CoreData.as_u16(), &self.core)?; + UserDataHeader::encode(dst, ClientGccType::SecurityData.as_u16(), &self.security)?; if let Some(ref network) = self.network { - UserDataHeader::encode(dst, ClientGccType::NetworkData, network)?; + UserDataHeader::encode(dst, ClientGccType::NetworkData.as_u16(), network)?; } if let Some(ref cluster) = self.cluster { - UserDataHeader::encode(dst, ClientGccType::ClusterData, cluster)?; + UserDataHeader::encode(dst, ClientGccType::ClusterData.as_u16(), cluster)?; } if let Some(ref monitor) = self.monitor { - UserDataHeader::encode(dst, ClientGccType::MonitorData, monitor)?; + UserDataHeader::encode(dst, ClientGccType::MonitorData.as_u16(), monitor)?; } if let Some(ref message_channel) = self.message_channel { - UserDataHeader::encode(dst, ClientGccType::MessageChannelData, message_channel)?; + UserDataHeader::encode(dst, ClientGccType::MessageChannelData.as_u16(), message_channel)?; } if let Some(ref multi_transport_channel) = self.multi_transport_channel { - UserDataHeader::encode(dst, ClientGccType::MultiTransportChannelData, multi_transport_channel)?; + UserDataHeader::encode( + dst, + ClientGccType::MultiTransportChannelData.as_u16(), + multi_transport_channel, + )?; } if let Some(ref monitor_extended) = self.monitor_extended { - UserDataHeader::encode(dst, ClientGccType::MonitorExtendedData, monitor_extended)?; + UserDataHeader::encode(dst, ClientGccType::MonitorExtendedData.as_u16(), monitor_extended)?; } Ok(()) @@ -181,6 +177,7 @@ impl<'de> Decode<'de> for ClientGccBlocks { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ServerGccBlocks { pub core: ServerCoreData, pub network: ServerNetworkData, @@ -202,15 +199,19 @@ impl ServerGccBlocks { impl Encode for ServerGccBlocks { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - UserDataHeader::encode(dst, ServerGccType::CoreData, &self.core)?; - UserDataHeader::encode(dst, ServerGccType::NetworkData, &self.network)?; - UserDataHeader::encode(dst, ServerGccType::SecurityData, &self.security)?; + UserDataHeader::encode(dst, ServerGccType::CoreData.as_u16(), &self.core)?; + UserDataHeader::encode(dst, ServerGccType::NetworkData.as_u16(), &self.network)?; + UserDataHeader::encode(dst, ServerGccType::SecurityData.as_u16(), &self.security)?; if let Some(ref message_channel) = self.message_channel { - UserDataHeader::encode(dst, ServerGccType::MessageChannelData, message_channel)?; + UserDataHeader::encode(dst, ServerGccType::MessageChannelData.as_u16(), message_channel)?; } if let Some(ref multi_transport_channel) = self.multi_transport_channel { - UserDataHeader::encode(dst, ServerGccType::MultiTransportChannelData, multi_transport_channel)?; + UserDataHeader::encode( + dst, + ServerGccType::MultiTransportChannelData.as_u16(), + multi_transport_channel, + )?; } Ok(()) @@ -265,7 +266,8 @@ impl<'de> Decode<'de> for ServerGccBlocks { } #[repr(u16)] -#[derive(Debug, Copy, Clone, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ClientGccType { CoreData = 0xC001, SecurityData = 0xC002, @@ -277,8 +279,19 @@ pub enum ClientGccType { MultiTransportChannelData = 0xC00A, } +impl ClientGccType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u16(self) -> u16 { + self as u16 + } +} + #[repr(u16)] -#[derive(Debug, Copy, Clone, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ServerGccType { CoreData = 0x0C01, SecurityData = 0x0C02, @@ -287,7 +300,18 @@ pub enum ServerGccType { MultiTransportChannelData = 0x0C08, } +impl ServerGccType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u16(self) -> u16 { + self as u16 + } +} + #[derive(Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct UserDataHeader; impl UserDataHeader { @@ -295,12 +319,12 @@ impl UserDataHeader { pub fn encode(dst: &mut WriteCursor<'_>, block_type: T, block: &B) -> EncodeResult<()> where - T: ToPrimitive, + T: Into, B: Encode, { ensure_fixed_part_size!(in: dst); - dst.write_u16(block_type.to_u16().unwrap()); + dst.write_u16(block_type.into()); dst.write_u16(cast_length!("blockLen", block.size() + USER_DATA_HEADER_SIZE)?); block.encode(dst)?; @@ -327,35 +351,3 @@ impl UserDataHeader { Ok((block_type, src.read_slice(len))) } } - -#[derive(Debug, Error)] -pub enum GccError { - #[error("IO error")] - IOError(#[from] io::Error), - #[error("core data block error")] - CoreError(#[from] CoreDataError), - #[error("security data block error")] - SecurityError(#[from] SecurityDataError), - #[error("network data block error")] - NetworkError(#[from] NetworkDataError), - #[error("cluster data block error")] - ClusterError(#[from] ClusterDataError), - #[error("invalid GCC block type")] - InvalidGccType, - #[error("invalid conference create request: {0}")] - InvalidConferenceCreateRequest(String), - #[error("invalid Conference create response: {0}")] - InvalidConferenceCreateResponse(String), - #[error("a server did not send the required GCC data block: {0:?}")] - RequiredClientDataBlockIsAbsent(ClientGccType), - #[error("a client did not send the required GCC data block: {0:?}")] - RequiredServerDataBlockIsAbsent(ServerGccType), - #[error("PDU error: {0}")] - Pdu(PduError), -} - -impl From for GccError { - fn from(e: PduError) -> Self { - Self::Pdu(e) - } -} diff --git a/crates/ironrdp-pdu/src/gcc/monitor_data.rs b/crates/ironrdp-pdu/src/gcc/monitor_data.rs index f6cb021bfd..01972e8ef7 100644 --- a/crates/ironrdp-pdu/src/gcc/monitor_data.rs +++ b/crates/ironrdp-pdu/src/gcc/monitor_data.rs @@ -1,7 +1,7 @@ use bitflags::bitflags; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + invalid_field_err, }; pub const MONITOR_COUNT_SIZE: usize = 4; @@ -11,6 +11,7 @@ pub const MONITOR_FLAGS_SIZE: usize = 4; const MONITOR_COUNT_MAX: usize = 16; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientMonitorData { pub monitors: Vec, } @@ -49,13 +50,13 @@ impl<'de> Decode<'de> for ClientMonitorData { ensure_fixed_part_size!(in: src); let _flags = src.read_u32(); // is unused - let monitor_count = src.read_u32(); + let monitor_count = cast_length!("number of monitors", src.read_u32())?; - if monitor_count > MONITOR_COUNT_MAX as u32 { + if monitor_count > MONITOR_COUNT_MAX { return Err(invalid_field_err!("nMonitors", "too many monitors")); } - let mut monitors = Vec::with_capacity(monitor_count as usize); + let mut monitors = Vec::with_capacity(monitor_count); for _ in 0..monitor_count { monitors.push(Monitor::decode(src)?); } @@ -65,6 +66,7 @@ impl<'de> Decode<'de> for ClientMonitorData { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Monitor { pub left: i32, pub top: i32, @@ -124,6 +126,7 @@ impl<'de> Decode<'de> for Monitor { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct MonitorFlags: u32 { const PRIMARY = 1; } diff --git a/crates/ironrdp-pdu/src/gcc/monitor_extended_data.rs b/crates/ironrdp-pdu/src/gcc/monitor_extended_data.rs index 313ece7ab7..d90b6aa479 100644 --- a/crates/ironrdp-pdu/src/gcc/monitor_extended_data.rs +++ b/crates/ironrdp-pdu/src/gcc/monitor_extended_data.rs @@ -1,9 +1,9 @@ use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, - ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; const MONITOR_COUNT_MAX: usize = 16; const MONITOR_ATTRIBUTE_SIZE: u32 = 20; @@ -14,6 +14,7 @@ const MONITOR_COUNT: usize = 4; const MONITOR_SIZE: usize = 20; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientMonitorExtendedData { pub extended_monitors_info: Vec, } @@ -75,6 +76,7 @@ impl<'de> Decode<'de> for ClientMonitorExtendedData { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ExtendedMonitorInfo { pub physical_width: u32, pub physical_height: u32, @@ -95,7 +97,7 @@ impl Encode for ExtendedMonitorInfo { dst.write_u32(self.physical_width); dst.write_u32(self.physical_height); - dst.write_u32(self.orientation.to_u32().unwrap()); + dst.write_u32(u32::from(self.orientation.as_u16())); dst.write_u32(self.desktop_scale_factor); dst.write_u32(self.device_scale_factor); @@ -132,10 +134,22 @@ impl<'de> Decode<'de> for ExtendedMonitorInfo { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u16)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum MonitorOrientation { Landscape = 0, Portrait = 90, LandscapeFlipped = 180, PortraitFlipped = 270, } + +impl MonitorOrientation { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u16(self) -> u16 { + self as u16 + } +} diff --git a/crates/ironrdp-pdu/src/gcc/multi_transport_channel_data.rs b/crates/ironrdp-pdu/src/gcc/multi_transport_channel_data.rs index e9bd3291af..8265069e0d 100644 --- a/crates/ironrdp-pdu/src/gcc/multi_transport_channel_data.rs +++ b/crates/ironrdp-pdu/src/gcc/multi_transport_channel_data.rs @@ -1,9 +1,10 @@ use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, invalid_field_err, }; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct MultiTransportChannelData { pub flags: MultiTransportFlags, } @@ -45,6 +46,7 @@ impl<'de> Decode<'de> for MultiTransportChannelData { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct MultiTransportFlags: u32 { const TRANSPORT_TYPE_UDP_FECR = 0x01; const TRANSPORT_TYPE_UDP_FECL = 0x04; diff --git a/crates/ironrdp-pdu/src/gcc/network_data.rs b/crates/ironrdp-pdu/src/gcc/network_data.rs index 33b6d3872c..76c883e3cd 100644 --- a/crates/ironrdp-pdu/src/gcc/network_data.rs +++ b/crates/ironrdp-pdu/src/gcc/network_data.rs @@ -1,13 +1,12 @@ use std::borrow::Cow; -use std::{io, str}; +use std::str; use bitflags::bitflags; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, write_padding, Decode, - DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, read_padding, write_padding, }; use num_integer::Integer as _; -use thiserror::Error; const CHANNELS_MAX: usize = 31; @@ -25,9 +24,26 @@ const SERVER_CHANNEL_SIZE: usize = 2; /// is using all the code values from 0 to 255, as such any u8 value is a valid ANSI character. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct ChannelName { + /// INVARIANT: A null-terminated 8-byte array. + /// INVARIANT: Contains at most seven ANSI characters. inner: Cow<'static, [u8; Self::SIZE]>, } +#[cfg(feature = "arbitrary")] +impl<'a> arbitrary::Arbitrary<'a> for ChannelName { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + // Generate up to seven ANSI characters then enforce the null terminator, + // preserving both INVARIANTs on the inner array. + let len = u.int_in_range::(0..=Self::SIZE - 1)?; + let mut bytes = [0u8; Self::SIZE]; + let payload = u.bytes(len)?; + bytes[..len].copy_from_slice(payload); + Ok(Self { + inner: Cow::Owned(bytes), + }) + } +} + impl ChannelName { pub const SIZE: usize = 8; @@ -79,14 +95,16 @@ impl ChannelName { self.inner.as_ref() } - /// Get a &str if this channel name is a valid ASCII string. pub fn as_str(&self) -> Option<&str> { if self.inner.iter().all(u8::is_ascii) { + #[expect(clippy::missing_panics_doc, reason = "never panics per invariant on self.inner")] let terminator_idx = self .inner .iter() .position(|c| *c == 0) .expect("null-terminated ASCII string"); + + #[expect(clippy::missing_panics_doc, reason = "never panics per invariant on self.inner")] Some(str::from_utf8(&self.inner[..terminator_idx]).expect("ASCII characters")) } else { None @@ -95,6 +113,7 @@ impl ChannelName { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientNetworkData { pub channels: Vec, } @@ -147,6 +166,7 @@ impl<'de> Decode<'de> for ClientNetworkData { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ServerNetworkData { pub channel_ids: Vec, pub io_channel: u16, @@ -224,6 +244,7 @@ impl<'de> Decode<'de> for ServerNetworkData { /// Channel Definition Structure (CHANNEL_DEF) #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ChannelDef { pub name: ChannelName, pub options: ChannelOptions, @@ -270,6 +291,7 @@ impl<'de> Decode<'de> for ChannelDef { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ChannelOptions: u32 { const INITIALIZED = 0x8000_0000; const ENCRYPT_RDP = 0x4000_0000; @@ -284,15 +306,3 @@ bitflags! { const REMOTE_CONTROL_PERSISTENT = 0x0010_0000; } } - -#[derive(Debug, Error)] -pub enum NetworkDataError { - #[error("IO error")] - IOError(#[from] io::Error), - #[error("UTF-8 error")] - Utf8Error(#[from] str::Utf8Error), - #[error("invalid channel options field")] - InvalidChannelOptions, - #[error("invalid channel count field")] - InvalidChannelCount, -} diff --git a/crates/ironrdp-pdu/src/gcc/security_data.rs b/crates/ironrdp-pdu/src/gcc/security_data.rs index cb269a847f..5433f41435 100644 --- a/crates/ironrdp-pdu/src/gcc/security_data.rs +++ b/crates/ironrdp-pdu/src/gcc/security_data.rs @@ -1,13 +1,10 @@ -use std::io; - use bitflags::bitflags; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, - ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; -use thiserror::Error; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; const CLIENT_ENCRYPTION_METHODS_SIZE: usize = 4; const CLIENT_EXT_ENCRYPTION_METHODS_SIZE: usize = 4; @@ -20,6 +17,7 @@ const SERVER_RANDOM_LEN: usize = 0x20; const MAX_SERVER_CERT_LEN: usize = 1024; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientSecurityData { pub encryption_methods: EncryptionMethod, pub ext_encryption_methods: u32, @@ -73,6 +71,7 @@ impl<'de> Decode<'de> for ClientSecurityData { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ServerSecurityData { pub encryption_method: EncryptionMethod, pub encryption_level: EncryptionLevel, @@ -100,11 +99,14 @@ impl Encode for ServerSecurityData { ensure_size!(in: dst, size: self.size()); dst.write_u32(self.encryption_method.bits()); - dst.write_u32(self.encryption_level.to_u32().unwrap()); + dst.write_u32(self.encryption_level.as_u32()); if self.encryption_method.is_empty() && self.encryption_level == EncryptionLevel::None { if self.server_random.is_some() || !self.server_cert.is_empty() { - Err(invalid_field_err!("serverRandom", "An encryption method and encryption level is none, but the server random or certificate is not empty")) + Err(invalid_field_err!( + "serverRandom", + "An encryption method and encryption level is none, but the server random or certificate is not empty" + )) } else { Ok(()) } @@ -189,6 +191,7 @@ impl<'de> Decode<'de> for ServerSecurityData { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct EncryptionMethod: u32 { const BIT_40 = 0x0000_0001; const BIT_128 = 0x0000_0002; @@ -197,7 +200,8 @@ bitflags! { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum EncryptionLevel { None = 0, Low = 1, @@ -206,18 +210,12 @@ pub enum EncryptionLevel { Fips = 4, } -#[derive(Debug, Error)] -pub enum SecurityDataError { - #[error("IO error")] - IOError(#[from] io::Error), - #[error("invalid encryption methods field")] - InvalidEncryptionMethod, - #[error("invalid encryption level field")] - InvalidEncryptionLevel, - #[error("invalid server random length field: {0}")] - InvalidServerRandomLen(u32), - #[error("invalid input: {0}")] - InvalidInput(String), - #[error("invalid server certificate length: {0}")] - InvalidServerCertificateLen(u32), +impl EncryptionLevel { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } } diff --git a/crates/ironrdp-pdu/src/geometry.rs b/crates/ironrdp-pdu/src/geometry.rs index 094619d8d0..7b697fc6a6 100644 --- a/crates/ironrdp-pdu/src/geometry.rs +++ b/crates/ironrdp-pdu/src/geometry.rs @@ -1,6 +1,6 @@ use core::cmp::{max, min}; -use ironrdp_core::{ensure_fixed_part_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor}; +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size}; pub(crate) mod private { pub struct BaseRectangle { @@ -90,6 +90,7 @@ pub trait Rectangle: RectangleImpl { /// This struct is defined as an **inclusive** rectangle. /// That is, the pixel at coordinate (right, bottom) is included in the rectangle. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct InclusiveRectangle { pub left: u16, pub top: u16, @@ -101,6 +102,7 @@ pub struct InclusiveRectangle { /// This struct is defined as an **exclusive** rectangle. /// That is, the pixel at coordinate (right, bottom) is not included in the rectangle. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ExclusiveRectangle { pub left: u16, pub top: u16, @@ -149,10 +151,12 @@ impl_rectangle!(InclusiveRectangle); impl_rectangle!(ExclusiveRectangle); impl Rectangle for InclusiveRectangle { + /// INVARIANT: `0 < output (width)` fn width(&self) -> u16 { self.right - self.left + 1 } + /// INVARIANT: `0 < output (height)` fn height(&self) -> u16 { self.bottom - self.top + 1 } diff --git a/crates/ironrdp-pdu/src/input/fast_path.rs b/crates/ironrdp-pdu/src/input/fast_path.rs index 705d4dfaf9..28c6767b0c 100644 --- a/crates/ironrdp-pdu/src/input/fast_path.rs +++ b/crates/ironrdp-pdu/src/input/fast_path.rs @@ -1,11 +1,11 @@ use bit_field::BitField as _; use bitflags::bitflags; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, other_err, Decode, DecodeResult, Encode, - EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, other_err, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use crate::fast_path::EncryptionFlags; use crate::input::{MousePdu, MouseRelPdu, MouseXPdu}; @@ -13,6 +13,7 @@ use crate::per; /// Implements the Fast-Path RDP message header PDU. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct FastPathInputHeader { pub flags: EncryptionFlags, pub data_length: usize, @@ -51,9 +52,7 @@ impl Encode for FastPathInputHeader { fn size(&self) -> usize { let num_events_length = if self.num_events < 16 { 0 } else { 1 }; - Self::FIXED_PART_SIZE - + per::sizeof_length(self.data_length as u16 + num_events_length as u16 + 1) - + num_events_length + Self::FIXED_PART_SIZE + per::sizeof_length(self.data_length + num_events_length + 1) + num_events_length } } @@ -62,7 +61,7 @@ impl<'de> Decode<'de> for FastPathInputHeader { ensure_fixed_part_size!(in: src); let header = src.read_u8(); - let flags = EncryptionFlags::from_bits_truncate(header.get_bits(6..8)); + let flags = EncryptionFlags::from_bits_retain(header.get_bits(6..8)); let mut num_events = header.get_bits(2..6); let (length, sizeof_length) = per::read_length(src).map_err(|e| other_err!("perLen", source: e))?; @@ -78,7 +77,7 @@ impl<'de> Decode<'de> for FastPathInputHeader { 0 }; - let data_length = length as usize - sizeof_length - 1 - num_events_length; + let data_length = usize::from(length) - sizeof_length - 1 - num_events_length; Ok(FastPathInputHeader { flags, @@ -88,7 +87,8 @@ impl<'de> Decode<'de> for FastPathInputHeader { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[repr(u8)] pub enum FastpathInputEventType { ScanCode = 0x0000, @@ -100,7 +100,18 @@ pub enum FastpathInputEventType { QoeTimestamp = 0x0006, } -#[derive(Debug, Clone, PartialEq, Eq)] +impl FastpathInputEventType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum FastPathInputEvent { KeyboardEvent(KeyboardFlags, u8), UnicodeKeyboardEvent(KeyboardFlags, u16), @@ -132,7 +143,7 @@ impl Encode for FastPathInputEvent { FastPathInputEvent::SyncEvent(flags) => (flags.bits(), FastpathInputEventType::Sync), }; header.set_bits(0..5, flags); - header.set_bits(5..8, code.to_u8().unwrap()); + header.set_bits(5..8, code.as_u8()); dst.write_u8(header); match self { FastPathInputEvent::KeyboardEvent(_, code) => { @@ -227,6 +238,7 @@ impl<'de> Decode<'de> for FastPathInputEvent { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct KeyboardFlags: u8 { const RELEASE = 0x01; const EXTENDED = 0x02; @@ -236,6 +248,7 @@ bitflags! { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SynchronizeFlags: u8 { const SCROLL_LOCK = 0x01; const NUM_LOCK = 0x02; @@ -245,10 +258,46 @@ bitflags! { } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct FastPathInput(pub Vec); +pub struct FastPathInput( + /// INVARIANT: (1..=255).contains(len()) = at least one, and at most 255 elements. + Vec, +); + +// Hand-rolled because `derive(Arbitrary)` cannot encode the 1..=255 length +// invariant. Without this constraint, encode()/size() panic via u8::try_from +// on out-of-range lengths under fuzz. +#[cfg(feature = "arbitrary")] +impl<'a> arbitrary::Arbitrary<'a> for FastPathInput { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + let len = u.int_in_range::(1..=255)?; + let mut events = Vec::with_capacity(len); + for _ in 0..len { + events.push(FastPathInputEvent::arbitrary(u)?); + } + Ok(Self(events)) + } +} impl FastPathInput { const NAME: &'static str = "FastPathInput"; + + pub fn new(input_events: Vec) -> DecodeResult { + // Ensure the invariant on `input_events.len()` is respected. + if !(1..=255usize).contains(&input_events.len()) { + return Err(invalid_field_err!("nEvents", "invalid number of input events")); + } + + Ok(Self(input_events)) + } + + pub fn single(input_event: FastPathInputEvent) -> Self { + // A single element upholds the invariant. + Self(vec![input_event]) + } + + pub fn input_events(&self) -> &[FastPathInputEvent] { + &self.0 + } } impl Encode for FastPathInput { @@ -261,7 +310,7 @@ impl Encode for FastPathInput { let data_length = self.0.iter().map(Encode::size).sum::(); let header = FastPathInputHeader { - num_events: self.0.len() as u8, + num_events: u8::try_from(self.0.len()).expect("per invariant (1..=255).contains(num_events.len())"), flags: EncryptionFlags::empty(), data_length, }; @@ -281,7 +330,8 @@ impl Encode for FastPathInput { fn size(&self) -> usize { let data_length = self.0.iter().map(Encode::size).sum::(); let header = FastPathInputHeader { - num_events: self.0.len() as u8, + num_events: u8::try_from(self.0.len()) + .expect("INVARIANT: num_events is within the range of 1 to 255, inclusive"), flags: EncryptionFlags::empty(), data_length, }; @@ -292,10 +342,10 @@ impl Encode for FastPathInput { impl<'de> Decode<'de> for FastPathInput { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { let header = FastPathInputHeader::decode(src)?; - let events = (0..header.num_events) - .map(|_| FastPathInputEvent::decode(src)) + let events = core::iter::repeat_with(|| FastPathInputEvent::decode(src)) + .take(usize::from(header.num_events)) .collect::, _>>()?; - Ok(Self(events)) + Self::new(events) } } diff --git a/crates/ironrdp-pdu/src/input/mod.rs b/crates/ironrdp-pdu/src/input/mod.rs index a3d168d209..1e73147d5e 100644 --- a/crates/ironrdp-pdu/src/input/mod.rs +++ b/crates/ironrdp-pdu/src/input/mod.rs @@ -1,12 +1,9 @@ -use std::io; - use ironrdp_core::{ - ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, write_padding, Decode, DecodeResult, Encode, - EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, read_padding, write_padding, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; -use thiserror::Error; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; pub mod fast_path; pub mod mouse; @@ -26,6 +23,7 @@ pub use self::unicode::UnicodePdu; pub use self::unused::UnusedPdu; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct InputEventPdu(pub Vec); impl InputEventPdu { @@ -38,7 +36,7 @@ impl Encode for InputEventPdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - dst.write_u16(self.0.len() as u16); + dst.write_u16(cast_length!("input events count", self.0.len())?); write_padding!(dst, 2); for event in self.0.iter() { @@ -61,11 +59,11 @@ impl<'de> Decode<'de> for InputEventPdu { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let number_of_events = src.read_u16(); + let number_of_events = usize::from(src.read_u16()); read_padding!(src, 2); - let events = (0..number_of_events) - .map(|_| InputEvent::decode(src)) + let events = core::iter::repeat_with(|| InputEvent::decode(src)) + .take(number_of_events) .collect::, _>>()?; Ok(Self(events)) @@ -73,6 +71,7 @@ impl<'de> Decode<'de> for InputEventPdu { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum InputEvent { Sync(SyncPdu), Unused(UnusedPdu), @@ -94,7 +93,7 @@ impl Encode for InputEvent { ensure_fixed_part_size!(in: dst); dst.write_u32(0); // event time is ignored by a server - dst.write_u16(InputEventType::from(self).to_u16().unwrap()); + dst.write_u16(InputEventType::from(self).as_u16()); match self { Self::Sync(pdu) => pdu.encode(dst), @@ -146,7 +145,7 @@ impl<'de> Decode<'de> for InputEvent { } } -#[derive(Debug, Copy, Clone, PartialEq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, FromPrimitive)] #[repr(u16)] enum InputEventType { Sync = 0x0000, @@ -158,6 +157,16 @@ enum InputEventType { MouseRel = 0x8004, } +impl InputEventType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + impl From<&InputEvent> for InputEventType { fn from(event: &InputEvent) -> Self { match event { @@ -171,21 +180,3 @@ impl From<&InputEvent> for InputEventType { } } } - -#[derive(Debug, Error)] -pub enum InputEventError { - #[error("IO error")] - IOError(#[from] io::Error), - #[error("invalid Input Event type: {0}")] - InvalidInputEventType(u16), - #[error("encryption not supported")] - EncryptionNotSupported, - #[error("event code not supported {0}")] - EventCodeUnsupported(u8), - #[error("keyboard flags not supported {0}")] - KeyboardFlagsUnsupported(u8), - #[error("synchronize flags not supported {0}")] - SynchronizeFlagsUnsupported(u8), - #[error("Fast-Path Input Event PDU is empty")] - EmptyFastPathInput, -} diff --git a/crates/ironrdp-pdu/src/input/mouse.rs b/crates/ironrdp-pdu/src/input/mouse.rs index 6f8fc1594c..0bbbd065dc 100644 --- a/crates/ironrdp-pdu/src/input/mouse.rs +++ b/crates/ironrdp-pdu/src/input/mouse.rs @@ -1,7 +1,8 @@ use bitflags::bitflags; -use ironrdp_core::{ensure_fixed_part_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor}; +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size}; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct MousePdu { pub flags: PointerFlags, pub number_of_wheel_rotation_units: i16, @@ -25,7 +26,22 @@ impl Encode for MousePdu { PointerFlags::empty().bits() }; - let wheel_rotations_bits = u16::from(self.number_of_wheel_rotation_units as u8); // truncate + // The wire field is 9-bit two's complement: representable range is + // [-256, 255], narrower than i16. + debug_assert!( + (-256..=255).contains(&self.number_of_wheel_rotation_units), + "number_of_wheel_rotation_units out of the 9-bit two's-complement range [-256, 255]: {}", + self.number_of_wheel_rotation_units + ); + + #[expect( + clippy::as_conversions, + clippy::cast_sign_loss, + clippy::cast_possible_truncation, + reason = "truncation intended" + )] + let truncated_wheel_rotation_units = self.number_of_wheel_rotation_units as u8; + let wheel_rotations_bits = u16::from(truncated_wheel_rotation_units); let flags = self.flags.bits() | wheel_negative_bit | wheel_rotations_bits; @@ -51,12 +67,24 @@ impl<'de> Decode<'de> for MousePdu { let flags_raw = src.read_u16(); - let flags = PointerFlags::from_bits_truncate(flags_raw); - - let wheel_rotations_bits = flags_raw as u8; // truncate - + let flags = PointerFlags::from_bits_retain(flags_raw); + + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "truncation intended" + )] + let wheel_rotations_bits = flags_raw as u8; + + // Per MS-RDPBCGR 2.2.8.1.1.3.1.1.3, WheelRotationMask (0x01FF) is a 9-bit + // TWO'S-COMPLEMENT field: WHEEL_NEGATIVE (0x0100) is the sign bit of that + // 9-bit value, not an independent "negate this magnitude" flag. So a byte + // of 0xFF with WHEEL_NEGATIVE set means -1, not -255. This must mirror + // `encode` above, which already produces a proper two's-complement byte + // via a truncating cast (`self.number_of_wheel_rotation_units as u8`) — + // without this, `decode(encode(x))` does not round-trip for x < 0. let number_of_wheel_rotation_units = if flags.contains(PointerFlags::WHEEL_NEGATIVE) { - -i16::from(wheel_rotations_bits) + i16::from(wheel_rotations_bits) - 0x100 } else { i16::from(wheel_rotations_bits) }; @@ -74,6 +102,7 @@ impl<'de> Decode<'de> for MousePdu { } bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct PointerFlags: u16 { const WHEEL_NEGATIVE = 0x0100; const VERTICAL_WHEEL = 0x0200; @@ -83,5 +112,56 @@ bitflags! { const RIGHT_BUTTON = 0x2000; const MIDDLE_BUTTON_OR_WHEEL = 0x4000; const DOWN = 0x8000; + + const _ = !0; + } +} + +#[cfg(test)] +mod tests { + use ironrdp_core::{decode, encode_vec}; + + use super::*; + + fn mouse_pdu(number_of_wheel_rotation_units: i16) -> MousePdu { + MousePdu { + flags: PointerFlags::VERTICAL_WHEEL, + number_of_wheel_rotation_units, + x_position: 0, + y_position: 0, + } + } + + #[test] + fn wheel_rotation_units_round_trip_through_encode_decode() { + // Every representable value must survive an encode/decode round trip. + // This previously failed for small negative values: encode(-1) produced + // byte 0xFF + WHEEL_NEGATIVE, which decode incorrectly read back as -255 + // (sign-magnitude) instead of -1 (two's complement, matching encode). + // + // The wire field is 9-bit two's complement, so its representable domain + // is [-256, 255] (wider than i8, narrower than i16) — iterate that exact + // range rather than i8::MIN..=i8::MAX so this test documents (and checks) + // the real contract, not an arbitrary subset of it. + for value in -256i16..=255i16 { + let pdu = mouse_pdu(value); + let buffer = encode_vec(&pdu).unwrap(); + let decoded: MousePdu = decode(buffer.as_slice()).unwrap(); + assert_eq!( + decoded.number_of_wheel_rotation_units, value, + "round trip failed for {value}" + ); + } + } + + #[test] + fn small_negative_wheel_rotation_decodes_correctly() { + // WHEEL_NEGATIVE set, byte = 0xFF -> true value is -1 (two's complement: + // byte - 0x100), NOT -255 (sign-magnitude: -byte). + let flags = (PointerFlags::VERTICAL_WHEEL | PointerFlags::WHEEL_NEGATIVE).bits() | 0x00FF; + let mut buffer = [0u8; 6]; + buffer[0..2].copy_from_slice(&flags.to_le_bytes()); + let pdu: MousePdu = decode(buffer.as_slice()).unwrap(); + assert_eq!(pdu.number_of_wheel_rotation_units, -1); } } diff --git a/crates/ironrdp-pdu/src/input/mouse_rel.rs b/crates/ironrdp-pdu/src/input/mouse_rel.rs index 47f1068d25..1c50dbe715 100644 --- a/crates/ironrdp-pdu/src/input/mouse_rel.rs +++ b/crates/ironrdp-pdu/src/input/mouse_rel.rs @@ -1,7 +1,8 @@ use bitflags::bitflags; -use ironrdp_core::{ensure_fixed_part_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor}; +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size}; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct MouseRelPdu { pub flags: PointerRelFlags, pub x_delta: i16, @@ -38,7 +39,7 @@ impl<'de> Decode<'de> for MouseRelPdu { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let flags = PointerRelFlags::from_bits_truncate(src.read_u16()); + let flags = PointerRelFlags::from_bits_retain(src.read_u16()); let x_delta = src.read_i16(); let y_delta = src.read_i16(); @@ -52,6 +53,7 @@ impl<'de> Decode<'de> for MouseRelPdu { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct PointerRelFlags: u16 { const MOVE = 0x0800; const DOWN = 0x8000; @@ -60,5 +62,7 @@ bitflags! { const BUTTON3 = 0x4000; const XBUTTON1 = 0x0001; const XBUTTON2 = 0x0002; + + const _ = !0; } } diff --git a/crates/ironrdp-pdu/src/input/mouse_x.rs b/crates/ironrdp-pdu/src/input/mouse_x.rs index 10937cf846..76625ffdcc 100644 --- a/crates/ironrdp-pdu/src/input/mouse_x.rs +++ b/crates/ironrdp-pdu/src/input/mouse_x.rs @@ -1,7 +1,8 @@ use bitflags::bitflags; -use ironrdp_core::{ensure_fixed_part_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor}; +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size}; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct MouseXPdu { pub flags: PointerXFlags, pub x_position: u16, @@ -38,7 +39,7 @@ impl<'de> Decode<'de> for MouseXPdu { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let flags = PointerXFlags::from_bits_truncate(src.read_u16()); + let flags = PointerXFlags::from_bits_retain(src.read_u16()); let x_position = src.read_u16(); let y_position = src.read_u16(); @@ -52,9 +53,12 @@ impl<'de> Decode<'de> for MouseXPdu { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct PointerXFlags: u16 { const DOWN = 0x8000; const BUTTON1 = 0x0001; const BUTTON2 = 0x0002; + + const _ = !0; } } diff --git a/crates/ironrdp-pdu/src/input/scan_code.rs b/crates/ironrdp-pdu/src/input/scan_code.rs index 0798866c45..e6154ee462 100644 --- a/crates/ironrdp-pdu/src/input/scan_code.rs +++ b/crates/ironrdp-pdu/src/input/scan_code.rs @@ -1,10 +1,11 @@ use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, read_padding, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, read_padding, + write_padding, }; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ScanCodePdu { pub flags: KeyboardFlags, pub key_code: u16, @@ -40,7 +41,7 @@ impl<'de> Decode<'de> for ScanCodePdu { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let flags = KeyboardFlags::from_bits_truncate(src.read_u16()); + let flags = KeyboardFlags::from_bits_retain(src.read_u16()); let key_code = src.read_u16(); read_padding!(src, 2); @@ -50,10 +51,13 @@ impl<'de> Decode<'de> for ScanCodePdu { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct KeyboardFlags: u16 { const EXTENDED = 0x0100; const EXTENDED_1 = 0x0200; const DOWN = 0x4000; const RELEASE = 0x8000; + + const _ = !0; } } diff --git a/crates/ironrdp-pdu/src/input/sync.rs b/crates/ironrdp-pdu/src/input/sync.rs index 814539378f..7fc3f87f60 100644 --- a/crates/ironrdp-pdu/src/input/sync.rs +++ b/crates/ironrdp-pdu/src/input/sync.rs @@ -1,10 +1,11 @@ use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, read_padding, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, read_padding, + write_padding, }; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SyncPdu { pub flags: SyncToggleFlags, } @@ -39,7 +40,7 @@ impl<'de> Decode<'de> for SyncPdu { ensure_fixed_part_size!(in: src); read_padding!(src, 2); - let flags = SyncToggleFlags::from_bits_truncate(src.read_u32()); + let flags = SyncToggleFlags::from_bits_retain(src.read_u32()); Ok(Self { flags }) } @@ -47,10 +48,13 @@ impl<'de> Decode<'de> for SyncPdu { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SyncToggleFlags: u32 { const SCROLL_LOCK = 0x1; const NUM_LOCK = 0x2; const CAPS_LOCK = 0x4; const KANA_LOCK = 0x8; + + const _ = !0; } } diff --git a/crates/ironrdp-pdu/src/input/unicode.rs b/crates/ironrdp-pdu/src/input/unicode.rs index 37ce8d67a1..f99dede6e1 100644 --- a/crates/ironrdp-pdu/src/input/unicode.rs +++ b/crates/ironrdp-pdu/src/input/unicode.rs @@ -1,10 +1,11 @@ use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, read_padding, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, read_padding, + write_padding, }; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct UnicodePdu { pub flags: KeyboardFlags, pub unicode_code: u16, @@ -40,7 +41,7 @@ impl<'de> Decode<'de> for UnicodePdu { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let flags = KeyboardFlags::from_bits_truncate(src.read_u16()); + let flags = KeyboardFlags::from_bits_retain(src.read_u16()); let unicode_code = src.read_u16(); read_padding!(src, 2); @@ -50,7 +51,10 @@ impl<'de> Decode<'de> for UnicodePdu { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct KeyboardFlags: u16 { const RELEASE = 0x8000; + + const _ = !0; } } diff --git a/crates/ironrdp-pdu/src/input/unused.rs b/crates/ironrdp-pdu/src/input/unused.rs index 8e3a00ff3a..59abab076e 100644 --- a/crates/ironrdp-pdu/src/input/unused.rs +++ b/crates/ironrdp-pdu/src/input/unused.rs @@ -1,9 +1,10 @@ use ironrdp_core::{ - ensure_fixed_part_size, read_padding, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, read_padding, + write_padding, }; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct UnusedPdu; impl UnusedPdu { diff --git a/crates/ironrdp-pdu/src/lib.rs b/crates/ironrdp-pdu/src/lib.rs index c6e2dadce5..031a95fa18 100644 --- a/crates/ironrdp-pdu/src/lib.rs +++ b/crates/ironrdp-pdu/src/lib.rs @@ -1,10 +1,6 @@ #![cfg_attr(doc, doc = include_str!("../README.md"))] #![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] #![allow(clippy::arithmetic_side_effects)] // FIXME: remove -#![allow(clippy::cast_lossless)] // FIXME: remove -#![allow(clippy::cast_possible_truncation)] // FIXME: remove -#![allow(clippy::cast_possible_wrap)] // FIXME: remove -#![allow(clippy::cast_sign_loss)] // FIXME: remove use core::fmt; @@ -33,8 +29,7 @@ pub(crate) mod ber; pub(crate) mod crypto; pub(crate) mod per; -pub use crate::basic_output::{bitmap, fast_path, pointer, surface_commands}; -pub use crate::rdp::vc::dvc; +pub use crate::basic_output::{bitmap, fast_path, pointer, slow_path, surface_commands}; pub type PduResult = Result; @@ -55,10 +50,12 @@ pub trait PduErrorExt { } impl PduErrorExt for PduError { + #[track_caller] fn decode(context: &'static str, source: E) -> Self { Self::new(context, PduErrorKind::Decode).with_source(source) } + #[track_caller] fn encode(context: &'static str, source: E) -> Self { Self::new(context, PduErrorKind::Encode).with_source(source) } @@ -104,6 +101,10 @@ impl Action { } } + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] pub fn as_u8(self) -> u8 { self as u8 } @@ -252,9 +253,7 @@ macro_rules! custom_err { ) .with_source($source) }}; - ( $source:expr $(,)? ) => {{ - $crate::custom_err!($crate::function!(), $source) - }}; + ( $source:expr $(,)? ) => {{ $crate::custom_err!($crate::function!(), $source) }}; } #[doc(hidden)] diff --git a/crates/ironrdp-pdu/src/macros.rs b/crates/ironrdp-pdu/src/macros.rs index 2bbfda73b0..9b5aaf4ddf 100644 --- a/crates/ironrdp-pdu/src/macros.rs +++ b/crates/ironrdp-pdu/src/macros.rs @@ -48,6 +48,7 @@ macro_rules! const_assert { }; } +// TODO: move to ironrdp-core crate. /// Implements additional traits for a plain old data structure (POD). #[macro_export] macro_rules! impl_pdu_pod { @@ -88,6 +89,7 @@ macro_rules! impl_x224_pdu_pod { }; } +// TODO: move to ironrdp-core crate. /// Implements additional traits for a borrowing PDU and defines a static-bounded owned version. #[macro_export] macro_rules! impl_pdu_borrowing { diff --git a/crates/ironrdp-pdu/src/mcs.rs b/crates/ironrdp-pdu/src/mcs.rs index 9e7cc97d56..679567cd2a 100644 --- a/crates/ironrdp-pdu/src/mcs.rs +++ b/crates/ironrdp-pdu/src/mcs.rs @@ -1,15 +1,80 @@ use std::borrow::Cow; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, other_err, read_padding, - unexpected_message_type_err, IntoOwned, ReadCursor, WriteCursor, + Decode, Encode, IntoOwned, ReadCursor, WriteBuf, WriteCursor, cast_length, decode, encode_buf, encode_vec, + ensure_fixed_part_size, ensure_size, invalid_field_err, other_err, read_padding, unexpected_message_type_err, }; use crate::gcc::{ChannelDef, ClientGccBlocks, ConferenceCreateRequest, ConferenceCreateResponse}; use crate::tpdu::{TpduCode, TpduHeader}; use crate::tpkt::TpktHeader; -use crate::x224::{user_data_size, X224Pdu}; -use crate::{impl_x224_pdu_borrowing, impl_x224_pdu_pod, per, DecodeResult, EncodeResult, PduError}; +use crate::x224::{X224, X224Pdu, user_data_size}; +use crate::{DecodeResult, EncodeResult, impl_x224_pdu_borrowing, impl_x224_pdu_pod, per}; + +/// Encodes an arbitrary PDU as the user data of an MCS [`SendDataRequest`], wrapped in an X.224 data PDU. +pub fn encode_send_data_request( + initiator_id: u16, + channel_id: u16, + user_msg: &T, + buf: &mut WriteBuf, +) -> EncodeResult +where + T: Encode, +{ + let user_data = encode_vec(user_msg)?; + + let pdu = SendDataRequest { + initiator_id, + channel_id, + user_data: Cow::Owned(user_data), + }; + + let written = encode_buf(&X224(pdu), buf)?; + + Ok(written) +} + +/// The user data carried by an MCS Send Data Indication, along with its channel routing information. +#[derive(Debug, Clone, Copy)] +pub struct SendDataIndicationCtx<'a> { + pub initiator_id: u16, + pub channel_id: u16, + pub user_data: &'a [u8], +} + +impl<'a> SendDataIndicationCtx<'a> { + pub fn decode_user_data<'de, T>(&self) -> DecodeResult + where + T: Decode<'de>, + 'a: 'de, + { + decode::(self.user_data) + } +} + +/// Decodes an X.224-wrapped MCS Send Data Indication and returns its [`SendDataIndicationCtx`]. +pub fn decode_send_data_indication(src: &[u8]) -> DecodeResult> { + let mcs_msg = decode::>>(src)?; + + match mcs_msg.0 { + McsMessage::SendDataIndication(msg) => { + let Cow::Borrowed(user_data) = msg.user_data else { + unreachable!() + }; + + Ok(SendDataIndicationCtx { + initiator_id: msg.initiator_id, + channel_id: msg.channel_id, + user_data, + }) + } + McsMessage::DisconnectProviderUltimatum(_) => Err(other_err!( + "decode_send_data_indication", + "received disconnect provider ultimatum" + )), + _ => Err(other_err!("decode_send_data_indication", "unexpected MCS message")), + } +} // T.125 MCS is defined in: // @@ -142,9 +207,7 @@ const SEND_DATA_PDU_DATA_PRIORITY_AND_SEGMENTATION: u8 = 0x70; /// |e| ::invalid_field(Self::MCS_NAME, field_name, "PER").with_source(e) /// ``` macro_rules! per_field_err { - ($field_name:expr) => {{ - |error| ironrdp_core::invalid_field_err_with_source(Self::MCS_NAME, $field_name, "PER", error) - }}; + ($field_name:expr) => {{ |error| ironrdp_core::invalid_field_err_with_source(Self::MCS_NAME, $field_name, "PER", error) }}; } #[doc(hidden)] @@ -232,6 +295,10 @@ impl DomainMcsPdu { } } + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] fn as_u8(self) -> u8 { self as u8 } @@ -262,6 +329,7 @@ fn write_mcspdu_header(dst: &mut WriteCursor<'_>, domain_mcspdu: DomainMcsPdu, o /// The kind of the RDP header message that may carry additional data. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum McsMessage<'a> { ErectDomainRequest(ErectDomainPdu), AttachUserRequest(AttachUserRequest), @@ -369,6 +437,7 @@ impl<'de> McsPdu<'de> for McsMessage<'de> { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ErectDomainPdu { pub sub_height: u32, pub sub_interval: u32, @@ -406,6 +475,7 @@ impl<'de> McsPdu<'de> for ErectDomainPdu { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct AttachUserRequest; impl_x224_pdu_pod!(AttachUserRequest); @@ -431,6 +501,7 @@ impl<'de> McsPdu<'de> for AttachUserRequest { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct AttachUserConfirm { pub result: u8, pub initiator_id: u16, @@ -468,6 +539,7 @@ impl<'de> McsPdu<'de> for AttachUserConfirm { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ChannelJoinRequest { pub initiator_id: u16, pub channel_id: u16, @@ -505,6 +577,7 @@ impl<'de> McsPdu<'de> for ChannelJoinRequest { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ChannelJoinConfirm { pub result: u8, pub initiator_id: u16, @@ -550,6 +623,7 @@ impl<'de> McsPdu<'de> for ChannelJoinConfirm { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SendDataRequest<'a> { pub initiator_id: u16, pub channel_id: u16, @@ -622,15 +696,12 @@ impl<'de> McsPdu<'de> for SendDataRequest<'de> { } fn mcs_size(&self) -> usize { - per::CHOICE_SIZE - + per::U16_SIZE * 2 - + 1 - + per::sizeof_length(u16::try_from(self.user_data.len()).unwrap_or(u16::MAX)) - + self.user_data.len() + per::CHOICE_SIZE + per::U16_SIZE * 2 + 1 + per::sizeof_length(self.user_data.len()) + self.user_data.len() } } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SendDataIndication<'a> { pub initiator_id: u16, pub channel_id: u16, @@ -703,16 +774,13 @@ impl<'de> McsPdu<'de> for SendDataIndication<'de> { } fn mcs_size(&self) -> usize { - per::CHOICE_SIZE - + per::U16_SIZE * 2 - + 1 - + per::sizeof_length(u16::try_from(self.user_data.len()).unwrap_or(u16::MAX)) - + self.user_data.len() + per::CHOICE_SIZE + per::U16_SIZE * 2 + 1 + per::sizeof_length(self.user_data.len()) + self.user_data.len() } } /// The reason of `DisconnectProviderUltimatum`. #[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[repr(u8)] pub enum DisconnectReason { DomainDisconnected = 0, @@ -723,7 +791,11 @@ pub enum DisconnectReason { } impl DisconnectReason { - pub fn as_u8(self) -> u8 { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { self as u8 } @@ -756,6 +828,7 @@ impl core::fmt::Display for DisconnectReason { } #[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct DisconnectProviderUltimatum { pub reason: DisconnectReason, } @@ -836,6 +909,7 @@ impl<'de> McsPdu<'de> for DisconnectProviderUltimatum { } #[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ConnectInitial { pub conference_create_request: ConferenceCreateRequest, pub calling_domain_selector: Vec, @@ -847,24 +921,25 @@ pub struct ConnectInitial { } impl ConnectInitial { - pub fn with_gcc_blocks(gcc_blocks: ClientGccBlocks) -> Self { - Self { - conference_create_request: ConferenceCreateRequest { gcc_blocks }, + pub fn with_gcc_blocks(gcc_blocks: ClientGccBlocks) -> DecodeResult { + Ok(Self { + conference_create_request: ConferenceCreateRequest::new(gcc_blocks)?, calling_domain_selector: vec![0x01], called_domain_selector: vec![0x01], upward_flag: true, target_parameters: DomainParameters::target(), min_parameters: DomainParameters::min(), max_parameters: DomainParameters::max(), - } + }) } pub fn channel_names(&self) -> Option> { - self.conference_create_request.gcc_blocks.channel_names() + self.conference_create_request.gcc_blocks().channel_names() } } #[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ConnectResponse { pub conference_create_response: ConferenceCreateResponse, pub called_connect_id: u32, @@ -873,15 +948,16 @@ pub struct ConnectResponse { impl ConnectResponse { pub fn channel_ids(&self) -> Vec { - self.conference_create_response.gcc_blocks.channel_ids() + self.conference_create_response.gcc_blocks().channel_ids() } pub fn global_channel_id(&self) -> u16 { - self.conference_create_response.gcc_blocks.global_channel_id() + self.conference_create_response.gcc_blocks().global_channel_id() } } #[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct DomainParameters { pub max_channel_ids: u32, pub max_user_ids: u32, @@ -934,20 +1010,20 @@ impl DomainParameters { } } -pub use legacy::McsError; - mod legacy { - use std::io; + #![allow( + clippy::multiple_inherent_impl, + reason = "Cannot move the implementation from the legacy module" + )] - use ironrdp_core::{Decode, DecodeResult, Encode}; - use thiserror::Error; + use ironrdp_core::{Decode, DecodeResult, Encode, cast_int}; use super::{ - cast_length, ensure_size, ConnectInitial, ConnectResponse, DomainParameters, PduError, ReadCursor, WriteCursor, - RESULT_ENUM_LENGTH, + ConnectInitial, ConnectResponse, DomainParameters, RESULT_ENUM_LENGTH, ReadCursor, WriteCursor, cast_length, + ensure_size, }; - use crate::gcc::{ConferenceCreateRequest, ConferenceCreateResponse, GccError}; - use crate::{ber, EncodeResult}; + use crate::gcc::{ConferenceCreateRequest, ConferenceCreateResponse}; + use crate::{EncodeResult, ber}; // impl<'de> McsPdu<'de> for ConnectInitial { // const MCS_NAME: &'static str = "DisconnectProviderUltimatum"; @@ -972,11 +1048,15 @@ mod legacy { const NAME: &'static str = "ConnectInitial"; fn fields_buffer_ber_length(&self) -> usize { - ber::sizeof_octet_string(self.calling_domain_selector.len() as u16) - + ber::sizeof_octet_string(self.called_domain_selector.len() as u16) - + ber::SIZEOF_BOOL - + (self.target_parameters.size() + self.min_parameters.size() + self.max_parameters.size()) - + ber::sizeof_octet_string(self.conference_create_request.size() as u16) + // Can't rewrite in `as`-less way, because it's used in `Encode::size` which doesn't return an error. + #[expect(clippy::cast_possible_truncation, clippy::as_conversions)] + { + ber::sizeof_octet_string(self.calling_domain_selector.len() as u16) + + ber::sizeof_octet_string(self.called_domain_selector.len() as u16) + + ber::SIZEOF_BOOL + + (self.target_parameters.size() + self.min_parameters.size() + self.max_parameters.size()) + + ber::sizeof_octet_string(self.conference_create_request.size() as u16) + } } } @@ -984,7 +1064,8 @@ mod legacy { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - ber::write_application_tag(dst, MCS_TYPE_CONNECT_INITIAL, self.fields_buffer_ber_length() as u16)?; + let field_buffer_ber_length = cast_length!("field_buffer_ber_length", self.fields_buffer_ber_length())?; + ber::write_application_tag(dst, MCS_TYPE_CONNECT_INITIAL, field_buffer_ber_length)?; ber::write_octet_string(dst, self.calling_domain_selector.as_ref())?; ber::write_octet_string(dst, self.called_domain_selector.as_ref())?; ber::write_bool(dst, self.upward_flag)?; @@ -1003,9 +1084,12 @@ mod legacy { fn size(&self) -> usize { let fields_buffer_ber_length = self.fields_buffer_ber_length(); + // Can't rewrite in `as`-less way, because it's used in `Encode::size` which doesn't return an error. + #[expect(clippy::cast_possible_truncation, clippy::as_conversions)] + let fields_buffer_ber_length_u16 = fields_buffer_ber_length as u16; fields_buffer_ber_length - + ber::sizeof_application_tag(MCS_TYPE_CONNECT_INITIAL, fields_buffer_ber_length as u16) + + ber::sizeof_application_tag(MCS_TYPE_CONNECT_INITIAL, fields_buffer_ber_length_u16) } } @@ -1037,10 +1121,14 @@ mod legacy { const NAME: &'static str = "ConnectResponse"; fn fields_buffer_ber_length(&self) -> usize { - ber::SIZEOF_ENUMERATED - + ber::sizeof_integer(self.called_connect_id) - + self.domain_parameters.size() - + ber::sizeof_octet_string(self.conference_create_response.size() as u16) + // Can't rewrite in `as`-less way, because it's used in `Encode::size` which doesn't return an error. + #[expect(clippy::cast_possible_truncation, clippy::as_conversions)] + { + ber::SIZEOF_ENUMERATED + + ber::sizeof_integer(self.called_connect_id) + + self.domain_parameters.size() + + ber::sizeof_octet_string(self.conference_create_response.size() as u16) + } } } @@ -1048,7 +1136,8 @@ mod legacy { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - ber::write_application_tag(dst, MCS_TYPE_CONNECT_RESPONSE, self.fields_buffer_ber_length() as u16)?; + let field_buffer_ber_length = cast_length!("field_buffer_ber_length", self.fields_buffer_ber_length())?; + ber::write_application_tag(dst, MCS_TYPE_CONNECT_RESPONSE, field_buffer_ber_length)?; ber::write_enumerated(dst, 0)?; ber::write_integer(dst, self.called_connect_id)?; self.domain_parameters.encode(dst)?; @@ -1064,9 +1153,11 @@ mod legacy { fn size(&self) -> usize { let fields_buffer_ber_length = self.fields_buffer_ber_length(); - + // Can't rewrite in `as`-less way, because it's used in `Encode::size` which doesn't return an error. + #[expect(clippy::cast_possible_truncation, clippy::as_conversions)] + let fields_buffer_ber_length_u16 = fields_buffer_ber_length as u16; fields_buffer_ber_length - + ber::sizeof_application_tag(MCS_TYPE_CONNECT_RESPONSE, fields_buffer_ber_length as u16) + + ber::sizeof_application_tag(MCS_TYPE_CONNECT_RESPONSE, fields_buffer_ber_length_u16) } } @@ -1074,7 +1165,7 @@ mod legacy { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ber::read_application_tag(src, MCS_TYPE_CONNECT_RESPONSE)?; ber::read_enumerated(src, RESULT_ENUM_LENGTH)?; - let called_connect_id = ber::read_integer(src)? as u32; + let called_connect_id = cast_int!("called_connect_id", ber::read_integer(src)?)?; let domain_parameters = DomainParameters::decode(src)?; let _user_data_buffer_length = ber::read_octet_string_tag(src)?; let conference_create_response = ConferenceCreateResponse::decode(src)?; @@ -1126,22 +1217,25 @@ mod legacy { fn size(&self) -> usize { let fields_buffer_ber_length = self.fields_buffer_ber_length(); + // Can't rewrite in `as`-less way, because it's used in `Encode::size` which doesn't return an error. + #[expect(clippy::cast_possible_truncation, clippy::as_conversions)] + let fields_buffer_ber_length_u16 = fields_buffer_ber_length as u16; // FIXME: maybe size should return PduResult... - fields_buffer_ber_length + ber::sizeof_sequence_tag(fields_buffer_ber_length as u16) + fields_buffer_ber_length + ber::sizeof_sequence_tag(fields_buffer_ber_length_u16) } } impl<'de> Decode<'de> for DomainParameters { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ber::read_sequence_tag(src)?; - let max_channel_ids = ber::read_integer(src)? as u32; - let max_user_ids = ber::read_integer(src)? as u32; - let max_token_ids = ber::read_integer(src)? as u32; - let num_priorities = ber::read_integer(src)? as u32; - let min_throughput = ber::read_integer(src)? as u32; - let max_height = ber::read_integer(src)? as u32; - let max_mcs_pdu_size = ber::read_integer(src)? as u32; - let protocol_version = ber::read_integer(src)? as u32; + let max_channel_ids = cast_int!("max_channel_ids", ber::read_integer(src)?)?; + let max_user_ids = cast_int!("max_user_ids", ber::read_integer(src)?)?; + let max_token_ids = cast_int!("max_token_ids", ber::read_integer(src)?)?; + let num_priorities = cast_int!("num_priorities", ber::read_integer(src)?)?; + let min_throughput = cast_int!("min_throughput", ber::read_integer(src)?)?; + let max_height = cast_int!("max_height", ber::read_integer(src)?)?; + let max_mcs_pdu_size = cast_int!("max_mcs_pdu_size", ber::read_integer(src)?)?; + let protocol_version = cast_int!("protocol_version", ber::read_integer(src)?)?; Ok(Self { max_channel_ids, @@ -1155,34 +1249,4 @@ mod legacy { }) } } - - #[derive(Debug, Error)] - pub enum McsError { - #[error("IO error")] - IOError(#[from] io::Error), - #[error("GCC block error")] - GccError(#[from] GccError), - #[error("invalid disconnect provider ultimatum")] - InvalidDisconnectProviderUltimatum, - #[error("invalid domain MCS PDU")] - InvalidDomainMcsPdu, - #[error("invalid MCS Connection Sequence PDU")] - InvalidPdu(String), - #[error("invalid invalid MCS channel id")] - UnexpectedChannelId(String), - #[error("PDU error: {0}")] - Pdu(PduError), - } - - impl From for McsError { - fn from(e: PduError) -> Self { - Self::Pdu(e) - } - } - - impl From for io::Error { - fn from(e: McsError) -> io::Error { - io::Error::other(format!("MCS Connection Sequence error: {e}")) - } - } } diff --git a/crates/ironrdp-pdu/src/nego.rs b/crates/ironrdp-pdu/src/nego.rs index abfe6f0041..f7d27e83b6 100644 --- a/crates/ironrdp-pdu/src/nego.rs +++ b/crates/ironrdp-pdu/src/nego.rs @@ -3,13 +3,13 @@ use core::fmt; use bitflags::bitflags; -use ironrdp_core::{ensure_size, invalid_field_err, unexpected_message_type_err, ReadCursor, WriteCursor}; +use ironrdp_core::{ReadCursor, WriteCursor, ensure_size, invalid_field_err, unexpected_message_type_err}; use tap::prelude::*; use crate::tpdu::{TpduCode, TpduHeader}; use crate::tpkt::TpktHeader; use crate::x224::X224Pdu; -use crate::{impl_x224_pdu_pod, DecodeResult, EncodeResult, Pdu as _}; +use crate::{DecodeResult, EncodeResult, Pdu as _, impl_x224_pdu_pod}; bitflags! { /// A 32-bit, unsigned integer that contains flags indicating the supported security protocols. @@ -17,6 +17,7 @@ bitflags! { /// Used to negotiate the security protocol to use during the Connection Initiation phase using /// the [`ConnectionConfirm`] and [`ConnectionRequest`] messages. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SecurityProtocol: u32 { /// PROTOCOL_SSL, TLS + login subsystem (winlogon.exe) const SSL = 0x0000_0001; @@ -65,6 +66,8 @@ bitflags! { const RESTRICTED_ADMIN_MODE_REQUIRED = 0x01; const REDIRECTED_AUTHENTICATION_MODE_REQUIRED = 0x02; const CORRELATION_INFO_PRESENT = 0x08; + + const _ = !0; } } @@ -81,11 +84,14 @@ bitflags! { const RDP_NEG_RSP_RESERVED = 0x04; const RESTRICTED_ADMIN_MODE_SUPPORTED = 0x08; const REDIRECTED_AUTHENTICATION_MODE_SUPPORTED = 0x10; + + const _ = !0; } } /// A 32-bit, unsigned integer that specifies the negotiation failure code #[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct FailureCode(u32); impl FailureCode { @@ -157,6 +163,7 @@ impl fmt::Display for FailureCode { /// /// * [Client X.224 Connection Request PDU](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/18a27ef9-6f9a-4501-b000-94b1fe3c2c10) #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum NegoRequestData { RoutingToken(RoutingToken), Cookie(Cookie), @@ -194,6 +201,7 @@ impl NegoRequestData { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Cookie(pub String); impl Cookie { @@ -213,6 +221,7 @@ impl Cookie { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct RoutingToken(pub String); impl RoutingToken { @@ -318,7 +327,7 @@ impl<'de> X224Pdu<'de> for ConnectionRequest { return Err(unexpected_message_type_err!(Self::NAME, u8::from(msg_type))); } - let flags = RequestFlags::from_bits_truncate(src.read_u8()); + let flags = RequestFlags::from_bits_retain(src.read_u8()); if flags.contains(RequestFlags::CORRELATION_INFO_PRESENT) { // TODO(#111): support for RDP_NEG_CORRELATION_INFO @@ -331,7 +340,7 @@ impl<'de> X224Pdu<'de> for ConnectionRequest { let _length = src.read_u16(); - let protocol = SecurityProtocol::from_bits_truncate(src.read_u32()); + let protocol = SecurityProtocol::from_bits_retain(src.read_u32()); Ok(Self { nego_data, @@ -410,9 +419,9 @@ impl<'de> X224Pdu<'de> for ConnectionConfirm { match NegoMsgType::from(src.read_u8()) { NegoMsgType::RESPONSE => { - let flags = ResponseFlags::from_bits_truncate(src.read_u8()); + let flags = ResponseFlags::from_bits_retain(src.read_u8()); let _length = src.read_u16(); - let protocol = SecurityProtocol::from_bits_truncate(src.read_u32()); + let protocol = SecurityProtocol::from_bits_retain(src.read_u32()); Ok(Self::Response { flags, protocol }) } diff --git a/crates/ironrdp-pdu/src/pcb.rs b/crates/ironrdp-pdu/src/pcb.rs index 687a4650ad..404d4e6eea 100644 --- a/crates/ironrdp-pdu/src/pcb.rs +++ b/crates/ironrdp-pdu/src/pcb.rs @@ -1,14 +1,15 @@ //! This module contains the RDP_PRECONNECTION_PDU_V1 and RDP_PRECONNECTION_PDU_V2 structures. use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, invalid_field_err_with_source, read_padding, - write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, invalid_field_err_with_source, read_padding, write_padding, }; use crate::Pdu; /// Preconnection PDU version #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct PcbVersion(pub u32); impl PcbVersion { @@ -26,6 +27,7 @@ impl PcbVersion { /// use this string and the Id field of the RDP_PRECONNECTION_PDU_V1 packet to /// determine the RDP source. This string is opaque to the protocol. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct PreconnectionBlob { /// Preconnection PDU version pub version: PcbVersion, diff --git a/crates/ironrdp-pdu/src/per.rs b/crates/ironrdp-pdu/src/per.rs index dc76a2cbe8..07afb54802 100644 --- a/crates/ironrdp-pdu/src/per.rs +++ b/crates/ironrdp-pdu/src/per.rs @@ -105,7 +105,7 @@ pub(crate) fn write_length(dst: &mut WriteCursor<'_>, length: u16) { if length > 0x7f { write_long_length(dst, length); } else { - dst.write_u8(u8::try_from(length).unwrap()); + dst.write_u8(u8::try_from(length).expect("length is guaranteed to fit into u8 due to the prior check")); } } @@ -114,12 +114,8 @@ pub(crate) fn write_long_length(dst: &mut WriteCursor<'_>, length: u16) { dst.write_u16_be(length | 0x8000) } -pub(crate) fn sizeof_length(length: u16) -> usize { - if length > 0x7f { - 2 - } else { - 1 - } +pub(crate) fn sizeof_length(length: usize) -> usize { + if length > 0x7f { 2 } else { 1 } } pub(crate) fn sizeof_long_length() -> usize { @@ -187,10 +183,10 @@ pub(crate) fn read_u32(src: &mut ReadCursor<'_>) -> Result { pub(crate) fn write_u32(dst: &mut WriteCursor<'_>, value: u32) { if value <= 0xff { write_length(dst, 1); - dst.write_u8(u8::try_from(value).unwrap()); + dst.write_u8(u8::try_from(value).expect("value is guaranteed to fit into u8 due to the prior check")); } else if value <= 0xffff { write_length(dst, 2); - dst.write_u16_be(u16::try_from(value).unwrap()); + dst.write_u16_be(u16::try_from(value).expect("value is guaranteed to fit into u16 due to the prior check")); } else { write_length(dst, 4); dst.write_u32_be(value); @@ -243,7 +239,10 @@ pub(crate) fn read_object_id(src: &mut ReadCursor<'_>) -> Result<[u8; OBJECT_ID_ } pub(crate) fn write_object_id(dst: &mut WriteCursor<'_>, object_ids: [u8; OBJECT_ID_SIZE]) { - write_length(dst, OBJECT_ID_SIZE as u16 - 1); + write_length( + dst, + u16::try_from(OBJECT_ID_SIZE).expect("OBJECT_ID_SIZE fits into u16") - 1, + ); let first_two_tuples = object_ids[0] * 40 + object_ids[1]; dst.write_u8(first_two_tuples); @@ -338,8 +337,8 @@ pub(crate) mod legacy { Ok(2) } - pub(crate) fn write_short_length(mut stream: impl io::Write, length: u16) -> io::Result { - stream.write_u8(length as u8)?; + pub(crate) fn write_short_length(mut stream: impl io::Write, length: u8) -> io::Result { + stream.write_u8(length)?; Ok(1) } @@ -347,6 +346,12 @@ pub(crate) mod legacy { if length > 0x7f { write_long_length(stream, length) } else { + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "cast is valid due to prior check" + )] + let length = length as u8; write_short_length(stream, length) } } @@ -413,12 +418,25 @@ pub(crate) mod legacy { pub(crate) fn write_u32(mut stream: impl io::Write, value: u32) -> io::Result { if value <= 0xff { let size = write_length(&mut stream, 1)?; - stream.write_u8(value as u8)?; + + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "cast is valid due to prior check" + )] + let value = value as u8; + stream.write_u8(value)?; Ok(size + 1) } else if value <= 0xffff { let size = write_length(&mut stream, 2)?; - stream.write_u16::(value as u16)?; + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "cast is valid due to prior check" + )] + let value = value as u16; + stream.write_u16::(value)?; Ok(size + 2) } else { @@ -488,7 +506,10 @@ pub(crate) mod legacy { } pub(crate) fn write_object_id(mut stream: impl io::Write, object_ids: [u8; OBJECT_ID_SIZE]) -> io::Result { - let size = write_length(&mut stream, OBJECT_ID_SIZE as u16 - 1)?; + let object_oid_size: u16 = OBJECT_ID_SIZE + .try_into() + .expect("OBJECT_ID_SIZE is known to fit into u16"); + let size = write_length(&mut stream, object_oid_size - 1)?; let first_two_tuples = object_ids[0] * 40 + object_ids[1]; stream.write_u8(first_two_tuples)?; @@ -503,7 +524,7 @@ pub(crate) mod legacy { pub(crate) fn read_octet_string(mut stream: impl io::Read, min: usize) -> io::Result> { let (read_length, _) = read_length(&mut stream)?; - let mut read_octet_string = vec![0; min + read_length as usize]; + let mut read_octet_string = vec![0; min + usize::from(read_length)]; stream.read_exact(read_octet_string.as_mut())?; Ok(read_octet_string) @@ -516,7 +537,9 @@ pub(crate) mod legacy { min }; - let size = write_length(&mut stream, length as u16)?; + let length = u16::try_from(length) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid octet string length"))?; + let size = write_length(&mut stream, length)?; stream.write_all(octet_string)?; Ok(size + octet_string.len()) @@ -527,7 +550,7 @@ pub(crate) mod legacy { let length = (read_length + min).div_ceil(2); - let mut read_numeric_string = vec![0; length as usize]; + let mut read_numeric_string = vec![0; usize::from(length)]; stream.read_exact(read_numeric_string.as_mut())?; Ok(()) @@ -536,7 +559,9 @@ pub(crate) mod legacy { pub(crate) fn write_numeric_string(mut stream: impl io::Write, num_str: &[u8], min: usize) -> io::Result { let length = if num_str.len() >= min { num_str.len() - min } else { min }; - let mut size = write_length(&mut stream, length as u16)?; + let length = u16::try_from(length) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid numeric string length"))?; + let mut size = write_length(&mut stream, length)?; let magic_transform = |elem| (elem - 0x30) % 10; diff --git a/crates/ironrdp-pdu/src/rdp.rs b/crates/ironrdp-pdu/src/rdp.rs deleted file mode 100644 index 1d28a27256..0000000000 --- a/crates/ironrdp-pdu/src/rdp.rs +++ /dev/null @@ -1,117 +0,0 @@ -use std::io; - -use ironrdp_core::{ - ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, -}; -use thiserror::Error; - -use crate::input::InputEventError; -use crate::rdp::capability_sets::CapabilitySetsError; -use crate::rdp::client_info::{ClientInfo, ClientInfoError}; -use crate::rdp::headers::{BasicSecurityHeader, BasicSecurityHeaderFlags, ShareControlPduType, ShareDataPduType}; -use crate::rdp::server_license::ServerLicenseError; -use crate::PduError; - -pub mod capability_sets; -pub mod client_info; -pub mod finalization_messages; -pub mod headers; -pub mod refresh_rectangle; -pub mod server_error_info; -pub mod server_license; -pub mod session_info; -pub mod suppress_output; -pub mod vc; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ClientInfoPdu { - pub security_header: BasicSecurityHeader, - pub client_info: ClientInfo, -} - -impl ClientInfoPdu { - const NAME: &'static str = "ClientInfoPDU"; - - const FIXED_PART_SIZE: usize = BasicSecurityHeader::FIXED_PART_SIZE + ClientInfo::FIXED_PART_SIZE; -} - -impl Encode for ClientInfoPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - self.security_header.encode(dst)?; - self.client_info.encode(dst)?; - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - self.security_header.size() + self.client_info.size() - } -} - -impl<'de> Decode<'de> for ClientInfoPdu { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let security_header = BasicSecurityHeader::decode(src)?; - if !security_header.flags.contains(BasicSecurityHeaderFlags::INFO_PKT) { - return Err(invalid_field_err!("securityHeader", "got invalid security header")); - } - - let client_info = ClientInfo::decode(src)?; - - Ok(Self { - security_header, - client_info, - }) - } -} - -#[derive(Debug, Error)] -pub enum RdpError { - #[error("IO error")] - IOError(#[from] io::Error), - #[error("client Info PDU error")] - ClientInfoError(#[from] ClientInfoError), - #[error("server License PDU error")] - ServerLicenseError(#[from] ServerLicenseError), - #[error("capability sets error")] - CapabilitySetsError(#[from] CapabilitySetsError), - #[error("invalid RDP security header")] - InvalidSecurityHeader, - #[error("invalid RDP Share Control Header: {0}")] - InvalidShareControlHeader(String), - #[error("invalid RDP Share Data Header: {0}")] - InvalidShareDataHeader(String), - #[error("invalid RDP Connection Sequence PDU")] - InvalidPdu(String), - #[error("unexpected RDP Share Control Header PDU type: {0:?}")] - UnexpectedShareControlPdu(ShareControlPduType), - #[error("unexpected RDP Share Data Header PDU type: {0:?}")] - UnexpectedShareDataPdu(ShareDataPduType), - #[error("save session info PDU error")] - SaveSessionInfoError(#[from] session_info::SessionError), - #[error("input event PDU error")] - InputEventError(#[from] InputEventError), - #[error("not enough bytes")] - NotEnoughBytes, - #[error("PDU error: {0}")] - Pdu(PduError), -} - -impl From for RdpError { - fn from(e: PduError) -> Self { - Self::Pdu(e) - } -} - -impl From for io::Error { - fn from(e: RdpError) -> io::Error { - io::Error::other(format!("RDP Connection Sequence error: {e}")) - } -} diff --git a/crates/ironrdp-pdu/src/rdp/autodetect.rs b/crates/ironrdp-pdu/src/rdp/autodetect.rs new file mode 100644 index 0000000000..630ab00edb --- /dev/null +++ b/crates/ironrdp-pdu/src/rdp/autodetect.rs @@ -0,0 +1,1246 @@ +//! Auto-Detect Request and Response PDU types. +//! +//! Implements Connect-Time and Continuous network characteristics detection +//! per [\[MS-RDPBCGR\] 2.2.14]. +//! +//! The server sends request PDUs to measure round-trip time and bandwidth. +//! The client responds with measured results. During connect-time, the server +//! sends random payload data (BW\_PAYLOAD) for bandwidth measurement. During +//! continuous detection, actual PDU traffic between BW\_START and BW\_STOP +//! replaces the payload messages. +//! +//! [\[MS-RDPBCGR\] 2.2.14]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dc672839-4f4e-40b1-a71c-cd6a959baa38 + +use ironrdp_core::{ + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_size, invalid_field_err, +}; + +use crate::rdp::headers::{BasicSecurityHeader, BasicSecurityHeaderFlags}; + +// ============================================================================ +// Constants +// ============================================================================ + +/// Auto-Detect Request (server to client). +/// +/// [\[MS-RDPBCGR\] 2.2.14.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/5a53eadd-64a2-430d-b197-56bdf7ac9ee9 +pub const TYPE_ID_AUTODETECT_REQUEST: u8 = 0x00; + +/// Auto-Detect Response (client to server). +/// +/// [\[MS-RDPBCGR\] 2.2.14.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/9deccc61-ccef-48ed-bfc3-7ad44e2af274 +pub const TYPE_ID_AUTODETECT_RESPONSE: u8 = 0x01; + +/// Minimum header size shared by all autodetect PDUs. +const HEADER_MIN_SIZE: usize = 1 /* headerLength */ + + 1 /* headerTypeId */ + + 2 /* sequenceNumber */ + + 2 /* requestType or responseType */; + +// ============================================================================ +// Request Type Codes +// ============================================================================ + +/// RTT Measure Request during connect-time auto-detection. +/// +/// [\[MS-RDPBCGR\] 2.2.14.1.1] +/// +/// [\[MS-RDPBCGR\] 2.2.14.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/33b5dd38-a7c3-43d5-a717-ded2391ed599 +pub const RTT_REQUEST_CONNECT_TIME: u16 = 0x1001; + +/// RTT Measure Request during continuous auto-detection. +pub const RTT_REQUEST_CONTINUOUS: u16 = 0x0001; + +/// Bandwidth Measure Start during connect-time auto-detection. +/// +/// [\[MS-RDPBCGR\] 2.2.14.1.2] +/// +/// [\[MS-RDPBCGR\] 2.2.14.1.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/1429c9e6-3e33-462b-b0d9-7dbff7faf979 +pub const BW_START_CONNECT_TIME: u16 = 0x1014; + +/// Bandwidth Measure Start for continuous detection over reliable UDP or TCP. +pub const BW_START_RELIABLE_UDP: u16 = 0x0014; + +/// Bandwidth Measure Start for continuous detection over lossy UDP. +pub const BW_START_LOSSY_UDP: u16 = 0x0114; + +/// Bandwidth Measure Payload (connect-time only). +/// +/// [\[MS-RDPBCGR\] 2.2.14.1.3] +/// +/// [\[MS-RDPBCGR\] 2.2.14.1.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/6fe95264-b083-4548-822a-729cfffd9f1c +pub const BW_PAYLOAD: u16 = 0x0002; + +/// Bandwidth Measure Stop during connect-time auto-detection (includes payload). +/// +/// [\[MS-RDPBCGR\] 2.2.14.1.4] +/// +/// [\[MS-RDPBCGR\] 2.2.14.1.4]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/515150db-4e7a-4c9b-88d8-63f9fe79981f +pub const BW_STOP_CONNECT_TIME: u16 = 0x002B; + +/// Bandwidth Measure Stop for continuous detection over reliable UDP or TCP. +pub const BW_STOP_RELIABLE_UDP: u16 = 0x0429; + +/// Bandwidth Measure Stop for continuous detection over lossy UDP. +pub const BW_STOP_LOSSY_UDP: u16 = 0x0629; + +/// Network Characteristics Result: baseRTT + averageRTT (no bandwidth). +/// +/// [\[MS-RDPBCGR\] 2.2.14.1.5] +/// +/// [\[MS-RDPBCGR\] 2.2.14.1.5]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/228ffc5c-b60c-4d3e-9781-ac613f822fdf +pub const NETCHAR_RESULT_RTT: u16 = 0x0840; + +/// Network Characteristics Result: bandwidth + averageRTT (no baseRTT). +pub const NETCHAR_RESULT_BW_RTT: u16 = 0x0880; + +/// Network Characteristics Result: all three fields (baseRTT + bandwidth + averageRTT). +pub const NETCHAR_RESULT_ALL: u16 = 0x08C0; + +// ============================================================================ +// Response Type Codes +// ============================================================================ + +/// RTT Measure Response. +/// +/// [\[MS-RDPBCGR\] 2.2.14.2.1] +/// +/// [\[MS-RDPBCGR\] 2.2.14.2.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/841649b2-de9d-4143-b91c-d81d7d02e269 +pub const RTT_RESPONSE: u16 = 0x0000; + +/// Bandwidth Measure Results during connect-time auto-detection. +/// +/// [\[MS-RDPBCGR\] 2.2.14.2.2] +/// +/// [\[MS-RDPBCGR\] 2.2.14.2.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/6999bd6a-7eb2-4fba-9e5a-c932596056bf +pub const BW_RESULTS_CONNECT_TIME: u16 = 0x0003; + +/// Bandwidth Measure Results during continuous detection or over tunnel. +pub const BW_RESULTS_CONTINUOUS: u16 = 0x000B; + +/// Network Characteristics Sync (auto-reconnect shortcut). +/// +/// [\[MS-RDPBCGR\] 2.2.14.2.3] +/// +/// [\[MS-RDPBCGR\] 2.2.14.2.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/d6c7fe90-13b5-4b19-8288-433927fe4809 +pub const NETCHAR_SYNC: u16 = 0x0018; + +// ============================================================================ +// Server → Client Request PDUs +// ============================================================================ + +/// Auto-Detect Request from server to client. +/// +/// Encapsulates one of five message types, discriminated by `request_type`. +/// +/// [\[MS-RDPBCGR\] 2.2.14.1] +/// +/// [\[MS-RDPBCGR\] 2.2.14.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/5a53eadd-64a2-430d-b197-56bdf7ac9ee9 +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub enum AutoDetectRequest { + /// [\[MS-RDPBCGR\] 2.2.14.1.1] RTT Measure Request + /// + /// [\[MS-RDPBCGR\] 2.2.14.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/33b5dd38-a7c3-43d5-a717-ded2391ed599 + RttRequest { sequence_number: u16, request_type: u16 }, + + /// [\[MS-RDPBCGR\] 2.2.14.1.2] Bandwidth Measure Start + /// + /// [\[MS-RDPBCGR\] 2.2.14.1.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/1429c9e6-3e33-462b-b0d9-7dbff7faf979 + BandwidthMeasureStart { sequence_number: u16, request_type: u16 }, + + /// [\[MS-RDPBCGR\] 2.2.14.1.3] Bandwidth Measure Payload (connect-time only) + /// + /// [\[MS-RDPBCGR\] 2.2.14.1.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/6fe95264-b083-4548-822a-729cfffd9f1c + BandwidthMeasurePayload { sequence_number: u16, payload: Vec }, + + /// [\[MS-RDPBCGR\] 2.2.14.1.4] Bandwidth Measure Stop + /// + /// [\[MS-RDPBCGR\] 2.2.14.1.4]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/515150db-4e7a-4c9b-88d8-63f9fe79981f + BandwidthMeasureStop { + sequence_number: u16, + request_type: u16, + /// Optional payload (only when request_type is `BW_STOP_CONNECT_TIME`). + payload: Option>, + }, + + /// [\[MS-RDPBCGR\] 2.2.14.1.5] Network Characteristics Result + /// + /// [\[MS-RDPBCGR\] 2.2.14.1.5]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/228ffc5c-b60c-4d3e-9781-ac613f822fdf + NetworkCharacteristicsResult { + sequence_number: u16, + request_type: u16, + /// Lowest detected RTT in milliseconds (present when request_type is 0x0840 or 0x08C0). + base_rtt_ms: Option, + /// Current bandwidth in kilobits per second (present when request_type is 0x0880 or 0x08C0). + bandwidth_kbps: Option, + /// Current average RTT in milliseconds (always present). + average_rtt_ms: u32, + }, +} + +impl AutoDetectRequest { + const NAME: &'static str = "AutoDetectRequest"; + + /// Construct an RTT Measure Request for connect-time detection. + pub fn rtt_connect_time(sequence_number: u16) -> Self { + Self::RttRequest { + sequence_number, + request_type: RTT_REQUEST_CONNECT_TIME, + } + } + + /// Construct an RTT Measure Request for continuous detection. + pub fn rtt_continuous(sequence_number: u16) -> Self { + Self::RttRequest { + sequence_number, + request_type: RTT_REQUEST_CONTINUOUS, + } + } + + /// Construct a Bandwidth Measure Start for connect-time detection. + pub fn bw_start_connect_time(sequence_number: u16) -> Self { + Self::BandwidthMeasureStart { + sequence_number, + request_type: BW_START_CONNECT_TIME, + } + } + + /// Construct a Bandwidth Measure Start for continuous detection. + pub fn bw_start_continuous(sequence_number: u16) -> Self { + Self::BandwidthMeasureStart { + sequence_number, + request_type: BW_START_RELIABLE_UDP, + } + } + + /// Construct a Bandwidth Measure Payload with random data. + pub fn bw_payload(sequence_number: u16, payload: Vec) -> Self { + Self::BandwidthMeasurePayload { + sequence_number, + payload, + } + } + + /// Construct a Bandwidth Measure Stop for connect-time detection. + pub fn bw_stop_connect_time(sequence_number: u16, payload: Vec) -> Self { + Self::BandwidthMeasureStop { + sequence_number, + request_type: BW_STOP_CONNECT_TIME, + payload: Some(payload), + } + } + + /// Construct a Bandwidth Measure Stop for continuous detection. + pub fn bw_stop_continuous(sequence_number: u16) -> Self { + Self::BandwidthMeasureStop { + sequence_number, + request_type: BW_STOP_RELIABLE_UDP, + payload: None, + } + } + + /// Construct a Network Characteristics Result with all fields. + pub fn netchar_result(sequence_number: u16, base_rtt_ms: u32, bandwidth_kbps: u32, average_rtt_ms: u32) -> Self { + Self::NetworkCharacteristicsResult { + sequence_number, + request_type: NETCHAR_RESULT_ALL, + base_rtt_ms: Some(base_rtt_ms), + bandwidth_kbps: Some(bandwidth_kbps), + average_rtt_ms, + } + } + + /// Get the sequence number of this request. + pub fn sequence_number(&self) -> u16 { + match self { + Self::RttRequest { sequence_number, .. } + | Self::BandwidthMeasureStart { sequence_number, .. } + | Self::BandwidthMeasurePayload { sequence_number, .. } + | Self::BandwidthMeasureStop { sequence_number, .. } + | Self::NetworkCharacteristicsResult { sequence_number, .. } => *sequence_number, + } + } +} + +impl Encode for AutoDetectRequest { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + match self { + Self::RttRequest { + sequence_number, + request_type, + } => { + dst.write_u8(0x06); // headerLength + dst.write_u8(TYPE_ID_AUTODETECT_REQUEST); + dst.write_u16(*sequence_number); + dst.write_u16(*request_type); + } + + Self::BandwidthMeasureStart { + sequence_number, + request_type, + } => { + dst.write_u8(0x06); // headerLength + dst.write_u8(TYPE_ID_AUTODETECT_REQUEST); + dst.write_u16(*sequence_number); + dst.write_u16(*request_type); + } + + Self::BandwidthMeasurePayload { + sequence_number, + payload, + } => { + dst.write_u8(0x08); // headerLength + dst.write_u8(TYPE_ID_AUTODETECT_REQUEST); + dst.write_u16(*sequence_number); + dst.write_u16(BW_PAYLOAD); + dst.write_u16(u16::try_from(payload.len()).unwrap_or(u16::MAX)); + dst.write_slice(payload); + } + + Self::BandwidthMeasureStop { + sequence_number, + request_type, + payload, + } => { + if let Some(data) = payload { + dst.write_u8(0x08); // headerLength (with payload) + dst.write_u8(TYPE_ID_AUTODETECT_REQUEST); + dst.write_u16(*sequence_number); + dst.write_u16(*request_type); + dst.write_u16(u16::try_from(data.len()).unwrap_or(u16::MAX)); + dst.write_slice(data); + } else { + dst.write_u8(0x06); // headerLength (no payload) + dst.write_u8(TYPE_ID_AUTODETECT_REQUEST); + dst.write_u16(*sequence_number); + dst.write_u16(*request_type); + } + } + + Self::NetworkCharacteristicsResult { + sequence_number, + request_type, + base_rtt_ms, + bandwidth_kbps, + average_rtt_ms, + } => { + let header_len = match request_type { + &NETCHAR_RESULT_ALL => 0x12u8, + _ => 0x0Eu8, + }; + dst.write_u8(header_len); + dst.write_u8(TYPE_ID_AUTODETECT_REQUEST); + dst.write_u16(*sequence_number); + dst.write_u16(*request_type); + + if let Some(rtt) = base_rtt_ms { + dst.write_u32(*rtt); + } + if let Some(bw) = bandwidth_kbps { + dst.write_u32(*bw); + } + dst.write_u32(*average_rtt_ms); + } + } + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + match self { + Self::RttRequest { .. } | Self::BandwidthMeasureStart { .. } => HEADER_MIN_SIZE, + + Self::BandwidthMeasurePayload { payload, .. } => { + HEADER_MIN_SIZE + 2 /* payloadLength */ + payload.len() + } + + Self::BandwidthMeasureStop { payload, .. } => match payload { + Some(data) => HEADER_MIN_SIZE + 2 /* payloadLength */ + data.len(), + None => HEADER_MIN_SIZE, + }, + + Self::NetworkCharacteristicsResult { + base_rtt_ms, + bandwidth_kbps, + .. + } => { + HEADER_MIN_SIZE + + if base_rtt_ms.is_some() { 4 } else { 0 } + + if bandwidth_kbps.is_some() { 4 } else { 0 } + + 4 /* averageRTT */ + } + } + } +} + +impl<'de> Decode<'de> for AutoDetectRequest { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_size!(in: src, size: HEADER_MIN_SIZE); + + // headerLength consumed but not validated — the requestType determines the layout. + let _header_length = src.read_u8(); + + let header_type_id = src.read_u8(); + + if header_type_id != TYPE_ID_AUTODETECT_REQUEST { + return Err(invalid_field_err!( + "headerTypeId", + "expected TYPE_ID_AUTODETECT_REQUEST (0x00)" + )); + } + + let sequence_number = src.read_u16(); + let request_type = src.read_u16(); + + match request_type { + RTT_REQUEST_CONNECT_TIME | RTT_REQUEST_CONTINUOUS => Ok(Self::RttRequest { + sequence_number, + request_type, + }), + + BW_START_CONNECT_TIME | BW_START_RELIABLE_UDP | BW_START_LOSSY_UDP => Ok(Self::BandwidthMeasureStart { + sequence_number, + request_type, + }), + + BW_PAYLOAD => { + ensure_size!(in: src, size: 2); + let payload_length = src.read_u16(); + ensure_size!(in: src, size: usize::from(payload_length)); + let payload = src.read_slice(usize::from(payload_length)).to_vec(); + Ok(Self::BandwidthMeasurePayload { + sequence_number, + payload, + }) + } + + BW_STOP_CONNECT_TIME => { + // Connect-time stop has payloadLength + payload. + ensure_size!(in: src, size: 2); + let payload_length = src.read_u16(); + ensure_size!(in: src, size: usize::from(payload_length)); + let payload = src.read_slice(usize::from(payload_length)).to_vec(); + Ok(Self::BandwidthMeasureStop { + sequence_number, + request_type, + payload: Some(payload), + }) + } + + BW_STOP_RELIABLE_UDP | BW_STOP_LOSSY_UDP => Ok(Self::BandwidthMeasureStop { + sequence_number, + request_type, + payload: None, + }), + + NETCHAR_RESULT_RTT => { + // baseRTT + averageRTT (no bandwidth). + ensure_size!(in: src, size: 8); + let base_rtt_ms = src.read_u32(); + let average_rtt_ms = src.read_u32(); + Ok(Self::NetworkCharacteristicsResult { + sequence_number, + request_type, + base_rtt_ms: Some(base_rtt_ms), + bandwidth_kbps: None, + average_rtt_ms, + }) + } + + NETCHAR_RESULT_BW_RTT => { + // bandwidth + averageRTT (no baseRTT). + ensure_size!(in: src, size: 8); + let bandwidth_kbps = src.read_u32(); + let average_rtt_ms = src.read_u32(); + Ok(Self::NetworkCharacteristicsResult { + sequence_number, + request_type, + base_rtt_ms: None, + bandwidth_kbps: Some(bandwidth_kbps), + average_rtt_ms, + }) + } + + NETCHAR_RESULT_ALL => { + // baseRTT + bandwidth + averageRTT. + ensure_size!(in: src, size: 12); + let base_rtt_ms = src.read_u32(); + let bandwidth_kbps = src.read_u32(); + let average_rtt_ms = src.read_u32(); + Ok(Self::NetworkCharacteristicsResult { + sequence_number, + request_type, + base_rtt_ms: Some(base_rtt_ms), + bandwidth_kbps: Some(bandwidth_kbps), + average_rtt_ms, + }) + } + + _ => Err(invalid_field_err!("requestType", "unknown autodetect request type")), + } + } +} + +// ============================================================================ +// Client → Server Response PDUs +// ============================================================================ + +/// Auto-Detect Response from client to server. +/// +/// Encapsulates one of three message types, discriminated by `response_type`. +/// +/// [\[MS-RDPBCGR\] 2.2.14.2] +/// +/// [\[MS-RDPBCGR\] 2.2.14.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/fd28dcb8-671d-48bf-8a98-18be46785dab +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub enum AutoDetectResponse { + /// [\[MS-RDPBCGR\] 2.2.14.2.1] RTT Measure Response + /// + /// [\[MS-RDPBCGR\] 2.2.14.2.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/841649b2-de9d-4143-b91c-d81d7d02e269 + RttResponse { sequence_number: u16 }, + + /// [\[MS-RDPBCGR\] 2.2.14.2.2] Bandwidth Measure Results + /// + /// [\[MS-RDPBCGR\] 2.2.14.2.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/6999bd6a-7eb2-4fba-9e5a-c932596056bf + BandwidthMeasureResults { + sequence_number: u16, + response_type: u16, + /// Time delta between BW_START and BW_STOP receipt, in milliseconds. + time_delta_ms: u32, + /// Total bytes received between BW_START and BW_STOP. + byte_count: u32, + }, + + /// [\[MS-RDPBCGR\] 2.2.14.2.3] Network Characteristics Sync (auto-reconnect shortcut) + /// + /// [\[MS-RDPBCGR\] 2.2.14.2.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/d6c7fe90-13b5-4b19-8288-433927fe4809 + NetworkCharacteristicsSync { + sequence_number: u16, + /// Previously detected bandwidth in kilobits per second. + bandwidth_kbps: u32, + /// Previously detected RTT in milliseconds. + rtt_ms: u32, + }, +} + +impl AutoDetectResponse { + const NAME: &'static str = "AutoDetectResponse"; + + /// Get the sequence number of this response. + pub fn sequence_number(&self) -> u16 { + match self { + Self::RttResponse { sequence_number } + | Self::BandwidthMeasureResults { sequence_number, .. } + | Self::NetworkCharacteristicsSync { sequence_number, .. } => *sequence_number, + } + } + + /// Compute bandwidth from BandwidthMeasureResults. + /// + /// Returns bandwidth in kilobits per second, or None if this is not + /// a BandwidthMeasureResults variant or timeDelta is zero. + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "bandwidth in kbps fits in u32 for any realistic network (max ~4 Tbps)" + )] + pub fn computed_bandwidth_kbps(&self) -> Option { + match self { + Self::BandwidthMeasureResults { + time_delta_ms, + byte_count, + .. + } => { + if *time_delta_ms == 0 { + return None; + } + // bandwidth_kbps = (byte_count * 8) / time_delta_ms. + let kbps = u64::from(*byte_count) * 8 / u64::from(*time_delta_ms); + Some(kbps as u32) + } + _ => None, + } + } +} + +impl Encode for AutoDetectResponse { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + match self { + Self::RttResponse { sequence_number } => { + dst.write_u8(0x06); // headerLength + dst.write_u8(TYPE_ID_AUTODETECT_RESPONSE); + dst.write_u16(*sequence_number); + dst.write_u16(RTT_RESPONSE); + } + + Self::BandwidthMeasureResults { + sequence_number, + response_type, + time_delta_ms, + byte_count, + } => { + dst.write_u8(0x0E); // headerLength + dst.write_u8(TYPE_ID_AUTODETECT_RESPONSE); + dst.write_u16(*sequence_number); + dst.write_u16(*response_type); + dst.write_u32(*time_delta_ms); + dst.write_u32(*byte_count); + } + + Self::NetworkCharacteristicsSync { + sequence_number, + bandwidth_kbps, + rtt_ms, + } => { + dst.write_u8(0x0E); // headerLength + dst.write_u8(TYPE_ID_AUTODETECT_RESPONSE); + dst.write_u16(*sequence_number); + dst.write_u16(NETCHAR_SYNC); + dst.write_u32(*bandwidth_kbps); + dst.write_u32(*rtt_ms); + } + } + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + match self { + Self::RttResponse { .. } => HEADER_MIN_SIZE, + Self::BandwidthMeasureResults { .. } | Self::NetworkCharacteristicsSync { .. } => { + HEADER_MIN_SIZE + 4 /* field1 */ + 4 /* field2 */ + } + } + } +} + +impl<'de> Decode<'de> for AutoDetectResponse { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_size!(in: src, size: HEADER_MIN_SIZE); + + // headerLength consumed but not validated — the response_type determines the layout. + let _header_length = src.read_u8(); + + let header_type_id = src.read_u8(); + + if header_type_id != TYPE_ID_AUTODETECT_RESPONSE { + return Err(invalid_field_err!( + "headerTypeId", + "expected TYPE_ID_AUTODETECT_RESPONSE (0x01)" + )); + } + + let sequence_number = src.read_u16(); + let response_type = src.read_u16(); + + match response_type { + RTT_RESPONSE => Ok(Self::RttResponse { sequence_number }), + + BW_RESULTS_CONNECT_TIME | BW_RESULTS_CONTINUOUS => { + ensure_size!(in: src, size: 8); + let time_delta_ms = src.read_u32(); + let byte_count = src.read_u32(); + Ok(Self::BandwidthMeasureResults { + sequence_number, + response_type, + time_delta_ms, + byte_count, + }) + } + + NETCHAR_SYNC => { + ensure_size!(in: src, size: 8); + let bandwidth_kbps = src.read_u32(); + let rtt_ms = src.read_u32(); + Ok(Self::NetworkCharacteristicsSync { + sequence_number, + bandwidth_kbps, + rtt_ms, + }) + } + + _ => Err(invalid_field_err!("responseType", "unknown autodetect response type")), + } + } +} + +// ============================================================================ +// MCS message channel framing +// ============================================================================ +// +// Auto-detect is not a Share Data PDU. Per [MS-RDPBCGR] 2.2.14.3 / 2.2.14.4 it +// rides the MCS message channel framed by a Basic Security Header whose +// SEC_AUTODETECT_REQ / SEC_AUTODETECT_RSP flag identifies it, the same dispatch +// mechanism used by multitransport (see `rdp::multitransport`). + +/// Server Auto-Detect Request PDU ([MS-RDPBCGR] 2.2.14.3). +/// +/// Wraps an [`AutoDetectRequest`] with the `SEC_AUTODETECT_REQ` security header. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct AutoDetectReqPdu { + pub security_header: BasicSecurityHeader, + pub request: AutoDetectRequest, +} + +impl AutoDetectReqPdu { + const NAME: &'static str = "AutoDetectReqPdu"; + + /// Wrap a request with the `SEC_AUTODETECT_REQ` security header. + pub fn new(request: AutoDetectRequest) -> Self { + Self { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::AUTODETECT_REQ, + }, + request, + } + } +} + +impl Encode for AutoDetectReqPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + self.security_header.encode(dst)?; + self.request.encode(dst)?; + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + BasicSecurityHeader::FIXED_PART_SIZE + self.request.size() + } +} + +impl<'de> Decode<'de> for AutoDetectReqPdu { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + let security_header = BasicSecurityHeader::decode(src)?; + + if !security_header.flags.contains(BasicSecurityHeaderFlags::AUTODETECT_REQ) { + return Err(invalid_field_err!("securityHeader", "expected SEC_AUTODETECT_REQ flag")); + } + + let request = AutoDetectRequest::decode(src)?; + + Ok(Self { + security_header, + request, + }) + } +} + +/// Client Auto-Detect Response PDU ([MS-RDPBCGR] 2.2.14.4). +/// +/// Wraps an [`AutoDetectResponse`] with the `SEC_AUTODETECT_RSP` security header. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct AutoDetectRspPdu { + pub security_header: BasicSecurityHeader, + pub response: AutoDetectResponse, +} + +impl AutoDetectRspPdu { + const NAME: &'static str = "AutoDetectRspPdu"; + + /// Wrap a response with the `SEC_AUTODETECT_RSP` security header. + pub fn new(response: AutoDetectResponse) -> Self { + Self { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::AUTODETECT_RSP, + }, + response, + } + } +} + +impl Encode for AutoDetectRspPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + self.security_header.encode(dst)?; + self.response.encode(dst)?; + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + BasicSecurityHeader::FIXED_PART_SIZE + self.response.size() + } +} + +impl<'de> Decode<'de> for AutoDetectRspPdu { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + let security_header = BasicSecurityHeader::decode(src)?; + + if !security_header.flags.contains(BasicSecurityHeaderFlags::AUTODETECT_RSP) { + return Err(invalid_field_err!("securityHeader", "expected SEC_AUTODETECT_RSP flag")); + } + + let response = AutoDetectResponse::decode(src)?; + + Ok(Self { + security_header, + response, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn req_pdu_round_trip() { + let original = AutoDetectReqPdu::new(AutoDetectRequest::RttRequest { + sequence_number: 7, + request_type: RTT_REQUEST_CONTINUOUS, + }); + assert_eq!(original.security_header.flags, BasicSecurityHeaderFlags::AUTODETECT_REQ); + + let encoded = ironrdp_core::encode_vec(&original).unwrap(); + let decoded = ironrdp_core::decode::(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn rsp_pdu_round_trip() { + let original = AutoDetectRspPdu::new(AutoDetectResponse::RttResponse { sequence_number: 7 }); + assert_eq!(original.security_header.flags, BasicSecurityHeaderFlags::AUTODETECT_RSP); + + let encoded = ironrdp_core::encode_vec(&original).unwrap(); + let decoded = ironrdp_core::decode::(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn req_pdu_rejects_response_flag() { + // A response-flagged frame must not decode as a request PDU. + let rsp = AutoDetectRspPdu::new(AutoDetectResponse::RttResponse { sequence_number: 1 }); + let encoded = ironrdp_core::encode_vec(&rsp).unwrap(); + assert!(ironrdp_core::decode::(&encoded).is_err()); + } + + // ======================================================================== + // Request encoding/decoding tests + // ======================================================================== + + const RTT_REQUEST_WIRE: &[u8] = &[ + 0x06, // headerLength + 0x00, // headerTypeId = TYPE_ID_AUTODETECT_REQUEST + 0x01, 0x00, // sequenceNumber = 1 + 0x01, 0x10, // requestType = RTT_REQUEST_CONNECT_TIME (0x1001) + ]; + + const BW_START_WIRE: &[u8] = &[ + 0x06, // headerLength + 0x00, // headerTypeId = TYPE_ID_AUTODETECT_REQUEST + 0x02, 0x00, // sequenceNumber = 2 + 0x14, 0x10, // requestType = BW_START_CONNECT_TIME (0x1014) + ]; + + const BW_PAYLOAD_WIRE: &[u8] = &[ + 0x08, // headerLength + 0x00, // headerTypeId + 0x03, 0x00, // sequenceNumber = 3 + 0x02, 0x00, // requestType = BW_PAYLOAD (0x0002) + 0x04, 0x00, // payloadLength = 4 + 0xAA, 0xBB, 0xCC, 0xDD, // payload + ]; + + const BW_STOP_CONNECT_WIRE: &[u8] = &[ + 0x08, // headerLength + 0x00, // headerTypeId + 0x04, 0x00, // sequenceNumber = 4 + 0x2B, 0x00, // requestType = BW_STOP_CONNECT_TIME (0x002B) + 0x02, 0x00, // payloadLength = 2 + 0xEE, 0xFF, // payload + ]; + + const BW_STOP_CONTINUOUS_WIRE: &[u8] = &[ + 0x06, // headerLength + 0x00, // headerTypeId + 0x05, 0x00, // sequenceNumber = 5 + 0x29, 0x04, // requestType = BW_STOP_RELIABLE_UDP (0x0429) + ]; + + const NETCHAR_ALL_WIRE: &[u8] = &[ + 0x12, // headerLength + 0x00, // headerTypeId + 0x06, 0x00, // sequenceNumber = 6 + 0xC0, 0x08, // requestType = NETCHAR_RESULT_ALL (0x08C0) + 0x0A, 0x00, 0x00, 0x00, // baseRTT = 10 + 0xE8, 0x03, 0x00, 0x00, // bandwidth = 1000 + 0x14, 0x00, 0x00, 0x00, // averageRTT = 20 + ]; + + #[test] + fn decode_rtt_request() { + let pdu = ironrdp_core::decode::(RTT_REQUEST_WIRE).unwrap(); + match pdu { + AutoDetectRequest::RttRequest { + sequence_number, + request_type, + } => { + assert_eq!(sequence_number, 1); + assert_eq!(request_type, RTT_REQUEST_CONNECT_TIME); + } + other => panic!("expected RttRequest, got {other:?}"), + } + } + + #[test] + fn encode_rtt_request() { + let pdu = AutoDetectRequest::rtt_connect_time(1); + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + assert_eq!(encoded.as_slice(), RTT_REQUEST_WIRE); + } + + #[test] + fn decode_bw_start() { + let pdu = ironrdp_core::decode::(BW_START_WIRE).unwrap(); + match pdu { + AutoDetectRequest::BandwidthMeasureStart { + sequence_number, + request_type, + } => { + assert_eq!(sequence_number, 2); + assert_eq!(request_type, BW_START_CONNECT_TIME); + } + other => panic!("expected BandwidthMeasureStart, got {other:?}"), + } + } + + #[test] + fn encode_bw_start() { + let pdu = AutoDetectRequest::bw_start_connect_time(2); + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + assert_eq!(encoded.as_slice(), BW_START_WIRE); + } + + #[test] + fn decode_bw_payload() { + let pdu = ironrdp_core::decode::(BW_PAYLOAD_WIRE).unwrap(); + match pdu { + AutoDetectRequest::BandwidthMeasurePayload { + sequence_number, + payload, + } => { + assert_eq!(sequence_number, 3); + assert_eq!(payload, vec![0xAA, 0xBB, 0xCC, 0xDD]); + } + other => panic!("expected BandwidthMeasurePayload, got {other:?}"), + } + } + + #[test] + fn encode_bw_payload() { + let pdu = AutoDetectRequest::bw_payload(3, vec![0xAA, 0xBB, 0xCC, 0xDD]); + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + assert_eq!(encoded.as_slice(), BW_PAYLOAD_WIRE); + } + + #[test] + fn decode_bw_stop_connect_time() { + let pdu = ironrdp_core::decode::(BW_STOP_CONNECT_WIRE).unwrap(); + match pdu { + AutoDetectRequest::BandwidthMeasureStop { + sequence_number, + request_type, + payload, + } => { + assert_eq!(sequence_number, 4); + assert_eq!(request_type, BW_STOP_CONNECT_TIME); + assert_eq!(payload, Some(vec![0xEE, 0xFF])); + } + other => panic!("expected BandwidthMeasureStop, got {other:?}"), + } + } + + #[test] + fn encode_bw_stop_connect_time() { + let pdu = AutoDetectRequest::bw_stop_connect_time(4, vec![0xEE, 0xFF]); + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + assert_eq!(encoded.as_slice(), BW_STOP_CONNECT_WIRE); + } + + #[test] + fn decode_bw_stop_continuous() { + let pdu = ironrdp_core::decode::(BW_STOP_CONTINUOUS_WIRE).unwrap(); + match pdu { + AutoDetectRequest::BandwidthMeasureStop { + sequence_number, + request_type, + payload, + } => { + assert_eq!(sequence_number, 5); + assert_eq!(request_type, BW_STOP_RELIABLE_UDP); + assert!(payload.is_none()); + } + other => panic!("expected BandwidthMeasureStop, got {other:?}"), + } + } + + #[test] + fn encode_bw_stop_continuous() { + let pdu = AutoDetectRequest::bw_stop_continuous(5); + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + assert_eq!(encoded.as_slice(), BW_STOP_CONTINUOUS_WIRE); + } + + #[test] + fn decode_netchar_all() { + let pdu = ironrdp_core::decode::(NETCHAR_ALL_WIRE).unwrap(); + match pdu { + AutoDetectRequest::NetworkCharacteristicsResult { + sequence_number, + request_type, + base_rtt_ms, + bandwidth_kbps, + average_rtt_ms, + } => { + assert_eq!(sequence_number, 6); + assert_eq!(request_type, NETCHAR_RESULT_ALL); + assert_eq!(base_rtt_ms, Some(10)); + assert_eq!(bandwidth_kbps, Some(1000)); + assert_eq!(average_rtt_ms, 20); + } + other => panic!("expected NetworkCharacteristicsResult, got {other:?}"), + } + } + + #[test] + fn encode_netchar_all() { + let pdu = AutoDetectRequest::netchar_result(6, 10, 1000, 20); + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + assert_eq!(encoded.as_slice(), NETCHAR_ALL_WIRE); + } + + #[test] + fn request_round_trip() { + let cases = vec![ + AutoDetectRequest::rtt_connect_time(100), + AutoDetectRequest::rtt_continuous(200), + AutoDetectRequest::bw_start_connect_time(300), + AutoDetectRequest::bw_start_continuous(400), + AutoDetectRequest::bw_payload(500, vec![1, 2, 3, 4, 5]), + AutoDetectRequest::bw_stop_connect_time(600, vec![0xFF; 10]), + AutoDetectRequest::bw_stop_continuous(700), + AutoDetectRequest::netchar_result(800, 5, 50000, 15), + ]; + + for original in cases { + let encoded = ironrdp_core::encode_vec(&original).unwrap(); + let decoded = ironrdp_core::decode::(&encoded).unwrap(); + assert_eq!(decoded, original, "round-trip failed for {original:?}"); + } + } + + #[test] + fn request_unknown_type_is_error() { + let bad_wire: &[u8] = &[0x06, 0x00, 0x01, 0x00, 0xFF, 0xFF]; + assert!(ironrdp_core::decode::(bad_wire).is_err()); + } + + #[test] + fn request_wrong_header_type_is_error() { + // headerTypeId = 0x01 (response) instead of 0x00 (request). + let bad_wire: &[u8] = &[0x06, 0x01, 0x01, 0x00, 0x01, 0x00]; + assert!(ironrdp_core::decode::(bad_wire).is_err()); + } + + // ======================================================================== + // Response encoding/decoding tests + // ======================================================================== + + const RTT_RESPONSE_WIRE: &[u8] = &[ + 0x06, // headerLength + 0x01, // headerTypeId = TYPE_ID_AUTODETECT_RESPONSE + 0x01, 0x00, // sequenceNumber = 1 + 0x00, 0x00, // responseType = RTT_RESPONSE + ]; + + const BW_RESULTS_WIRE: &[u8] = &[ + 0x0E, // headerLength + 0x01, // headerTypeId + 0x04, 0x00, // sequenceNumber = 4 + 0x03, 0x00, // responseType = BW_RESULTS_CONNECT_TIME + 0xE8, 0x03, 0x00, 0x00, // timeDelta = 1000 + 0x00, 0x10, 0x00, 0x00, // byteCount = 4096 + ]; + + const NETCHAR_SYNC_WIRE: &[u8] = &[ + 0x0E, // headerLength + 0x01, // headerTypeId + 0x01, 0x00, // sequenceNumber = 1 + 0x18, 0x00, // responseType = NETCHAR_SYNC + 0x88, 0x13, 0x00, 0x00, // bandwidth = 5000 kbps + 0x0F, 0x00, 0x00, 0x00, // rtt = 15 ms + ]; + + #[test] + fn decode_rtt_response() { + let pdu = ironrdp_core::decode::(RTT_RESPONSE_WIRE).unwrap(); + match pdu { + AutoDetectResponse::RttResponse { sequence_number } => { + assert_eq!(sequence_number, 1); + } + other => panic!("expected RttResponse, got {other:?}"), + } + } + + #[test] + fn encode_rtt_response() { + let pdu = AutoDetectResponse::RttResponse { sequence_number: 1 }; + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + assert_eq!(encoded.as_slice(), RTT_RESPONSE_WIRE); + } + + #[test] + fn decode_bw_results() { + let pdu = ironrdp_core::decode::(BW_RESULTS_WIRE).unwrap(); + match pdu { + AutoDetectResponse::BandwidthMeasureResults { + sequence_number, + response_type, + time_delta_ms, + byte_count, + } => { + assert_eq!(sequence_number, 4); + assert_eq!(response_type, BW_RESULTS_CONNECT_TIME); + assert_eq!(time_delta_ms, 1000); + assert_eq!(byte_count, 4096); + } + other => panic!("expected BandwidthMeasureResults, got {other:?}"), + } + } + + #[test] + fn encode_bw_results() { + let pdu = AutoDetectResponse::BandwidthMeasureResults { + sequence_number: 4, + response_type: BW_RESULTS_CONNECT_TIME, + time_delta_ms: 1000, + byte_count: 4096, + }; + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + assert_eq!(encoded.as_slice(), BW_RESULTS_WIRE); + } + + #[test] + fn computed_bandwidth() { + let pdu = AutoDetectResponse::BandwidthMeasureResults { + sequence_number: 1, + response_type: BW_RESULTS_CONNECT_TIME, + time_delta_ms: 1000, + byte_count: 125_000, // 125KB in 1 second = 1000 kbps + }; + assert_eq!(pdu.computed_bandwidth_kbps(), Some(1000)); + } + + #[test] + fn computed_bandwidth_zero_delta() { + let pdu = AutoDetectResponse::BandwidthMeasureResults { + sequence_number: 1, + response_type: BW_RESULTS_CONNECT_TIME, + time_delta_ms: 0, + byte_count: 100, + }; + assert_eq!(pdu.computed_bandwidth_kbps(), None); + } + + #[test] + fn decode_netchar_sync() { + let pdu = ironrdp_core::decode::(NETCHAR_SYNC_WIRE).unwrap(); + match pdu { + AutoDetectResponse::NetworkCharacteristicsSync { + sequence_number, + bandwidth_kbps, + rtt_ms, + } => { + assert_eq!(sequence_number, 1); + assert_eq!(bandwidth_kbps, 5000); + assert_eq!(rtt_ms, 15); + } + other => panic!("expected NetworkCharacteristicsSync, got {other:?}"), + } + } + + #[test] + fn encode_netchar_sync() { + let pdu = AutoDetectResponse::NetworkCharacteristicsSync { + sequence_number: 1, + bandwidth_kbps: 5000, + rtt_ms: 15, + }; + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + assert_eq!(encoded.as_slice(), NETCHAR_SYNC_WIRE); + } + + #[test] + fn response_round_trip() { + let cases = vec![ + AutoDetectResponse::RttResponse { sequence_number: 42 }, + AutoDetectResponse::BandwidthMeasureResults { + sequence_number: 100, + response_type: BW_RESULTS_CONTINUOUS, + time_delta_ms: 500, + byte_count: 1_000_000, + }, + AutoDetectResponse::NetworkCharacteristicsSync { + sequence_number: 200, + bandwidth_kbps: 10000, + rtt_ms: 25, + }, + ]; + + for original in cases { + let encoded = ironrdp_core::encode_vec(&original).unwrap(); + let decoded = ironrdp_core::decode::(&encoded).unwrap(); + assert_eq!(decoded, original, "round-trip failed for {original:?}"); + } + } + + #[test] + fn response_unknown_type_is_error() { + let bad_wire: &[u8] = &[0x06, 0x01, 0x01, 0x00, 0xFF, 0xFF]; + assert!(ironrdp_core::decode::(bad_wire).is_err()); + } + + #[test] + fn response_wrong_header_type_is_error() { + // headerTypeId = 0x00 (request) instead of 0x01 (response). + let bad_wire: &[u8] = &[0x06, 0x00, 0x01, 0x00, 0x00, 0x00]; + assert!(ironrdp_core::decode::(bad_wire).is_err()); + } + + #[test] + fn sequence_number_accessor() { + let req = AutoDetectRequest::rtt_connect_time(42); + assert_eq!(req.sequence_number(), 42); + + let rsp = AutoDetectResponse::RttResponse { sequence_number: 99 }; + assert_eq!(rsp.sequence_number(), 99); + } +} diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap.rs index cd5a4f4c27..a9c0a3a190 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap.rs @@ -3,23 +3,27 @@ mod tests; use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, invalid_field_err, read_padding, write_padding, Decode, DecodeResult, Encode, EncodeResult, - ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, invalid_field_err, + read_padding, write_padding, }; const BITMAP_LENGTH: usize = 24; bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BitmapDrawingFlags: u8 { const ALLOW_DYNAMIC_COLOR_FIDELITY = 0x02; const ALLOW_COLOR_SUBSAMPLING = 0x04; const ALLOW_SKIP_ALPHA = 0x08; const UNUSED_FLAG = 0x10; + + const _ = !0; } } #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Bitmap { pub pref_bits_per_pix: u16, pub desktop_width: u16, @@ -87,7 +91,7 @@ impl<'de> Decode<'de> for Bitmap { } let _high_color_flags = src.read_u8(); - let drawing_flags = BitmapDrawingFlags::from_bits_truncate(src.read_u8()); + let drawing_flags = BitmapDrawingFlags::from_bits_retain(src.read_u8()); // According to the spec: // "This field MUST be set to TRUE (0x0001) because multiple rectangle support is required for a connection to proceed." diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap/tests.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap/tests.rs index a1ba6b0699..78b8fa1051 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap/tests.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; @@ -19,28 +20,25 @@ const BITMAP_BUFFER: [u8; 24] = [ 0x00, 0x00, // pad2octetsB ]; -lazy_static! { - pub static ref BITMAP: Bitmap = Bitmap { - pref_bits_per_pix: 24, - desktop_width: 1280, - desktop_height: 1024, - desktop_resize_flag: true, - drawing_flags: BitmapDrawingFlags::ALLOW_SKIP_ALPHA, - }; -} +static BITMAP: LazyLock = LazyLock::new(|| Bitmap { + pref_bits_per_pix: 24, + desktop_width: 1280, + desktop_height: 1024, + desktop_resize_flag: true, + drawing_flags: BitmapDrawingFlags::ALLOW_SKIP_ALPHA, +}); #[test] fn from_buffer_correctly_parses_bitmap_capset() { let buffer = BITMAP_BUFFER.as_ref(); - assert_eq!(*BITMAP, decode(buffer).unwrap()); + let bitmap = LazyLock::force(&BITMAP); + assert_eq!(bitmap, &decode(buffer).unwrap()); } #[test] fn to_buffer_correctly_serializes_bitmap_capset() { - let capset = BITMAP.clone(); - - let buffer = encode_vec(&capset).unwrap(); + let buffer = encode_vec(LazyLock::force(&BITMAP)).unwrap(); assert_eq!(buffer, BITMAP_BUFFER.as_ref()); } diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_cache.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_cache/mod.rs similarity index 90% rename from crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_cache.rs rename to crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_cache/mod.rs index 76c538661d..bcf345456c 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_cache.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_cache/mod.rs @@ -3,8 +3,8 @@ mod tests; use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, read_padding, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, read_padding, + write_padding, }; pub const BITMAP_CACHE_ENTRIES_NUM: usize = 3; @@ -16,6 +16,7 @@ const BITMAP_CACHE_REV2_CELL_INFO_NUM: usize = 5; const CACHE_ENTRY_LENGTH: usize = 4; #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BitmapCache { pub caches: [CacheEntry; BITMAP_CACHE_ENTRIES_NUM], } @@ -65,6 +66,7 @@ impl<'de> Decode<'de> for BitmapCache { } #[derive(Debug, PartialEq, Eq, Copy, Clone, Default)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct CacheEntry { pub entries: u16, pub max_cell_size: u16, @@ -108,13 +110,17 @@ impl<'de> Decode<'de> for CacheEntry { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct CacheFlags: u16 { const PERSISTENT_KEYS_EXPECTED_FLAG = 1; const ALLOW_CACHE_WAITING_LIST_FLAG = 2; + + const _ = !0; } } #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BitmapCacheRev2 { pub cache_flags: CacheFlags, pub num_cell_caches: u8, @@ -157,7 +163,7 @@ impl<'de> Decode<'de> for BitmapCacheRev2 { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let cache_flags = CacheFlags::from_bits_truncate(src.read_u16()); + let cache_flags = CacheFlags::from_bits_retain(src.read_u16()); let _padding = src.read_u8(); let num_cell_caches = src.read_u8(); @@ -178,6 +184,7 @@ impl<'de> Decode<'de> for BitmapCacheRev2 { } #[derive(Debug, PartialEq, Eq, Copy, Clone, Default)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct CellInfo { pub num_entries: u32, pub is_cache_persistent: bool, diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_cache/tests.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_cache/tests.rs index 6ee14e9e9f..abd58ab8dc 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_cache/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_cache/tests.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; @@ -35,58 +36,56 @@ const BITMAP_CACHE_REV2_BUFFER: [u8; 36] = [ const CELL_INFO_BUFFER: [u8; 4] = [0xfb, 0x09, 0x00, 0x80]; -lazy_static! { - pub static ref BITMAP_CACHE: BitmapCache = BitmapCache { - caches: [ - CacheEntry { - entries: 200, - max_cell_size: 512 - }, - CacheEntry { - entries: 600, - max_cell_size: 2048 - }, - CacheEntry { - entries: 1000, - max_cell_size: 8192 - } - ], - }; - pub static ref BITMAP_CACHE_REV2: BitmapCacheRev2 = BitmapCacheRev2 { - cache_flags: CacheFlags::PERSISTENT_KEYS_EXPECTED_FLAG | CacheFlags::ALLOW_CACHE_WAITING_LIST_FLAG, - num_cell_caches: 3, - cache_cell_info: [ - CellInfo { - num_entries: 120, - is_cache_persistent: false - }, - CellInfo { - num_entries: 120, - is_cache_persistent: false - }, - CellInfo { - num_entries: 2555, - is_cache_persistent: true - }, - CellInfo { - num_entries: 0, - is_cache_persistent: false - }, - CellInfo { - num_entries: 0, - is_cache_persistent: false - } - ], - }; - pub static ref CELL_INFO: CellInfo = CellInfo { - num_entries: 2555, - is_cache_persistent: true - }; - pub static ref CACHE_ENTRY: CacheEntry = CacheEntry { - entries: 0x64, - max_cell_size: 0x32, - }; -} +static BITMAP_CACHE: LazyLock = LazyLock::new(|| BitmapCache { + caches: [ + CacheEntry { + entries: 200, + max_cell_size: 512, + }, + CacheEntry { + entries: 600, + max_cell_size: 2048, + }, + CacheEntry { + entries: 1000, + max_cell_size: 8192, + }, + ], +}); +static BITMAP_CACHE_REV2: LazyLock = LazyLock::new(|| BitmapCacheRev2 { + cache_flags: CacheFlags::PERSISTENT_KEYS_EXPECTED_FLAG | CacheFlags::ALLOW_CACHE_WAITING_LIST_FLAG, + num_cell_caches: 3, + cache_cell_info: [ + CellInfo { + num_entries: 120, + is_cache_persistent: false, + }, + CellInfo { + num_entries: 120, + is_cache_persistent: false, + }, + CellInfo { + num_entries: 2555, + is_cache_persistent: true, + }, + CellInfo { + num_entries: 0, + is_cache_persistent: false, + }, + CellInfo { + num_entries: 0, + is_cache_persistent: false, + }, + ], +}); +static CELL_INFO: LazyLock = LazyLock::new(|| CellInfo { + num_entries: 2555, + is_cache_persistent: true, +}); +static CACHE_ENTRY: LazyLock = LazyLock::new(|| CacheEntry { + entries: 0x64, + max_cell_size: 0x32, +}); #[test] fn from_buffer_correctly_parses_bitmap_cache_capset() { @@ -97,9 +96,7 @@ fn from_buffer_correctly_parses_bitmap_cache_capset() { #[test] fn to_buffer_correctly_serializes_bitmap_cache_capset() { - let bitmap_cache = BITMAP_CACHE.clone(); - - let buffer = encode_vec(&bitmap_cache).unwrap(); + let buffer = encode_vec(&*BITMAP_CACHE).unwrap(); assert_eq!(buffer, BITMAP_CACHE_BUFFER.as_ref()); } @@ -118,9 +115,7 @@ fn from_buffer_correctly_parses_bitmap_cache_rev2_capset() { #[test] fn to_buffer_correctly_serializes_bitmap_cache_rev2_capset() { - let bitmap_cache = BITMAP_CACHE_REV2.clone(); - - let buffer = encode_vec(&bitmap_cache).unwrap(); + let buffer = encode_vec(&*BITMAP_CACHE_REV2).unwrap(); assert_eq!(buffer, BITMAP_CACHE_REV2_BUFFER.as_ref()); } diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs/mod.rs similarity index 89% rename from crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs.rs rename to crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs/mod.rs index d7242f7198..5d2089accb 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/bitmap_codecs/mod.rs @@ -6,11 +6,11 @@ use std::collections::HashMap; use bitflags::bitflags; use ironrdp_core::{ - cast_length, decode, ensure_fixed_part_size, ensure_size, invalid_field_err, other_err, Decode, DecodeResult, - Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, decode, ensure_fixed_part_size, + ensure_size, invalid_field_err, other_err, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; const RFX_ICAP_VERSION: u16 = 0x0100; const RFX_ICAP_TILE_SIZE: u16 = 0x40; @@ -48,6 +48,7 @@ const GUID_QOI: Guid = Guid(0x4dae_9af8, 0xb399, 0x4df6, 0xb4, 0x3a, 0x66, 0x2f, const GUID_QOIZ: Guid = Guid(0x229c_c6dc, 0xa860, 0x4b52, 0xb4, 0xd8, 0x05, 0x3a, 0x22, 0xb3, 0x89, 0x2b); #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Guid(u32, u16, u16, u8, u8, u8, u8, u8, u8, u8, u8); impl Guid { @@ -107,6 +108,7 @@ impl<'de> Decode<'de> for Guid { } #[derive(Debug, PartialEq, Eq, Clone, Default)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BitmapCodecs(pub Vec); impl BitmapCodecs { @@ -141,10 +143,10 @@ impl<'de> Decode<'de> for BitmapCodecs { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let codecs_count = src.read_u8(); + let codec_count = src.read_u8(); - let mut codecs = Vec::with_capacity(codecs_count as usize); - for _ in 0..codecs_count { + let mut codecs = Vec::with_capacity(usize::from(codec_count)); + for _ in 0..codec_count { codecs.push(Codec::decode(src)?); } @@ -153,6 +155,7 @@ impl<'de> Decode<'de> for BitmapCodecs { } #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Codec { pub id: u8, pub property: CodecProperty, @@ -278,7 +281,12 @@ impl<'de> Decode<'de> for Codec { match guid { GUID_REMOTEFX => CodecProperty::RemoteFx(property), GUID_IMAGE_REMOTEFX => CodecProperty::ImageRemoteFx(property), - _ => unreachable!(), + // `guid` is validated as RemoteFX or ImageRemoteFX by the outer + // match arm, so the `_` branch is genuinely dead. Keep it as a + // redundant correctness check that fires loudly under tests and + // fuzzing if a future change to the outer arm breaks that + // invariant. Not reachable from the wire. + _ => unreachable!("guid validated as RemoteFX or ImageRemoteFX by the outer match"), } } GUID_IGNORE => CodecProperty::Ignore, @@ -304,12 +312,14 @@ impl<'de> Decode<'de> for Codec { } #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum RemoteFxContainer { ClientContainer(RfxClientCapsContainer), ServerContainer(usize), } #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum CodecProperty { NsCodec(NsCodec), RemoteFx(RemoteFxContainer), @@ -336,6 +346,7 @@ pub enum CodecProperty { /// /// * [NSCodec Capability Set](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpnsc/0eac0ba8-7bdd-4300-ab8d-9bc784c0a669) #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct NsCodec { pub is_dynamic_fidelity_allowed: bool, pub is_subsampling_allowed: bool, @@ -386,6 +397,7 @@ impl<'de> Decode<'de> for NsCodec { } #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct RfxClientCapsContainer { pub capture_flags: CaptureFlags, pub caps_data: RfxCaps, @@ -423,7 +435,7 @@ impl<'de> Decode<'de> for RfxClientCapsContainer { ensure_fixed_part_size!(in: src); let _length = src.read_u32(); - let capture_flags = CaptureFlags::from_bits_truncate(src.read_u32()); + let capture_flags = CaptureFlags::from_bits_retain(src.read_u32()); let _caps_length = src.read_u32(); let caps_data = RfxCaps::decode(src)?; @@ -435,6 +447,7 @@ impl<'de> Decode<'de> for RfxClientCapsContainer { } #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct RfxCaps(pub RfxCapset); impl RfxCaps { @@ -490,6 +503,7 @@ impl<'de> Decode<'de> for RfxCaps { } #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct RfxCapset(pub Vec); impl RfxCapset { @@ -552,7 +566,7 @@ impl<'de> Decode<'de> for RfxCapset { let num_icaps = src.read_u16(); let _icaps_len = src.read_u16(); - let mut icaps_data = Vec::with_capacity(num_icaps as usize); + let mut icaps_data = Vec::with_capacity(usize::from(num_icaps)); for _ in 0..num_icaps { icaps_data.push(RfxICap::decode(src)?); } @@ -562,6 +576,7 @@ impl<'de> Decode<'de> for RfxCapset { } #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct RfxICap { pub flags: RfxICapFlags, pub entropy_bits: EntropyBits, @@ -582,7 +597,7 @@ impl Encode for RfxICap { dst.write_u8(self.flags.bits()); dst.write_u8(RFX_ICAP_COLOR_CONVERSION); dst.write_u8(RFX_ICAP_TRANSFORM_BITS); - dst.write_u8(self.entropy_bits.to_u8().unwrap()); + dst.write_u8(self.entropy_bits.as_u8()); Ok(()) } @@ -610,7 +625,7 @@ impl<'de> Decode<'de> for RfxICap { return Err(invalid_field_err!("tileSize", "invalid rfx icap tile size")); } - let flags = RfxICapFlags::from_bits_truncate(src.read_u8()); + let flags = RfxICapFlags::from_bits_retain(src.read_u8()); let color_conversion = src.read_u8(); if color_conversion != RFX_ICAP_COLOR_CONVERSION { @@ -629,29 +644,48 @@ impl<'de> Decode<'de> for RfxICap { } } -#[derive(PartialEq, Eq, Debug, FromPrimitive, ToPrimitive, Copy, Clone)] +#[repr(u8)] +#[derive(PartialEq, Eq, Debug, FromPrimitive, Copy, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum EntropyBits { Rlgr1 = 1, Rlgr3 = 4, } +impl EntropyBits { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct CaptureFlags: u32 { const CARDP_CAPS_CAPTURE_NON_CAC = 1; + + const _ = !0; } } bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct RfxICapFlags: u8 { const CODEC_MODE = 2; + + const _ = !0; } } // Those IDs are hard-coded for practical reasons, they are implementation // details of the IronRDP client. The server should respect the client IDs. #[derive(Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct CodecId(u8); pub const CODEC_ID_NONE: CodecId = CodecId(0); @@ -688,10 +722,7 @@ fn parse_codecs_config<'a>(codecs: &'a [&'a str]) -> Result true, "off" => false, @@ -708,6 +739,9 @@ fn parse_codecs_config<'a>(codecs: &'a [&'a str]) -> Result = LazyLock::new(|| { + Guid( + 0xca8d_1bb9, + 0x000f, + 0x154f, + 0x58, + 0x9f, + 0xae, + 0x2d, + 0x1a, + 0x87, + 0xe2, + 0xd6, + ) +}); +static RFX_ICAP: LazyLock = LazyLock::new(|| RfxICap { + flags: RfxICapFlags::CODEC_MODE, + entropy_bits: EntropyBits::Rlgr3, +}); +static RFX_CAPSET: LazyLock = LazyLock::new(|| { + RfxCapset(vec![ + RfxICap { + flags: RfxICapFlags::empty(), + entropy_bits: EntropyBits::Rlgr1, + }, + RfxICap { + flags: RfxICapFlags::CODEC_MODE, + entropy_bits: EntropyBits::Rlgr3, + }, + ]) +}); +static RFX_CAPS: LazyLock = LazyLock::new(|| { + RfxCaps(RfxCapset(vec![ RfxICap { flags: RfxICapFlags::empty(), entropy_bits: EntropyBits::Rlgr1, @@ -183,9 +209,12 @@ lazy_static! { RfxICap { flags: RfxICapFlags::CODEC_MODE, entropy_bits: EntropyBits::Rlgr3, - } - ]); - pub static ref RFX_CAPS: RfxCaps = RfxCaps(RfxCapset(vec![ + }, + ])) +}); +static RFX_CLIENT_CAPS_CONTAINER: LazyLock = LazyLock::new(|| RfxClientCapsContainer { + capture_flags: CaptureFlags::CARDP_CAPS_CAPTURE_NON_CAC, + caps_data: RfxCaps(RfxCapset(vec![ RfxICap { flags: RfxICapFlags::empty(), entropy_bits: EntropyBits::Rlgr1, @@ -193,9 +222,17 @@ lazy_static! { RfxICap { flags: RfxICapFlags::CODEC_MODE, entropy_bits: EntropyBits::Rlgr3, - } - ])); - pub static ref RFX_CLIENT_CAPS_CONTAINER: RfxClientCapsContainer = RfxClientCapsContainer { + }, + ])), +}); +static NSCODEC: LazyLock = LazyLock::new(|| NsCodec { + is_dynamic_fidelity_allowed: true, + is_subsampling_allowed: true, + color_loss_level: 3, +}); +static CODEC: LazyLock = LazyLock::new(|| Codec { + id: 3, + property: CodecProperty::RemoteFx(RemoteFxContainer::ClientContainer(RfxClientCapsContainer { capture_flags: CaptureFlags::CARDP_CAPS_CAPTURE_NON_CAC, caps_data: RfxCaps(RfxCapset(vec![ RfxICap { @@ -205,18 +242,19 @@ lazy_static! { RfxICap { flags: RfxICapFlags::CODEC_MODE, entropy_bits: EntropyBits::Rlgr3, - } + }, ])), - }; - pub static ref NSCODEC: NsCodec = NsCodec { - is_dynamic_fidelity_allowed: true, - is_subsampling_allowed: true, - color_loss_level: 3, - }; - pub static ref CODEC: Codec = Codec { - id: 3, - property: CodecProperty::RemoteFx(RemoteFxContainer::ClientContainer( - RfxClientCapsContainer { + })), +}); +static CODEC_SERVER_MODE: LazyLock = LazyLock::new(|| Codec { + id: 0, + property: CodecProperty::ImageRemoteFx(RemoteFxContainer::ServerContainer(4)), +}); +static BITMAP_CODECS: LazyLock = LazyLock::new(|| { + BitmapCodecs(vec![ + Codec { + id: 3, + property: CodecProperty::RemoteFx(RemoteFxContainer::ClientContainer(RfxClientCapsContainer { capture_flags: CaptureFlags::CARDP_CAPS_CAPTURE_NON_CAC, caps_data: RfxCaps(RfxCapset(vec![ RfxICap { @@ -226,33 +264,9 @@ lazy_static! { RfxICap { flags: RfxICapFlags::CODEC_MODE, entropy_bits: EntropyBits::Rlgr3, - } + }, ])), - } - )), - }; - pub static ref CODEC_SERVER_MODE: Codec = Codec { - id: 0, - property: CodecProperty::ImageRemoteFx(RemoteFxContainer::ServerContainer(4)), - }; - pub static ref BITMAP_CODECS: BitmapCodecs = BitmapCodecs(vec![ - Codec { - id: 3, - property: CodecProperty::RemoteFx(RemoteFxContainer::ClientContainer( - RfxClientCapsContainer { - capture_flags: CaptureFlags::CARDP_CAPS_CAPTURE_NON_CAC, - caps_data: RfxCaps(RfxCapset(vec![ - RfxICap { - flags: RfxICapFlags::empty(), - entropy_bits: EntropyBits::Rlgr1, - }, - RfxICap { - flags: RfxICapFlags::CODEC_MODE, - entropy_bits: EntropyBits::Rlgr3, - } - ])), - } - )) + })), }, Codec { id: 1, @@ -260,10 +274,10 @@ lazy_static! { is_dynamic_fidelity_allowed: true, is_subsampling_allowed: true, color_loss_level: 3, - }) + }), }, - ]); -} + ]) +}); #[test] fn from_buffer_correctly_parses_guid() { diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/brush.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/brush/mod.rs similarity index 60% rename from crates/ironrdp-pdu/src/rdp/capability_sets/brush.rs rename to crates/ironrdp-pdu/src/rdp/capability_sets/brush/mod.rs index fd2daca9bb..8c2ea69195 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/brush.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/brush/mod.rs @@ -2,21 +2,34 @@ mod tests; use ironrdp_core::{ - ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, invalid_field_err, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; const BRUSH_LENGTH: usize = 4; -#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u32)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum SupportLevel { Default = 0, Color8x8 = 1, ColorFull = 2, } +impl SupportLevel { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} + #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Brush { pub support_level: SupportLevel, } @@ -31,7 +44,7 @@ impl Encode for Brush { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u32(self.support_level.to_u32().unwrap()); + dst.write_u32(self.support_level.as_u32()); Ok(()) } diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/brush/tests.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/brush/tests.rs index 3941e4f965..75d9612a59 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/brush/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/brush/tests.rs @@ -1,15 +1,14 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; const BRUSH_BUFFER: [u8; 4] = [0x01, 0x00, 0x00, 0x00]; -lazy_static! { - pub static ref BRUSH: Brush = Brush { - support_level: SupportLevel::Color8x8, - }; -} +static BRUSH: LazyLock = LazyLock::new(|| Brush { + support_level: SupportLevel::Color8x8, +}); #[test] fn from_buffer_successfully_parses_brush_capset() { diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/frame_acknowledge.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/frame_acknowledge.rs index ea090c5e02..193295bfda 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/frame_acknowledge.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/frame_acknowledge.rs @@ -1,6 +1,7 @@ -use ironrdp_core::{ensure_fixed_part_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor}; +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size}; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct FrameAcknowledge { pub max_unacknowledged_frame_count: u32, } diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/general.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/general/mod.rs similarity index 92% rename from crates/ironrdp-pdu/src/rdp/capability_sets/general.rs rename to crates/ironrdp-pdu/src/rdp/capability_sets/general/mod.rs index 074a92901f..eb9d548f55 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/general.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/general/mod.rs @@ -5,13 +5,14 @@ use std::fmt; use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, invalid_field_err, }; const GENERAL_LENGTH: usize = 20; pub const PROTOCOL_VER: u16 = 0x0200; #[derive(Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct MajorPlatformType(u16); impl fmt::Debug for MajorPlatformType { @@ -46,6 +47,7 @@ impl MajorPlatformType { } #[derive(Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct MinorPlatformType(u16); impl fmt::Debug for MinorPlatformType { @@ -83,16 +85,20 @@ impl MinorPlatformType { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct GeneralExtraFlags: u16 { const FASTPATH_OUTPUT_SUPPORTED = 0x0001; const NO_BITMAP_COMPRESSION_HDR = 0x0400; const LONG_CREDENTIALS_SUPPORTED = 0x0004; const AUTORECONNECT_SUPPORTED = 0x0008; const ENC_SALTED_CHECKSUM = 0x0010; + + const _ = !0; } } #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct General { pub major_platform_type: MajorPlatformType, pub minor_platform_type: MinorPlatformType, @@ -165,7 +171,7 @@ impl<'de> Decode<'de> for General { return Err(invalid_field_err!("compressionTypes", "invalid compression types")); } - let extra_flags = GeneralExtraFlags::from_bits_truncate(src.read_u16()); + let extra_flags = GeneralExtraFlags::from_bits_retain(src.read_u16()); let update_cap_flags = src.read_u16(); if update_cap_flags != 0 { diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/general/tests.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/general/tests.rs index 7645829590..55b09cbf53 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/general/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/general/tests.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; @@ -17,20 +18,18 @@ const GENERAL_CAPSET_BUFFER: [u8; 20] = [ 0x00, // suppressOutputSupport ]; -lazy_static! { - pub static ref CAPSET_GENERAL: General = General { - major_platform_type: MajorPlatformType::WINDOWS, - minor_platform_type: MinorPlatformType::WINDOWS_NT, - protocol_version: PROTOCOL_VER, - extra_flags: GeneralExtraFlags::FASTPATH_OUTPUT_SUPPORTED - | GeneralExtraFlags::LONG_CREDENTIALS_SUPPORTED - | GeneralExtraFlags::AUTORECONNECT_SUPPORTED - | GeneralExtraFlags::ENC_SALTED_CHECKSUM - | GeneralExtraFlags::NO_BITMAP_COMPRESSION_HDR, - refresh_rect_support: false, - suppress_output_support: false, - }; -} +static CAPSET_GENERAL: LazyLock = LazyLock::new(|| General { + major_platform_type: MajorPlatformType::WINDOWS, + minor_platform_type: MinorPlatformType::WINDOWS_NT, + protocol_version: PROTOCOL_VER, + extra_flags: GeneralExtraFlags::FASTPATH_OUTPUT_SUPPORTED + | GeneralExtraFlags::LONG_CREDENTIALS_SUPPORTED + | GeneralExtraFlags::AUTORECONNECT_SUPPORTED + | GeneralExtraFlags::ENC_SALTED_CHECKSUM + | GeneralExtraFlags::NO_BITMAP_COMPRESSION_HDR, + refresh_rect_support: false, + suppress_output_support: false, +}); #[test] fn from_buffer_correctly_parses_general_capset() { diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/glyph_cache.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/glyph_cache/mod.rs similarity index 78% rename from crates/ironrdp-pdu/src/rdp/capability_sets/glyph_cache.rs rename to crates/ironrdp-pdu/src/rdp/capability_sets/glyph_cache/mod.rs index fe11b0592d..c09dcf0c98 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/glyph_cache.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/glyph_cache/mod.rs @@ -2,18 +2,20 @@ mod tests; use ironrdp_core::{ - ensure_fixed_part_size, invalid_field_err, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, invalid_field_err, + write_padding, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; pub const GLYPH_CACHE_NUM: usize = 10; const GLYPH_CACHE_LENGTH: usize = 48; const CACHE_DEFINITION_LENGTH: usize = 4; -#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u16)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum GlyphSupportLevel { None = 0, Partial = 1, @@ -21,7 +23,18 @@ pub enum GlyphSupportLevel { Encode = 3, } +impl GlyphSupportLevel { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + #[derive(Debug, PartialEq, Eq, Copy, Clone, Default)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct CacheDefinition { pub entries: u16, pub max_cell_size: u16, @@ -64,6 +77,7 @@ impl<'de> Decode<'de> for CacheDefinition { } #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct GlyphCache { pub glyph_cache: [CacheDefinition; GLYPH_CACHE_NUM], pub frag_cache: CacheDefinition, @@ -86,7 +100,7 @@ impl Encode for GlyphCache { self.frag_cache.encode(dst)?; - dst.write_u16(self.glyph_support_level.to_u16().unwrap()); + dst.write_u16(self.glyph_support_level.as_u16()); write_padding!(dst, 2); Ok(()) diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/glyph_cache/tests.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/glyph_cache/tests.rs index be7443e086..fff93d066c 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/glyph_cache/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/glyph_cache/tests.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; @@ -14,61 +15,59 @@ const GLYPH_CACHE_BUFFER: [u8; 48] = [ const CACHE_DEFINITION_BUFFER: [u8; 4] = [0xfe, 0x00, 0x04, 0x00]; -lazy_static! { - pub static ref GLYPH_CACHE: GlyphCache = GlyphCache { - glyph_cache: [ - CacheDefinition { - entries: 254, - max_cell_size: 4 - }, - CacheDefinition { - entries: 254, - max_cell_size: 4 - }, - CacheDefinition { - entries: 254, - max_cell_size: 8 - }, - CacheDefinition { - entries: 254, - max_cell_size: 8 - }, - CacheDefinition { - entries: 254, - max_cell_size: 16 - }, - CacheDefinition { - entries: 254, - max_cell_size: 32 - }, - CacheDefinition { - entries: 254, - max_cell_size: 64 - }, - CacheDefinition { - entries: 254, - max_cell_size: 128 - }, - CacheDefinition { - entries: 254, - max_cell_size: 256 - }, - CacheDefinition { - entries: 64, - max_cell_size: 2048 - } - ], - frag_cache: CacheDefinition { - entries: 256, +static GLYPH_CACHE: LazyLock = LazyLock::new(|| GlyphCache { + glyph_cache: [ + CacheDefinition { + entries: 254, + max_cell_size: 4, + }, + CacheDefinition { + entries: 254, + max_cell_size: 4, + }, + CacheDefinition { + entries: 254, + max_cell_size: 8, + }, + CacheDefinition { + entries: 254, + max_cell_size: 8, + }, + CacheDefinition { + entries: 254, + max_cell_size: 16, + }, + CacheDefinition { + entries: 254, + max_cell_size: 32, + }, + CacheDefinition { + entries: 254, + max_cell_size: 64, + }, + CacheDefinition { + entries: 254, + max_cell_size: 128, + }, + CacheDefinition { + entries: 254, max_cell_size: 256, }, - glyph_support_level: GlyphSupportLevel::Encode, - }; - pub static ref CACHE_DEFINITION: CacheDefinition = CacheDefinition { - entries: 254, - max_cell_size: 4, - }; -} + CacheDefinition { + entries: 64, + max_cell_size: 2048, + }, + ], + frag_cache: CacheDefinition { + entries: 256, + max_cell_size: 256, + }, + glyph_support_level: GlyphSupportLevel::Encode, +}); +static CACHE_DEFINITION: LazyLock = LazyLock::new(|| CacheDefinition { + entries: 254, + max_cell_size: 4, +}); #[test] fn from_buffer_correctly_parses_glyph_cache_capset() { diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/input.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/input.rs index ab8341c1d9..32aeeb6de5 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/input.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/input.rs @@ -3,18 +3,19 @@ mod tests; use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, read_padding, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, read_padding, + write_padding, }; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_traits::FromPrimitive as _; -use crate::gcc::{KeyboardType, IME_FILE_NAME_SIZE}; +use crate::gcc::{IME_FILE_NAME_SIZE, KeyboardType}; use crate::utils; const INPUT_LENGTH: usize = 84; bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct InputFlags: u16 { const SCANCODES = 0x0001; const MOUSEX = 0x0004; @@ -25,10 +26,13 @@ bitflags! { const MOUSE_RELATIVE = 0x0080; const TS_MOUSE_HWHEEL = 0x0100; const TS_QOE_TIMESTAMPS = 0x0200; + + const _ = !0; } } #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Input { pub input_flags: InputFlags, pub keyboard_layout: u32, @@ -53,7 +57,7 @@ impl Encode for Input { dst.write_u32(self.keyboard_layout); let type_buffer = match self.keyboard_type.as_ref() { - Some(value) => value.to_u32().unwrap_or(0), + Some(value) => value.as_u32(), None => 0, }; dst.write_u32(type_buffer); @@ -85,7 +89,7 @@ impl<'de> Decode<'de> for Input { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let input_flags = InputFlags::from_bits_truncate(src.read_u16()); + let input_flags = InputFlags::from_bits_retain(src.read_u16()); read_padding!(src, 2); let keyboard_layout = src.read_u32(); diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/input/tests.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/input/tests.rs index a9d150112e..5337365d5f 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/input/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/input/tests.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; @@ -16,16 +17,14 @@ const INPUT_BUFFER: [u8; 84] = [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // imeFileName ]; -lazy_static! { - pub static ref INPUT: Input = Input { - input_flags: InputFlags::SCANCODES | InputFlags::UNICODE | InputFlags::MOUSEX, - keyboard_layout: 0x409, - keyboard_type: Some(KeyboardType::IbmEnhanced), - keyboard_subtype: 0, - keyboard_function_key: 12, - keyboard_ime_filename: String::new(), - }; -} +static INPUT: LazyLock = LazyLock::new(|| Input { + input_flags: InputFlags::SCANCODES | InputFlags::UNICODE | InputFlags::MOUSEX, + keyboard_layout: 0x409, + keyboard_type: Some(KeyboardType::IbmEnhanced), + keyboard_subtype: 0, + keyboard_function_key: 12, + keyboard_ime_filename: String::new(), +}); #[test] fn from_buffer_correctly_parses_input_capset() { diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/large_pointer.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/large_pointer.rs index 14c7ad9258..d9edd8e4bb 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/large_pointer.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/large_pointer.rs @@ -1,7 +1,8 @@ use bitflags::bitflags; -use ironrdp_core::{ensure_fixed_part_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor}; +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size}; #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct LargePointer { pub flags: LargePointerSupportFlags, } @@ -34,7 +35,7 @@ impl<'de> Decode<'de> for LargePointer { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let flags = LargePointerSupportFlags::from_bits_truncate(src.read_u16()); + let flags = LargePointerSupportFlags::from_bits_retain(src.read_u16()); Ok(Self { flags }) } @@ -42,9 +43,12 @@ impl<'de> Decode<'de> for LargePointer { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct LargePointerSupportFlags: u16 { const UP_TO_96X96_PIXELS = 1; const UP_TO_384X384_PIXELS = 2; + + const _ = !0; } } diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/mod.rs similarity index 83% rename from crates/ironrdp-pdu/src/rdp/capability_sets.rs rename to crates/ironrdp-pdu/src/rdp/capability_sets/mod.rs index 8419f69761..3e894bf03d 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/mod.rs @@ -1,14 +1,11 @@ -use std::io; - use ironrdp_core::{ - cast_length, decode, ensure_fixed_part_size, ensure_size, invalid_field_err, unsupported_value_err, write_padding, - Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, decode, ensure_fixed_part_size, + ensure_size, invalid_field_err, unsupported_value_err, write_padding, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; -use thiserror::Error; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; -use crate::{utils, PduError}; +use crate::utils; mod bitmap; mod bitmap_cache; @@ -29,17 +26,17 @@ mod virtual_channel; pub use self::bitmap::{Bitmap, BitmapDrawingFlags}; pub use self::bitmap_cache::{ - BitmapCache, BitmapCacheRev2, CacheEntry, CacheFlags, CellInfo, BITMAP_CACHE_ENTRIES_NUM, + BITMAP_CACHE_ENTRIES_NUM, BitmapCache, BitmapCacheRev2, CacheEntry, CacheFlags, CellInfo, }; pub use self::bitmap_codecs::{ - client_codecs_capabilities, server_codecs_capabilities, BitmapCodecs, CaptureFlags, Codec, CodecId, CodecProperty, - EntropyBits, Guid, NsCodec, RemoteFxContainer, RfxCaps, RfxCapset, RfxClientCapsContainer, RfxICap, RfxICapFlags, - CODEC_ID_NONE, CODEC_ID_QOI, CODEC_ID_QOIZ, CODEC_ID_REMOTEFX, + BitmapCodecs, CODEC_ID_NONE, CODEC_ID_QOI, CODEC_ID_QOIZ, CODEC_ID_REMOTEFX, CaptureFlags, Codec, CodecId, + CodecProperty, EntropyBits, Guid, NsCodec, RemoteFxContainer, RfxCaps, RfxCapset, RfxClientCapsContainer, RfxICap, + RfxICapFlags, client_codecs_capabilities, server_codecs_capabilities, }; pub use self::brush::{Brush, SupportLevel}; pub use self::frame_acknowledge::FrameAcknowledge; pub use self::general::{General, GeneralExtraFlags, MajorPlatformType, MinorPlatformType, PROTOCOL_VER}; -pub use self::glyph_cache::{CacheDefinition, GlyphCache, GlyphSupportLevel, GLYPH_CACHE_NUM}; +pub use self::glyph_cache::{CacheDefinition, GLYPH_CACHE_NUM, GlyphCache, GlyphSupportLevel}; pub use self::input::{Input, InputFlags}; pub use self::large_pointer::{LargePointer, LargePointerSupportFlags}; pub use self::multifragment_update::MultifragmentUpdate; @@ -67,6 +64,7 @@ const NULL_TERMINATOR: &str = "\0"; /// /// [2.2.1.13.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/a07abad1-38bb-4a1a-96c9-253e3d5440df #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ServerDemandActive { pub pdu: DemandActive, } @@ -111,6 +109,7 @@ impl<'de> Decode<'de> for ServerDemandActive { /// /// [2.2.1.13.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/4c3c2710-0bf0-4c54-8e69-aff40ffcde66 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientConfirmActive { /// According to [MSDN](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/4e9722c3-ad83-43f5-af5a-529f73d88b48), /// this field MUST be set to [SERVER_CHANNEL_ID](constant.SERVER_CHANNEL_ID.html). @@ -161,6 +160,7 @@ impl<'de> Decode<'de> for ClientConfirmActive { /// /// [2.2.1.13.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/bd612af5-cb54-43a2-9646-438bc3ecf5db #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct DemandActive { pub source_descriptor: String, pub capability_sets: Vec, @@ -215,9 +215,9 @@ impl<'de> Decode<'de> for DemandActive { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let source_descriptor_length = src.read_u16() as usize; + let source_descriptor_length = usize::from(src.read_u16()); // The combined size in bytes of the numberCapabilities, pad2Octets, and capabilitySets fields. - let _combined_capabilities_length = src.read_u16() as usize; + let _combined_capabilities_length = usize::from(src.read_u16()); ensure_size!(in: src, size: source_descriptor_length); let source_descriptor = utils::decode_string( @@ -227,7 +227,7 @@ impl<'de> Decode<'de> for DemandActive { )?; ensure_size!(in: src, size: 2 + 2); - let capability_sets_count = src.read_u16() as usize; + let capability_sets_count = usize::from(src.read_u16()); let _padding = src.read_u16(); let mut capability_sets = Vec::with_capacity(capability_sets_count); @@ -244,6 +244,7 @@ impl<'de> Decode<'de> for DemandActive { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum CapabilitySet { // mandatory General(General), @@ -293,7 +294,7 @@ impl Encode for CapabilitySet { match self { CapabilitySet::General(capset) => { - dst.write_u16(CapabilitySetType::General.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::General.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -301,7 +302,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::Bitmap(capset) => { - dst.write_u16(CapabilitySetType::Bitmap.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::Bitmap.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -309,7 +310,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::Order(capset) => { - dst.write_u16(CapabilitySetType::Order.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::Order.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -317,7 +318,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::BitmapCache(capset) => { - dst.write_u16(CapabilitySetType::BitmapCache.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::BitmapCache.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -325,7 +326,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::BitmapCacheRev2(capset) => { - dst.write_u16(CapabilitySetType::BitmapCacheRev2.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::BitmapCacheRev2.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -333,7 +334,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::Pointer(capset) => { - dst.write_u16(CapabilitySetType::Pointer.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::Pointer.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -341,7 +342,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::Sound(capset) => { - dst.write_u16(CapabilitySetType::Sound.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::Sound.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -349,7 +350,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::Input(capset) => { - dst.write_u16(CapabilitySetType::Input.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::Input.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -357,7 +358,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::Brush(capset) => { - dst.write_u16(CapabilitySetType::Brush.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::Brush.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -365,7 +366,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::GlyphCache(capset) => { - dst.write_u16(CapabilitySetType::GlyphCache.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::GlyphCache.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -373,7 +374,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::OffscreenBitmapCache(capset) => { - dst.write_u16(CapabilitySetType::OffscreenBitmapCache.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::OffscreenBitmapCache.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -381,7 +382,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::VirtualChannel(capset) => { - dst.write_u16(CapabilitySetType::VirtualChannel.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::VirtualChannel.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -389,7 +390,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::SurfaceCommands(capset) => { - dst.write_u16(CapabilitySetType::SurfaceCommands.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::SurfaceCommands.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -397,7 +398,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::BitmapCodecs(capset) => { - dst.write_u16(CapabilitySetType::BitmapCodecs.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::BitmapCodecs.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -405,7 +406,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::MultiFragmentUpdate(capset) => { - dst.write_u16(CapabilitySetType::MultiFragmentUpdate.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::MultiFragmentUpdate.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -413,7 +414,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::LargePointer(capset) => { - dst.write_u16(CapabilitySetType::LargePointer.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::LargePointer.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -421,7 +422,7 @@ impl Encode for CapabilitySet { capset.encode(dst)?; } CapabilitySet::FrameAcknowledge(capset) => { - dst.write_u16(CapabilitySetType::FrameAcknowledge.to_u16().unwrap()); + dst.write_u16(CapabilitySetType::FrameAcknowledge.as_u16()); dst.write_u16(cast_length!( "len", capset.size() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -443,10 +444,38 @@ impl Encode for CapabilitySet { CapabilitySet::DrawGdiPlus(buffer) => (CapabilitySetType::DrawGdiPlus, buffer), CapabilitySet::Rail(buffer) => (CapabilitySetType::Rail, buffer), CapabilitySet::WindowList(buffer) => (CapabilitySetType::WindowList, buffer), - _ => unreachable!(), + CapabilitySet::BitmapCacheV3(buffer) => (CapabilitySetType::BitmapCacheV3CodecID, buffer), + // Structured variants are routed through the outer match's + // specific arms above this block and cannot reach this + // inner match. Listing them explicitly (instead of using + // `_ =>`) makes a future addition to `CapabilitySet` a + // compile error here until the new variant is routed in + // this `Encode` impl. PR #1313 (BitmapCacheV3 encoder + // `unreachable!()` reached on decoder-accepted input) + // demonstrated why a runtime catch-all is the wrong shape + // for this match. + CapabilitySet::General(_) + | CapabilitySet::Bitmap(_) + | CapabilitySet::Order(_) + | CapabilitySet::BitmapCache(_) + | CapabilitySet::BitmapCacheRev2(_) + | CapabilitySet::Pointer(_) + | CapabilitySet::Sound(_) + | CapabilitySet::Input(_) + | CapabilitySet::Brush(_) + | CapabilitySet::GlyphCache(_) + | CapabilitySet::OffscreenBitmapCache(_) + | CapabilitySet::VirtualChannel(_) + | CapabilitySet::MultiFragmentUpdate(_) + | CapabilitySet::LargePointer(_) + | CapabilitySet::SurfaceCommands(_) + | CapabilitySet::BitmapCodecs(_) + | CapabilitySet::FrameAcknowledge(_) => { + unreachable!("structured variant routed to raw-buffer encoder arm") + } }; - dst.write_u16(capability_set_type.to_u16().unwrap()); + dst.write_u16(capability_set_type.as_u16()); dst.write_u16(cast_length!( "len", capability_set_buffer.len() + CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE @@ -509,7 +538,7 @@ impl<'de> Decode<'de> for CapabilitySet { ) })?; - let length = src.read_u16() as usize; + let length = usize::from(src.read_u16()); if length < CAPABILITY_SET_TYPE_FIELD_SIZE + CAPABILITY_SET_LENGTH_FIELD_SIZE { return Err(invalid_field_err!("len", "invalid capability set length")); @@ -562,7 +591,8 @@ impl<'de> Decode<'de> for CapabilitySet { } } -#[derive(Copy, Clone, Debug, FromPrimitive, ToPrimitive)] +#[repr(u16)] +#[derive(Copy, Clone, Debug, FromPrimitive)] enum CapabilitySetType { General = 0x01, Bitmap = 0x02, @@ -595,68 +625,12 @@ enum CapabilitySetType { FrameAcknowledge = 0x1e, } -#[derive(Debug, Error)] -pub enum CapabilitySetsError { - #[error("IO error")] - IOError(#[from] io::Error), - #[error("UTF-8 error")] - Utf8Error(#[from] std::string::FromUtf8Error), - #[error("invalid type field")] - InvalidType, - #[error("invalid bitmap compression field")] - InvalidCompressionFlag, - #[error("invalid multiple rectangle support field")] - InvalidMultipleRectSupport, - #[error("invalid protocol version field")] - InvalidProtocolVersion, - #[error("invalid compression types field")] - InvalidCompressionTypes, - #[error("invalid update capability flags field")] - InvalidUpdateCapFlag, - #[error("invalid remote unshare flag field")] - InvalidRemoteUnshareFlag, - #[error("invalid compression level field")] - InvalidCompressionLevel, - #[error("invalid brush support level field")] - InvalidBrushSupportLevel, - #[error("invalid glyph support level field")] - InvalidGlyphSupportLevel, - #[error("invalid RemoteFX capability version")] - InvalidRfxICapVersion, - #[error("invalid RemoteFX capability tile size")] - InvalidRfxICapTileSize, - #[error("invalid RemoteFXICap color conversion bits")] - InvalidRfxICapColorConvBits, - #[error("invalid RemoteFXICap transform bits")] - InvalidRfxICapTransformBits, - #[error("invalid RemoteFXICap entropy bits field")] - InvalidRfxICapEntropyBits, - #[error("invalid RemoteFX capability set block type")] - InvalidRfxCapsetBlockType, - #[error("invalid RemoteFX capability set type")] - InvalidRfxCapsetType, - #[error("invalid RemoteFX capabilities block type")] - InvalidRfxCapsBlockType, - #[error("invalid RemoteFX capabilities block length")] - InvalidRfxCapsBockLength, - #[error("invalid number of capability sets in RemoteFX capabilities")] - InvalidRfxCapsNumCapsets, - #[error("invalid codec property field")] - InvalidCodecProperty, - #[error("invalid codec ID")] - InvalidCodecID, - #[error("invalid channel chunk size field")] - InvalidChunkSize, - #[error("invalid codec property length for the current property ID")] - InvalidPropertyLength, - #[error("invalid data length")] - InvalidLength, - #[error("PDU error: {0}")] - Pdu(PduError), -} - -impl From for CapabilitySetsError { - fn from(e: PduError) -> Self { - Self::Pdu(e) +impl CapabilitySetType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 } } diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/multifragment_update.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/multifragment_update.rs index 578d053282..f8459a757e 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/multifragment_update.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/multifragment_update.rs @@ -1,6 +1,7 @@ -use ironrdp_core::{ensure_fixed_part_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor}; +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size}; #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct MultifragmentUpdate { pub max_request_size: u32, } diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/offscreen_bitmap_cache.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/offscreen_bitmap_cache/mod.rs similarity index 87% rename from crates/ironrdp-pdu/src/rdp/capability_sets/offscreen_bitmap_cache.rs rename to crates/ironrdp-pdu/src/rdp/capability_sets/offscreen_bitmap_cache/mod.rs index 25271c840d..7514519ed5 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/offscreen_bitmap_cache.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/offscreen_bitmap_cache/mod.rs @@ -1,11 +1,12 @@ #[cfg(test)] mod tests; -use ironrdp_core::{ensure_fixed_part_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor}; +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size}; const OFFSCREEN_BITMAP_CACHE_LENGTH: usize = 8; #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct OffscreenBitmapCache { pub is_supported: bool, pub cache_size: u16, diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/offscreen_bitmap_cache/tests.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/offscreen_bitmap_cache/tests.rs index 5501ac7492..e8dac691d3 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/offscreen_bitmap_cache/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/offscreen_bitmap_cache/tests.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; @@ -8,14 +9,11 @@ const OFFSCREEN_BITMAP_CACHE_BUFFER: [u8; 8] = [ 0x00, 0x1e, // offscreenCacheSize 0x64, 0x00, // offscreenCacheEntries ]; - -lazy_static! { - pub static ref OFFSCREEN_BITMAP_CACHE: OffscreenBitmapCache = OffscreenBitmapCache { - is_supported: true, - cache_size: 7680, - cache_entries: 100, - }; -} +static OFFSCREEN_BITMAP_CACHE: LazyLock = LazyLock::new(|| OffscreenBitmapCache { + is_supported: true, + cache_size: 7680, + cache_entries: 100, +}); #[test] fn from_buffer_correctly_parses_offscreen_bitmap_cache_capset() { diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/order.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/order/mod.rs similarity index 80% rename from crates/ironrdp-pdu/src/rdp/capability_sets/order.rs rename to crates/ironrdp-pdu/src/rdp/capability_sets/order/mod.rs index 30de1c57f4..20f9f39348 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/order.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/order/mod.rs @@ -2,14 +2,16 @@ mod tests; use bitflags::bitflags; -use ironrdp_core::{ensure_fixed_part_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor}; +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size}; const ORDER_LENGTH: usize = 84; const ORD_LEVEL_1_ORDERS: u16 = 1; const SUPPORT_ARRAY_LEN: usize = 32; const DESKTOP_SAVE_Y_GRAN_VAL: u16 = 20; +#[repr(u8)] #[derive(Copy, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum OrderSupportIndex { DstBlt = 0x00, PatBlt = 0x01, @@ -34,32 +36,49 @@ pub enum OrderSupportIndex { Index = 0x1B, } +impl OrderSupportIndex { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct OrderFlags: u16 { const NEGOTIATE_ORDER_SUPPORT = 0x0002; const ZERO_BOUNDS_DELTAS_SUPPORT = 0x0008; const COLOR_INDEX_SUPPORT = 0x0020; const SOLID_PATTERN_BRUSH_ONLY = 0x0040; const ORDER_FLAGS_EXTRA_FLAGS = 0x0080; + + const _ = !0; } } bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct OrderSupportExFlags: u16 { const CACHE_BITMAP_REV3_SUPPORT = 2; const ALTSEC_FRAME_MARKER_SUPPORT = 4; + + const _ = !0; } } #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Order { - pub order_flags: OrderFlags, + order_flags: OrderFlags, order_support: [u8; SUPPORT_ARRAY_LEN], - pub order_support_ex_flags: OrderSupportExFlags, - pub desktop_save_size: u32, - pub text_ansi_code_page: u16, + order_support_ex_flags: OrderSupportExFlags, + desktop_save_size: u32, + text_ansi_code_page: u16, } impl Order { @@ -83,11 +102,11 @@ impl Order { } pub fn set_support_flag(&mut self, flag: OrderSupportIndex, value: bool) { - self.order_support[flag as usize] = u8::from(value) + self.order_support[usize::from(flag.as_u8())] = u8::from(value) } pub fn get_support_flag(&mut self, flag: OrderSupportIndex) -> bool { - self.order_support[flag as usize] == 1 + self.order_support[usize::from(flag.as_u8())] == 1 } } @@ -138,12 +157,12 @@ impl<'de> Decode<'de> for Order { let _max_order_level = src.read_u16(); let _num_fonts = src.read_u16(); - let order_flags = OrderFlags::from_bits_truncate(src.read_u16()); + let order_flags = OrderFlags::from_bits_retain(src.read_u16()); let order_support = src.read_array(); let _text_flags = src.read_u16(); - let order_support_ex_flags = OrderSupportExFlags::from_bits_truncate(src.read_u16()); + let order_support_ex_flags = OrderSupportExFlags::from_bits_retain(src.read_u16()); let _padding = src.read_u32(); let desktop_save_size = src.read_u32(); diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/order/tests.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/order/tests.rs index 3f6c873ee8..85d0e2ee2b 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/order/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/order/tests.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; @@ -24,42 +25,40 @@ const ORDER_BUFFER: [u8; 84] = [ 0x00, 0x00, // pad2octetsE ]; -lazy_static! { - pub static ref ORDER: Order = Order { - order_flags: OrderFlags::COLOR_INDEX_SUPPORT | OrderFlags::NEGOTIATE_ORDER_SUPPORT, - order_support: { - let mut array = [0u8; 32]; +static ORDER: LazyLock = LazyLock::new(|| Order { + order_flags: OrderFlags::COLOR_INDEX_SUPPORT | OrderFlags::NEGOTIATE_ORDER_SUPPORT, + order_support: { + let mut array = [0u8; 32]; - array[OrderSupportIndex::DstBlt as usize] = 1; - array[OrderSupportIndex::PatBlt as usize] = 1; - array[OrderSupportIndex::ScrBlt as usize] = 1; - array[OrderSupportIndex::MemBlt as usize] = 1; - array[OrderSupportIndex::Mem3Blt as usize] = 1; - array[OrderSupportIndex::DrawnInEGrid as usize] = 1; - array[OrderSupportIndex::LineTo as usize] = 1; - array[OrderSupportIndex::MultiDrawnInEGrid as usize] = 1; - array[OrderSupportIndex::SaveBitmap as usize] = 1; - array[OrderSupportIndex::MultiDstBlt as usize] = 1; - array[OrderSupportIndex::MultiPatBlt as usize] = 1; - array[OrderSupportIndex::MultiScrBlt as usize] = 1; - array[OrderSupportIndex::MultiOpaqueRect as usize] = 1; - array[OrderSupportIndex::Fast as usize] = 1; - array[OrderSupportIndex::PolygonSC as usize] = 1; - array[OrderSupportIndex::PolygonCB as usize] = 1; - array[OrderSupportIndex::Polyline as usize] = 1; - array[OrderSupportIndex::FastGlyph as usize] = 1; - array[OrderSupportIndex::EllipseSC as usize] = 1; - array[OrderSupportIndex::EllipseCB as usize] = 1; - array[OrderSupportIndex::Index as usize] = 1; + array[usize::from(OrderSupportIndex::DstBlt.as_u8())] = 1; + array[usize::from(OrderSupportIndex::PatBlt.as_u8())] = 1; + array[usize::from(OrderSupportIndex::ScrBlt.as_u8())] = 1; + array[usize::from(OrderSupportIndex::MemBlt.as_u8())] = 1; + array[usize::from(OrderSupportIndex::Mem3Blt.as_u8())] = 1; + array[usize::from(OrderSupportIndex::DrawnInEGrid.as_u8())] = 1; + array[usize::from(OrderSupportIndex::LineTo.as_u8())] = 1; + array[usize::from(OrderSupportIndex::MultiDrawnInEGrid.as_u8())] = 1; + array[usize::from(OrderSupportIndex::SaveBitmap.as_u8())] = 1; + array[usize::from(OrderSupportIndex::MultiDstBlt.as_u8())] = 1; + array[usize::from(OrderSupportIndex::MultiPatBlt.as_u8())] = 1; + array[usize::from(OrderSupportIndex::MultiScrBlt.as_u8())] = 1; + array[usize::from(OrderSupportIndex::MultiOpaqueRect.as_u8())] = 1; + array[usize::from(OrderSupportIndex::Fast.as_u8())] = 1; + array[usize::from(OrderSupportIndex::PolygonSC.as_u8())] = 1; + array[usize::from(OrderSupportIndex::PolygonCB.as_u8())] = 1; + array[usize::from(OrderSupportIndex::Polyline.as_u8())] = 1; + array[usize::from(OrderSupportIndex::FastGlyph.as_u8())] = 1; + array[usize::from(OrderSupportIndex::EllipseSC.as_u8())] = 1; + array[usize::from(OrderSupportIndex::EllipseCB.as_u8())] = 1; + array[usize::from(OrderSupportIndex::Index.as_u8())] = 1; - array - }, + array + }, - order_support_ex_flags: OrderSupportExFlags::CACHE_BITMAP_REV3_SUPPORT, - desktop_save_size: 230_400, - text_ansi_code_page: 0, - }; -} + order_support_ex_flags: OrderSupportExFlags::CACHE_BITMAP_REV3_SUPPORT, + desktop_save_size: 230_400, + text_ansi_code_page: 0, +}); #[test] fn from_buffer_correctly_parses_order_capset() { diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/pointer.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/pointer.rs index 5c4d98424e..2460f8dcfc 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/pointer.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/pointer.rs @@ -1,11 +1,12 @@ #[cfg(test)] mod tests; -use ironrdp_core::{ensure_fixed_part_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor}; +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size}; const POINTER_LENGTH: usize = 6; #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Pointer { pub color_pointer_cache_size: u16, pub pointer_cache_size: u16, diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/pointer/tests.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/pointer/tests.rs index d5933ab96f..f7b0a73a1b 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/pointer/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/pointer/tests.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; @@ -9,12 +10,10 @@ const POINTER_BUFFER: [u8; 6] = [ 0x15, 0x00, // pointerCacheSize ]; -lazy_static! { - pub static ref POINTER: Pointer = Pointer { - color_pointer_cache_size: 20, - pointer_cache_size: 21, - }; -} +static POINTER: LazyLock = LazyLock::new(|| Pointer { + color_pointer_cache_size: 20, + pointer_cache_size: 21, +}); #[test] fn from_buffer_correctly_parses_pointer_capset() { diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/sound.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/sound/mod.rs similarity index 74% rename from crates/ironrdp-pdu/src/rdp/capability_sets/sound.rs rename to crates/ironrdp-pdu/src/rdp/capability_sets/sound/mod.rs index b4ca1e6747..28cd5bbce3 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/sound.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/sound/mod.rs @@ -3,20 +3,24 @@ mod tests; use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, read_padding, write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, read_padding, + write_padding, }; const SOUND_LENGTH: usize = 4; bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SoundFlags: u16 { const BEEPS = 1; + + const _ = !0; } } #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Sound { pub flags: SoundFlags, } @@ -50,7 +54,7 @@ impl<'de> Decode<'de> for Sound { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let flags = SoundFlags::from_bits_truncate(src.read_u16()); + let flags = SoundFlags::from_bits_retain(src.read_u16()); read_padding!(src, 2); Ok(Sound { flags }) diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/sound/tests.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/sound/tests.rs index 0d3627ec94..d43f52b0e2 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/sound/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/sound/tests.rs @@ -1,15 +1,14 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; const SOUND_BUFFER: [u8; 4] = [0x01, 0x00, 0x00, 0x00]; -lazy_static! { - pub static ref SOUND: Sound = Sound { - flags: SoundFlags::BEEPS, - }; -} +static SOUND: LazyLock = LazyLock::new(|| Sound { + flags: SoundFlags::BEEPS, +}); #[test] fn from_buffer_correctly_parses_sound_capset() { diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/surface_commands.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/surface_commands.rs index c33cb17881..baef802147 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/surface_commands.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/surface_commands.rs @@ -2,20 +2,24 @@ mod tests; use bitflags::bitflags; -use ironrdp_core::{ensure_fixed_part_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor}; +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size}; const SURFACE_COMMANDS_LENGTH: usize = 8; bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct CmdFlags: u32 { const SET_SURFACE_BITS = 0x02; const FRAME_MARKER = 0x10; const STREAM_SURFACE_BITS = 0x40; + + const _ = !0; } } #[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SurfaceCommands { pub flags: CmdFlags, } @@ -49,7 +53,7 @@ impl<'de> Decode<'de> for SurfaceCommands { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let flags = CmdFlags::from_bits_truncate(src.read_u32()); + let flags = CmdFlags::from_bits_retain(src.read_u32()); let _reserved = src.read_u32(); Ok(SurfaceCommands { flags }) diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/surface_commands/tests.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/surface_commands/tests.rs index 1dfbb2c0b1..ac96f46ec0 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/surface_commands/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/surface_commands/tests.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; @@ -8,11 +9,9 @@ const SURFACE_COMMANDS_BUFFER: [u8; 8] = [ 0x00, 0x00, 0x00, 0x00, // reserved ]; -lazy_static! { - pub static ref SURFACE_COMMANDS: SurfaceCommands = SurfaceCommands { - flags: CmdFlags::SET_SURFACE_BITS | CmdFlags::FRAME_MARKER | CmdFlags::STREAM_SURFACE_BITS, - }; -} +static SURFACE_COMMANDS: LazyLock = LazyLock::new(|| SurfaceCommands { + flags: CmdFlags::SET_SURFACE_BITS | CmdFlags::FRAME_MARKER | CmdFlags::STREAM_SURFACE_BITS, +}); #[test] fn from_buffer_correctly_parses_surface_commands_capset() { diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/virtual_channel.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/virtual_channel/mod.rs similarity index 86% rename from crates/ironrdp-pdu/src/rdp/capability_sets/virtual_channel.rs rename to crates/ironrdp-pdu/src/rdp/capability_sets/virtual_channel/mod.rs index 84e6ac42f5..434d85c453 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/virtual_channel.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/virtual_channel/mod.rs @@ -3,7 +3,7 @@ mod tests; use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, ensure_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, }; const FLAGS_FIELD_SIZE: usize = 4; @@ -11,10 +11,13 @@ const CHUNK_SIZE_FIELD_SIZE: usize = 4; bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct VirtualChannelFlags: u32 { const NO_COMPRESSION = 0; const COMPRESSION_SERVER_TO_CLIENT = 1; const COMPRESSION_CLIENT_TO_SERVER_8K = 2; + + const _ = !0; } } @@ -29,7 +32,8 @@ bitflags! { /// # MSDN /// /// * [Virtual Channel Capability Set](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/a8593178-80c0-4b80-876c-cb77e62cecfc) -#[derive(Debug, PartialEq, Eq, Clone)] +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct VirtualChannel { pub flags: VirtualChannelFlags, pub chunk_size: Option, @@ -76,7 +80,7 @@ impl<'de> Decode<'de> for VirtualChannel { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let flags = VirtualChannelFlags::from_bits_truncate(src.read_u32()); + let flags = VirtualChannelFlags::from_bits_retain(src.read_u32()); let mut virtual_channel_pdu = Self { flags, diff --git a/crates/ironrdp-pdu/src/rdp/capability_sets/virtual_channel/tests.rs b/crates/ironrdp-pdu/src/rdp/capability_sets/virtual_channel/tests.rs index 6f42c54984..ff14c89a92 100644 --- a/crates/ironrdp-pdu/src/rdp/capability_sets/virtual_channel/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/capability_sets/virtual_channel/tests.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; @@ -12,16 +13,14 @@ const VIRTUAL_CHANNEL_BUFFER: [u8; 8] = [ 0x40, 0x06, 0x00, 0x00, // chunk size ]; -lazy_static! { - pub static ref VIRTUAL_CHANNEL_INCOMPLETE: VirtualChannel = VirtualChannel { - flags: VirtualChannelFlags::COMPRESSION_SERVER_TO_CLIENT, - chunk_size: None, - }; - pub static ref VIRTUAL_CHANNEL: VirtualChannel = VirtualChannel { - flags: VirtualChannelFlags::NO_COMPRESSION, - chunk_size: Some(1600), - }; -} +static VIRTUAL_CHANNEL_INCOMPLETE: LazyLock = LazyLock::new(|| VirtualChannel { + flags: VirtualChannelFlags::COMPRESSION_SERVER_TO_CLIENT, + chunk_size: None, +}); +static VIRTUAL_CHANNEL: LazyLock = LazyLock::new(|| VirtualChannel { + flags: VirtualChannelFlags::NO_COMPRESSION, + chunk_size: Some(1600), +}); #[test] fn from_buffer_correctly_parses_virtual_channel_incomplete_capset() { @@ -38,7 +37,7 @@ fn from_buffer_correctly_parses_virtual_channel_capset() { #[test] fn to_buffer_correctly_serializes_virtual_channel_incomplete_capset() { - let c = VIRTUAL_CHANNEL_INCOMPLETE.clone(); + let c = *VIRTUAL_CHANNEL_INCOMPLETE; let buffer = encode_vec(&c).unwrap(); @@ -47,7 +46,7 @@ fn to_buffer_correctly_serializes_virtual_channel_incomplete_capset() { #[test] fn to_buffer_correctly_serializes_virtual_channel_capset() { - let c = VIRTUAL_CHANNEL.clone(); + let c = *VIRTUAL_CHANNEL; let buffer = encode_vec(&c).unwrap(); diff --git a/crates/ironrdp-pdu/src/rdp/client_info.rs b/crates/ironrdp-pdu/src/rdp/client_info.rs index a3e90fafdd..da125aa3bd 100644 --- a/crates/ironrdp-pdu/src/rdp/client_info.rs +++ b/crates/ironrdp-pdu/src/rdp/client_info.rs @@ -1,17 +1,15 @@ use core::fmt; -use std::io; use bitflags::bitflags; use ironrdp_core::{ - ensure_fixed_part_size, ensure_size, invalid_field_err, write_padding, Decode, DecodeResult, Encode, EncodeResult, - ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, write_padding, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; -use thiserror::Error; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; +use crate::utils; use crate::utils::CharacterSet; -use crate::{utils, PduError}; const RECONNECT_COOKIE_LEN: usize = 28; const TIMEZONE_INFO_NAME_LEN: usize = 64; @@ -37,6 +35,7 @@ const BIAS_SIZE: usize = 4; /// /// [2.2.1.11.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/732394f5-e2b5-4ac5-8a0a-35345386b0d1 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientInfo { pub credentials: Credentials, pub code_page: u32, @@ -71,15 +70,30 @@ impl Encode for ClientInfo { dst.write_u32(self.code_page); - let flags_with_compression_type = self.flags.bits() | (self.compression_type.to_u32().unwrap() << 9); + let flags_with_compression_type = self.flags.bits() | (u32::from(self.compression_type.as_u8()) << 9); dst.write_u32(flags_with_compression_type); let domain = self.credentials.domain.clone().unwrap_or_default(); - dst.write_u16(string_len(domain.as_str(), character_set)); - dst.write_u16(string_len(self.credentials.username.as_str(), character_set)); - dst.write_u16(string_len(self.credentials.password.as_str(), character_set)); - dst.write_u16(string_len(self.alternate_shell.as_str(), character_set)); - dst.write_u16(string_len(self.work_dir.as_str(), character_set)); + dst.write_u16(cast_length!( + "domain length", + string_len(domain.as_str(), character_set) + )?); + dst.write_u16(cast_length!( + "username length", + string_len(self.credentials.username.as_str(), character_set) + )?); + dst.write_u16(cast_length!( + "password length", + string_len(self.credentials.password.as_str(), character_set) + )?); + dst.write_u16(cast_length!( + "alternate shell length", + string_len(self.alternate_shell.as_str(), character_set) + )?); + dst.write_u16(cast_length!( + "work dir length", + string_len(self.work_dir.as_str(), character_set) + )?); utils::write_string_to_cursor(dst, domain.as_str(), character_set, true)?; utils::write_string_to_cursor(dst, self.credentials.username.as_str(), character_set, true)?; @@ -111,12 +125,12 @@ impl Encode for ClientInfo { + PASSWORD_LENGTH_SIZE + ALTERNATE_SHELL_LENGTH_SIZE + WORK_DIR_LENGTH_SIZE - + (string_len(domain.as_str(), character_set) + + string_len(domain.as_str(), character_set) + string_len(self.credentials.username.as_str(), character_set) + string_len(self.credentials.password.as_str(), character_set) + string_len(self.alternate_shell.as_str(), character_set) - + string_len(self.work_dir.as_str(), character_set)) as usize - + character_set.to_usize().unwrap() * 5 // null terminator + + string_len(self.work_dir.as_str(), character_set) + + usize::from(character_set.as_u16()) * 5 // null terminator + self.extra_info.size(character_set) } } @@ -130,9 +144,8 @@ impl<'de> Decode<'de> for ClientInfo { let flags = ClientInfoFlags::from_bits(flags_with_compression_type & !COMPRESSION_TYPE_MASK) .ok_or_else(|| invalid_field_err!("flags", "invalid ClientInfoFlags"))?; - let compression_type = - CompressionType::from_u8(((flags_with_compression_type & COMPRESSION_TYPE_MASK) >> 9) as u8) - .ok_or_else(|| invalid_field_err!("flags", "invalid CompressionType"))?; + let compression_type = CompressionType::from_u32((flags_with_compression_type & COMPRESSION_TYPE_MASK) >> 9) + .ok_or_else(|| invalid_field_err!("flags", "invalid CompressionType"))?; let character_set = if flags.contains(ClientInfoFlags::UNICODE) { CharacterSet::Unicode @@ -141,12 +154,12 @@ impl<'de> Decode<'de> for ClientInfo { }; // Sizes exclude the length of the mandatory null terminator - let nt = character_set.to_usize().unwrap(); - let domain_size = src.read_u16() as usize + nt; - let user_name_size = src.read_u16() as usize + nt; - let password_size = src.read_u16() as usize + nt; - let alternate_shell_size = src.read_u16() as usize + nt; - let work_dir_size = src.read_u16() as usize + nt; + let nt = usize::from(character_set.as_u16()); + let domain_size = usize::from(src.read_u16()) + nt; + let user_name_size = usize::from(src.read_u16()) + nt; + let password_size = usize::from(src.read_u16()) + nt; + let alternate_shell_size = usize::from(src.read_u16()) + nt; + let work_dir_size = usize::from(src.read_u16()) + nt; ensure_size!(in: src, size: domain_size + user_name_size + password_size + alternate_shell_size + work_dir_size); let domain = utils::decode_string(src.read_slice(domain_size), character_set, true)?; @@ -178,6 +191,7 @@ impl<'de> Decode<'de> for ClientInfo { } #[derive(Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Credentials { pub username: String, pub password: String, @@ -195,6 +209,7 @@ impl fmt::Debug for Credentials { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ExtendedClientInfo { pub address_family: AddressFamily, pub address: String, @@ -211,12 +226,12 @@ impl ExtendedClientInfo { let address_family = AddressFamily::from_u16(src.read_u16()); // This size includes the length of the mandatory null terminator. - let address_size = src.read_u16() as usize; + let address_size = usize::from(src.read_u16()); ensure_size!(in: src, size: address_size + CLIENT_DIR_LENGTH_SIZE); let address = utils::decode_string(src.read_slice(address_size), character_set, false)?; // This size includes the length of the mandatory null terminator. - let dir_size = src.read_u16() as usize; + let dir_size = usize::from(src.read_u16()); ensure_size!(in: src, size: dir_size); let dir = utils::decode_string(src.read_slice(dir_size), character_set, false)?; @@ -234,11 +249,14 @@ impl ExtendedClientInfo { fn encode(&self, dst: &mut WriteCursor<'_>, character_set: CharacterSet) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size(character_set)); + let address_string_len: u16 = cast_length!("address length", string_len(self.address.as_str(), character_set))?; + let dir_string_len: u16 = cast_length!("dir length", string_len(self.dir.as_str(), character_set))?; + dst.write_u16(self.address_family.as_u16()); // // + size of null terminator, which will write in the write_string function - dst.write_u16(string_len(self.address.as_str(), character_set) + character_set.to_u16().unwrap()); + dst.write_u16(address_string_len + character_set.as_u16()); utils::write_string_to_cursor(dst, self.address.as_str(), character_set, true)?; - dst.write_u16(string_len(self.dir.as_str(), character_set) + character_set.to_u16().unwrap()); + dst.write_u16(dir_string_len + character_set.as_u16()); utils::write_string_to_cursor(dst, self.dir.as_str(), character_set, true)?; self.optional_data.encode(dst)?; @@ -248,16 +266,17 @@ impl ExtendedClientInfo { fn size(&self, character_set: CharacterSet) -> usize { CLIENT_ADDRESS_FAMILY_SIZE + CLIENT_ADDRESS_LENGTH_SIZE - + string_len(self.address.as_str(), character_set) as usize - + character_set.to_usize().unwrap() // null terminator + + string_len(self.address.as_str(), character_set) + + usize::from(character_set.as_u16()) // null terminator + CLIENT_DIR_LENGTH_SIZE - + string_len(self.dir.as_str(), character_set) as usize - + character_set.to_usize().unwrap() // null terminator + + string_len(self.dir.as_str(), character_set) + + usize::from(character_set.as_u16()) // null terminator + self.optional_data.size() } } #[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ExtendedClientOptionalInfo { timezone: Option, session_id: Option, @@ -270,8 +289,8 @@ impl ExtendedClientOptionalInfo { const NAME: &'static str = "ExtendedClientOptionalInfo"; /// Creates a new builder for [`ExtendedClientOptionalInfo`]. - pub fn builder( - ) -> builder::ExtendedClientOptionalInfoBuilder { + pub fn builder() + -> builder::ExtendedClientOptionalInfoBuilder { builder::ExtendedClientOptionalInfoBuilder::::default() } @@ -306,7 +325,7 @@ impl Encode for ExtendedClientOptionalInfo { dst.write_u32(performance_flags.bits()); } if let Some(reconnect_cookie) = self.reconnect_cookie { - dst.write_u16(RECONNECT_COOKIE_LEN as u16); + dst.write_u16(u16::try_from(RECONNECT_COOKIE_LEN).expect("RECONNECT_COOKIE_LEN fit into u16")); dst.write_array(reconnect_cookie); } @@ -363,7 +382,9 @@ impl<'de> Decode<'de> for ExtendedClientOptionalInfo { return Ok(optional_data); } let reconnect_cookie_size = src.read_u16(); - if reconnect_cookie_size != RECONNECT_COOKIE_LEN as u16 && reconnect_cookie_size != 0 { + if reconnect_cookie_size != u16::try_from(RECONNECT_COOKIE_LEN).expect("RECONNECT_COOKIE_LEN fit into u16") + && reconnect_cookie_size != 0 + { return Err(invalid_field_err!("cbAutoReconnectCookie", "invalid cookie size")); } if reconnect_cookie_size != 0 { @@ -389,6 +410,7 @@ impl<'de> Decode<'de> for ExtendedClientOptionalInfo { /// /// [2.2.1.11.1.1.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/526ed635-d7a9-4d3c-bbe1-4e3fb17585f4 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct TimezoneInfo { pub bias: i32, pub standard_name: String, @@ -483,6 +505,7 @@ impl Default for TimezoneInfo { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SystemTime { pub month: Month, pub day_of_week: DayOfWeek, @@ -500,6 +523,7 @@ impl SystemTime { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct OptionalSystemTime(pub Option); impl Encode for OptionalSystemTime { @@ -508,9 +532,9 @@ impl Encode for OptionalSystemTime { dst.write_u16(0); // year if let Some(st) = &self.0 { - dst.write_u16(st.month.to_u16().unwrap()); - dst.write_u16(st.day_of_week.to_u16().unwrap()); - dst.write_u16(st.day.to_u16().unwrap()); + dst.write_u16(st.month.as_u16()); + dst.write_u16(st.day_of_week.as_u16()); + dst.write_u16(st.day.as_u16()); dst.write_u16(st.hour); dst.write_u16(st.minute); dst.write_u16(st.second); @@ -564,7 +588,8 @@ impl<'de> Decode<'de> for OptionalSystemTime { } #[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum Month { January = 1, February = 2, @@ -580,8 +605,19 @@ pub enum Month { December = 12, } +impl Month { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + #[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum DayOfWeek { Sunday = 0, Monday = 1, @@ -592,8 +628,19 @@ pub enum DayOfWeek { Saturday = 6, } +impl DayOfWeek { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + #[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum DayOfWeekOccurrence { First = 1, Second = 2, @@ -602,8 +649,19 @@ pub enum DayOfWeekOccurrence { Last = 5, } +impl DayOfWeekOccurrence { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct PerformanceFlags: u32 { const DISABLE_WALLPAPER = 0x0000_0001; const DISABLE_FULLWINDOWDRAG = 0x0000_0002; @@ -626,14 +684,13 @@ impl Default for PerformanceFlags { #[repr(transparent)] #[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct AddressFamily(u16); impl AddressFamily { pub const INET: Self = Self(0x0002); pub const INET_6: Self = Self(0x0017); -} -impl AddressFamily { pub fn from_u16(val: u16) -> Self { Self(val) } @@ -645,6 +702,7 @@ impl AddressFamily { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientInfoFlags: u32 { /// INFO_MOUSE const MOUSE = 0x0000_0001; @@ -691,7 +749,9 @@ bitflags! { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u8)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum CompressionType { K8 = 0, K64 = 1, @@ -699,41 +759,28 @@ pub enum CompressionType { Rdp61 = 3, } -#[derive(Debug, Error)] -pub enum ClientInfoError { - #[error("IO error")] - IOError(#[from] io::Error), - #[error("UTF-8 error")] - Utf8Error(#[from] std::string::FromUtf8Error), - #[error("invalid address family field")] - InvalidAddressFamily, - #[error("invalid flags field")] - InvalidClientInfoFlags, - #[error("invalid performance flags field")] - InvalidPerformanceFlags, - #[error("invalid reconnect cookie field")] - InvalidReconnectCookie, - #[error("PDU error: {0}")] - Pdu(PduError), -} - -impl From for ClientInfoError { - fn from(e: PduError) -> Self { - Self::Pdu(e) +impl CompressionType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u8(self) -> u8 { + self as u8 } } -fn string_len(value: &str, character_set: CharacterSet) -> u16 { +fn string_len(value: &str, character_set: CharacterSet) -> usize { match character_set { - CharacterSet::Ansi => u16::try_from(value.len()).unwrap(), - CharacterSet::Unicode => u16::try_from(value.encode_utf16().count() * 2).unwrap(), + CharacterSet::Ansi => value.len(), + // TODO: Use UTF-16 helper. + CharacterSet::Unicode => value.encode_utf16().count() * 2, } } pub mod builder { use core::marker::PhantomData; - use super::{ExtendedClientOptionalInfo, PerformanceFlags, TimezoneInfo, RECONNECT_COOKIE_LEN}; + use super::{ExtendedClientOptionalInfo, PerformanceFlags, RECONNECT_COOKIE_LEN, TimezoneInfo}; pub struct ExtendedClientOptionalInfoBuilderStateSetTimeZone; pub struct ExtendedClientOptionalInfoBuilderStateSetSessionId; diff --git a/crates/ironrdp-pdu/src/rdp/finalization_messages.rs b/crates/ironrdp-pdu/src/rdp/finalization_messages.rs index a1c9fb7088..fee1ae58e3 100644 --- a/crates/ironrdp-pdu/src/rdp/finalization_messages.rs +++ b/crates/ironrdp-pdu/src/rdp/finalization_messages.rs @@ -1,10 +1,10 @@ use bitflags::bitflags; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + invalid_field_err, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use crate::gcc; @@ -15,6 +15,7 @@ const SYNCHRONIZE_MESSAGE_TYPE: u16 = 1; const MAX_MONITOR_COUNT: u32 = 64; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SynchronizePdu { pub target_user_id: u16, } @@ -60,6 +61,7 @@ impl<'de> Decode<'de> for SynchronizePdu { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ControlPdu { pub action: ControlAction, pub grant_id: u16, @@ -76,7 +78,7 @@ impl Encode for ControlPdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u16(self.action.to_u16().unwrap()); + dst.write_u16(self.action.as_u16()); dst.write_u16(self.grant_id); dst.write_u32(self.control_id); @@ -113,6 +115,7 @@ impl<'de> Decode<'de> for ControlPdu { /// /// [2.2.1.22.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/b4e557f3-7540-46fc-815d-0c12299cf1ee #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct FontPdu { pub number: u16, pub total_number: u16, @@ -179,6 +182,7 @@ impl<'de> Decode<'de> for FontPdu { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct MonitorLayoutPdu { pub monitors: Vec, } @@ -220,7 +224,10 @@ impl<'de> Decode<'de> for MonitorLayoutPdu { return Err(invalid_field_err!("nMonitors", "invalid monitor count")); } - let mut monitors = Vec::with_capacity(monitor_count as usize); + let mut monitors = Vec::with_capacity( + usize::try_from(monitor_count) + .expect("monitor_count is guaranteed to fit into usize due to the prior check"), + ); for _ in 0..monitor_count { monitors.push(gcc::Monitor::decode(src)?); } @@ -230,7 +237,8 @@ impl<'de> Decode<'de> for MonitorLayoutPdu { } #[repr(u16)] -#[derive(Debug, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ControlAction { RequestControl = 1, GrantedControl = 2, @@ -238,8 +246,19 @@ pub enum ControlAction { Cooperate = 4, } +impl ControlAction { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SequenceFlags: u16 { const FIRST = 1; const LAST = 2; diff --git a/crates/ironrdp-pdu/src/rdp/headers.rs b/crates/ironrdp-pdu/src/rdp/headers.rs index 5632c1d15b..223097f799 100644 --- a/crates/ironrdp-pdu/src/rdp/headers.rs +++ b/crates/ironrdp-pdu/src/rdp/headers.rs @@ -1,16 +1,19 @@ use bitflags::bitflags; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, not_enough_bytes_err, other_err, read_padding, - write_padding, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteBuf, WriteCursor, cast_length, decode, + ensure_fixed_part_size, ensure_size, invalid_field_err, not_enough_bytes_err, other_err, read_padding, + write_padding, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use crate::codecs::rfx::FrameAcknowledgePdu; use crate::input::InputEventPdu; +use crate::mcs::SendDataIndicationCtx; use crate::rdp::capability_sets::{ClientConfirmActive, ServerDemandActive}; use crate::rdp::client_info; use crate::rdp::finalization_messages::{ControlPdu, FontPdu, MonitorLayoutPdu, SynchronizePdu}; +use crate::rdp::multitransport::MultitransportRequestPdu; use crate::rdp::refresh_rectangle::RefreshRectanglePdu; use crate::rdp::server_error_info::ServerSetErrorInfoPdu; use crate::rdp::session_info::SaveSessionInfoPdu; @@ -32,6 +35,7 @@ const COMPRESSION_TYPE_FIELD_SIZE: usize = 1; const COMPRESSED_LENGTH_FIELD_SIZE: usize = 2; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BasicSecurityHeader { pub flags: BasicSecurityHeaderFlags, } @@ -72,7 +76,156 @@ impl<'de> Decode<'de> for BasicSecurityHeader { } } +/// Encodes a [`ShareControlPdu`] wrapped in an MCS Send Data Request. +pub fn encode_share_control( + initiator_id: u16, + channel_id: u16, + share_id: u32, + pdu: ShareControlPdu, + buf: &mut WriteBuf, +) -> EncodeResult { + let share_control_header = ShareControlHeader { + share_control_pdu: pdu, + pdu_source: initiator_id, + share_id, + }; + + crate::mcs::encode_send_data_request(initiator_id, channel_id, &share_control_header, buf) +} + +/// Encodes a [`ShareDataPdu`] wrapped in a Share Control header and an MCS Send Data Request. +pub fn encode_share_data( + initiator_id: u16, + channel_id: u16, + share_id: u32, + pdu: ShareDataPdu, + buf: &mut WriteBuf, +) -> EncodeResult { + let share_data_header = ShareDataHeader { + share_data_pdu: pdu, + stream_priority: StreamPriority::Medium, + compression_flags: CompressionFlags::empty(), + compression_type: client_info::CompressionType::K8, // ignored if CompressionFlags::empty() + }; + + encode_share_control( + initiator_id, + channel_id, + share_id, + ShareControlPdu::Data(share_data_header), + buf, + ) +} + +/// A decoded Share Control PDU together with its channel routing information. +#[derive(Debug, Clone)] +pub struct ShareControlCtx { + pub initiator_id: u16, + pub channel_id: u16, + pub share_id: u32, + pub pdu_source: u16, + pub pdu: ShareControlPdu, +} + +/// Decodes a [`ShareControlHeader`] from the user data of a Send Data Indication. +pub fn decode_share_control(ctx: SendDataIndicationCtx<'_>) -> DecodeResult { + let user_msg = ctx.decode_user_data::()?; + + Ok(ShareControlCtx { + initiator_id: ctx.initiator_id, + channel_id: ctx.channel_id, + share_id: user_msg.share_id, + pdu_source: user_msg.pdu_source, + pdu: user_msg.share_control_pdu, + }) +} + +/// A decoded Share Data PDU together with its channel routing information. +#[derive(Debug, Clone)] +pub struct ShareDataCtx { + pub initiator_id: u16, + pub channel_id: u16, + pub share_id: u32, + pub pdu_source: u16, + pub pdu: ShareDataPdu, +} + +/// Decodes a [`ShareDataHeader`] from the user data of a Send Data Indication. +pub fn decode_share_data(ctx: SendDataIndicationCtx<'_>) -> DecodeResult { + let ctx = decode_share_control(ctx)?; + + let ShareControlPdu::Data(share_data_header) = ctx.pdu else { + return Err(other_err!( + "decode_share_data", + "received unexpected Share Control PDU (expected Data PDU)" + )); + }; + + Ok(ShareDataCtx { + initiator_id: ctx.initiator_id, + channel_id: ctx.channel_id, + share_id: ctx.share_id, + pdu_source: ctx.pdu_source, + pdu: share_data_header.share_data_pdu, + }) +} + +/// A PDU received on the RDP IO channel. +pub enum IoChannelPdu { + Data(ShareDataCtx), + DeactivateAll(ServerDeactivateAll), + /// Server Initiate Multitransport Request PDU. + /// + /// Received when the server wants the client to establish a sideband UDP transport. + MultitransportRequest(MultitransportRequestPdu), +} + +/// Decodes a PDU received on the RDP IO channel from the user data of a Send Data Indication. +pub fn decode_io_channel(ctx: SendDataIndicationCtx<'_>) -> DecodeResult { + // Multitransport PDUs use BasicSecurityHeader (flags:u16, flagsHi:u16) instead + // of the ShareControlHeader (totalLength:u16, pduType:u16, ...) used by all + // other IO channel PDUs. We discriminate by checking flagsHi == 0 (ShareControl + // has pduType there, which is always non-zero) and requiring flags to be a valid + // BasicSecurityHeaderFlags combination. + if ctx.user_data.len() >= BASIC_SECURITY_HEADER_SIZE { + let flags_raw = u16::from_le_bytes([ctx.user_data[0], ctx.user_data[1]]); + let flags_hi = u16::from_le_bytes([ctx.user_data[2], ctx.user_data[3]]); + + if flags_hi == 0 { + if let Some(flags) = BasicSecurityHeaderFlags::from_bits(flags_raw) { + if flags.contains(BasicSecurityHeaderFlags::TRANSPORT_REQ) { + if let Ok(pdu) = decode::(ctx.user_data) { + return Ok(IoChannelPdu::MultitransportRequest(pdu)); + } + } + } + } + } + + let ctx = decode_share_control(ctx)?; + + match ctx.pdu { + ShareControlPdu::ServerDeactivateAll(deactivate_all) => Ok(IoChannelPdu::DeactivateAll(deactivate_all)), + ShareControlPdu::Data(share_data_header) => { + let share_data_ctx = ShareDataCtx { + initiator_id: ctx.initiator_id, + channel_id: ctx.channel_id, + share_id: ctx.share_id, + pdu_source: ctx.pdu_source, + pdu: share_data_header.share_data_pdu, + }; + + Ok(IoChannelPdu::Data(share_data_ctx)) + } + _ => Err(other_err!( + "decode_io_channel", + "received unexpected Share Control PDU (expected Data PDU or Server Deactivate All PDU)" + )), + } +} + #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ShareControlHeader { pub share_control_pdu: ShareControlPdu, pub pdu_source: u16, @@ -89,7 +242,7 @@ impl Encode for ShareControlHeader { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); - let pdu_type_with_version = PROTOCOL_VERSION | self.share_control_pdu.share_header_type().to_u16().unwrap(); + let pdu_type_with_version = PROTOCOL_VERSION | self.share_control_pdu.share_header_type().as_u16(); dst.write_u16(cast_length!( "len", @@ -115,7 +268,7 @@ impl<'de> Decode<'de> for ShareControlHeader { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let total_length = src.read_u16() as usize; + let total_length = usize::from(src.read_u16()); let pdu_type_with_version = src.read_u16(); let pdu_source = src.read_u16(); let share_id = src.read_u32(); @@ -156,6 +309,7 @@ impl<'de> Decode<'de> for ShareControlHeader { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ShareControlPdu { ServerDemandActive(ServerDemandActive), ClientConfirmActive(ClientConfirmActive), @@ -226,6 +380,7 @@ impl Encode for ShareControlPdu { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ShareDataHeader { pub share_data_pdu: ShareDataPdu, pub stream_priority: StreamPriority, @@ -249,18 +404,12 @@ impl Encode for ShareDataHeader { ensure_size!(in: dst, size: self.size()); if self.compression_flags.is_empty() { - let compression_flags_with_type = self.compression_flags.bits() | self.compression_type.to_u8().unwrap(); + let compression_flags_with_type = self.compression_flags.bits() | self.compression_type.as_u8(); write_padding!(dst, 1); - dst.write_u8(self.stream_priority.to_u8().unwrap()); - dst.write_u16(cast_length!( - "uncompressedLength", - self.share_data_pdu.size() - + PDU_TYPE_FIELD_SIZE - + COMPRESSION_TYPE_FIELD_SIZE - + COMPRESSED_LENGTH_FIELD_SIZE - )?); - dst.write_u8(self.share_data_pdu.share_header_type().to_u8().unwrap()); + dst.write_u8(self.stream_priority.as_u8()); + dst.write_u16(cast_length!("uncompressedLength", self.share_data_pdu.size())?); + dst.write_u8(self.share_data_pdu.share_header_type().as_u8()); dst.write_u8(compression_flags_with_type); dst.write_u16(0); // compressed length @@ -292,7 +441,7 @@ impl<'de> Decode<'de> for ShareDataHeader { let compression_flags_with_type = src.read_u8(); let compression_flags = - CompressionFlags::from_bits_truncate(compression_flags_with_type & !SHARE_DATA_HEADER_COMPRESSION_MASK); + CompressionFlags::from_bits_retain(compression_flags_with_type & !SHARE_DATA_HEADER_COMPRESSION_MASK); let compression_type = client_info::CompressionType::from_u8(compression_flags_with_type & SHARE_DATA_HEADER_COMPRESSION_MASK) .ok_or_else(|| invalid_field_err!("compressionType", "Invalid compression type"))?; @@ -310,6 +459,7 @@ impl<'de> Decode<'de> for ShareDataHeader { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ShareDataPdu { Synchronize(SynchronizePdu), Control(ControlPdu), @@ -495,6 +645,7 @@ impl Encode for ShareDataPdu { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BasicSecurityHeaderFlags: u16 { const EXCHANGE_PKT = 0x0001; const TRANSPORT_REQ = 0x0002; @@ -515,7 +666,9 @@ bitflags! { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u8)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum StreamPriority { Undefined = 0, Low = 1, @@ -523,7 +676,19 @@ pub enum StreamPriority { High = 4, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +impl StreamPriority { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + +#[repr(u16)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ShareControlPduType { DemandActivePdu = 0x1, ConfirmActivePdu = 0x3, @@ -532,7 +697,18 @@ pub enum ShareControlPduType { ServerRedirect = 0xa, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +impl ShareControlPduType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] #[repr(u8)] pub enum ShareDataPduType { Update = 0x02, @@ -562,12 +738,25 @@ pub enum ShareDataPduType { FrameAcknowledgePdu = 0x38, } +impl ShareDataPduType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct CompressionFlags: u8 { const COMPRESSED = 0x20; const AT_FRONT = 0x40; const FLUSHED = 0x80; + + const _ = !0; } } @@ -575,6 +764,7 @@ bitflags! { /// /// [2.2.3.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/8a29971a-df3c-48da-add2-8ed9a05edc89 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ServerDeactivateAll; impl ServerDeactivateAll { @@ -583,10 +773,14 @@ impl ServerDeactivateAll { impl Decode<'_> for ServerDeactivateAll { fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - let length_source_descriptor = src.read_u16(); - ensure_size!(in: src, size: length_source_descriptor.into()); - let _ = src.read_slice(length_source_descriptor.into()); + // Some servers (notably XRDP and older Windows versions) send a short + // Deactivate All PDU without the sourceDescriptor field. FreeRDP + // handles this by treating any remaining data as optional. + if src.len() >= Self::FIXED_PART_SIZE { + let length_source_descriptor = src.read_u16(); + ensure_size!(in: src, size: length_source_descriptor.into()); + let _ = src.read_slice(length_source_descriptor.into()); + } Ok(Self) } } diff --git a/crates/ironrdp-pdu/src/rdp/mod.rs b/crates/ironrdp-pdu/src/rdp/mod.rs new file mode 100644 index 0000000000..96ff89aed4 --- /dev/null +++ b/crates/ironrdp-pdu/src/rdp/mod.rs @@ -0,0 +1,69 @@ +use ironrdp_core::{ + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, invalid_field_err, +}; + +use crate::rdp::client_info::ClientInfo; +use crate::rdp::headers::{BasicSecurityHeader, BasicSecurityHeaderFlags}; + +pub mod autodetect; +pub mod capability_sets; +pub mod client_info; +pub mod finalization_messages; +pub mod headers; +pub mod multitransport; +pub mod refresh_rectangle; +pub mod server_error_info; +pub mod server_license; +pub mod session_info; +pub mod suppress_output; +pub mod vc; + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct ClientInfoPdu { + pub security_header: BasicSecurityHeader, + pub client_info: ClientInfo, +} + +impl ClientInfoPdu { + const NAME: &'static str = "ClientInfoPDU"; + + const FIXED_PART_SIZE: usize = BasicSecurityHeader::FIXED_PART_SIZE + ClientInfo::FIXED_PART_SIZE; +} + +impl Encode for ClientInfoPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + self.security_header.encode(dst)?; + self.client_info.encode(dst)?; + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + self.security_header.size() + self.client_info.size() + } +} + +impl<'de> Decode<'de> for ClientInfoPdu { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let security_header = BasicSecurityHeader::decode(src)?; + if !security_header.flags.contains(BasicSecurityHeaderFlags::INFO_PKT) { + return Err(invalid_field_err!("securityHeader", "got invalid security header")); + } + + let client_info = ClientInfo::decode(src)?; + + Ok(Self { + security_header, + client_info, + }) + } +} diff --git a/crates/ironrdp-pdu/src/rdp/multitransport.rs b/crates/ironrdp-pdu/src/rdp/multitransport.rs new file mode 100644 index 0000000000..66fa0df623 --- /dev/null +++ b/crates/ironrdp-pdu/src/rdp/multitransport.rs @@ -0,0 +1,383 @@ +//! Initiate Multitransport Request and Response PDU types. +//! +//! Defined in [\[MS-RDPBCGR\] 2.2.15.1] and [\[MS-RDPBCGR\] 2.2.15.2]. +//! +//! [\[MS-RDPBCGR\] 2.2.15.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/de783158-8b01-4818-8fb0-62523a5b3490 +//! [\[MS-RDPBCGR\] 2.2.15.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/44044233-e498-46f8-8e16-1ffa595a8e8b + +use ironrdp_core::{ + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, invalid_field_err, + read_padding, write_padding, +}; + +use crate::rdp::headers::{BasicSecurityHeader, BasicSecurityHeaderFlags}; + +/// Length of the security cookie used for transport binding validation. +const SECURITY_COOKIE_LEN: usize = 16; + +/// Requested transport protocol for multitransport bootstrapping. +/// +/// Defined in [\[MS-RDPBCGR\] 2.2.15.1], `requestedProtocol` field. +/// +/// [\[MS-RDPBCGR\] 2.2.15.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/de783158-8b01-4818-8fb0-62523a5b3490 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[repr(u16)] +pub enum RequestedProtocol { + /// Reliable UDP transport (RDPEUDP2 + TLS). + /// + /// `INITIATE_REQUEST_PROTOCOL_UDPFECR` + UdpFecR = 0x0001, + /// Lossy UDP transport (RDPEUDP + DTLS, with forward error correction). + /// + /// `INITIATE_REQUEST_PROTOCOL_UDPFECL` + UdpFecL = 0x0002, +} + +impl RequestedProtocol { + fn from_u16(val: u16) -> Option { + match val { + 0x0001 => Some(Self::UdpFecR), + 0x0002 => Some(Self::UdpFecL), + _ => None, + } + } + + #[expect( + clippy::as_conversions, + reason = "repr(u16) guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + +/// Server Initiate Multitransport Request PDU. +/// +/// Sent by the server on the IO channel after licensing to bootstrap a +/// sideband UDP transport. The `request_id` and `security_cookie` are +/// echoed by the client in the tunnel creation request over the new +/// transport, binding the two connections together. +/// +/// A server may send up to two of these — one for reliable and one for +/// lossy UDP. +/// +/// Defined in [\[MS-RDPBCGR\] 2.2.15.1]. +/// +/// [\[MS-RDPBCGR\] 2.2.15.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/de783158-8b01-4818-8fb0-62523a5b3490 +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct MultitransportRequestPdu { + pub security_header: BasicSecurityHeader, + /// Unique ID correlating this request with the tunnel creation request. + pub request_id: u32, + /// Which transport protocol the server is requesting. + pub requested_protocol: RequestedProtocol, + /// 16-byte random cookie for transport binding validation. + pub security_cookie: [u8; SECURITY_COOKIE_LEN], +} + +impl MultitransportRequestPdu { + const NAME: &'static str = "MultitransportRequestPdu"; + + const FIXED_PART_SIZE: usize = BasicSecurityHeader::FIXED_PART_SIZE + + 4 /* requestId */ + + 2 /* requestedProtocol */ + + 2 /* reserved */ + + SECURITY_COOKIE_LEN /* securityCookie */; +} + +impl Encode for MultitransportRequestPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + self.security_header.encode(dst)?; + dst.write_u32(self.request_id); + dst.write_u16(self.requested_protocol.as_u16()); + write_padding!(dst, 2); + dst.write_slice(&self.security_cookie); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'de> Decode<'de> for MultitransportRequestPdu { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let security_header = BasicSecurityHeader::decode(src)?; + + if !security_header.flags.contains(BasicSecurityHeaderFlags::TRANSPORT_REQ) { + return Err(invalid_field_err!("securityHeader", "expected TRANSPORT_REQ flag")); + } + + let request_id = src.read_u32(); + + let protocol_raw = src.read_u16(); + let requested_protocol = RequestedProtocol::from_u16(protocol_raw) + .ok_or_else(|| invalid_field_err!("requestedProtocol", "unknown protocol value"))?; + + read_padding!(src, 2); + + let security_cookie: [u8; SECURITY_COOKIE_LEN] = src.read_array(); + + Ok(Self { + security_header, + request_id, + requested_protocol, + security_cookie, + }) + } +} + +/// Client Initiate Multitransport Response PDU. +/// +/// Sent by the client on the IO channel after the UDP transport is +/// established (or has failed). The `request_id` must match the +/// corresponding server request. +/// +/// Defined in [\[MS-RDPBCGR\] 2.2.15.2]. +/// +/// [\[MS-RDPBCGR\] 2.2.15.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/44044233-e498-46f8-8e16-1ffa595a8e8b +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +pub struct MultitransportResponsePdu { + pub security_header: BasicSecurityHeader, + /// Request ID matching the server's Initiate Multitransport Request. + pub request_id: u32, + /// HRESULT indicating success or failure of the transport setup. + pub hr_response: u32, +} + +impl MultitransportResponsePdu { + const NAME: &'static str = "MultitransportResponsePdu"; + + const FIXED_PART_SIZE: usize = BasicSecurityHeader::FIXED_PART_SIZE + + 4 /* requestId */ + + 4 /* hrResponse */; + + /// `S_OK` — multitransport connection established. + /// + /// Per [\[MS-RDPBCGR\] 2.2.15.2], this MUST only be sent to servers that + /// advertised `SOFTSYNC_TCP_TO_UDP` in the GCC `MultiTransportChannelData`. + /// + /// [\[MS-RDPBCGR\] 2.2.15.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/44044233-e498-46f8-8e16-1ffa595a8e8b + pub const S_OK: u32 = 0x0000_0000; + + /// `E_ABORT` — client was unable to establish the multitransport connection. + pub const E_ABORT: u32 = 0x8000_4004; + + /// Create a success response for the given request ID. + pub fn success(request_id: u32) -> Self { + Self { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::TRANSPORT_RSP, + }, + request_id, + hr_response: Self::S_OK, + } + } + + /// Create a failure response for the given request ID. + pub fn abort(request_id: u32) -> Self { + Self { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::TRANSPORT_RSP, + }, + request_id, + hr_response: Self::E_ABORT, + } + } + + /// Whether this response indicates success. + pub fn is_success(&self) -> bool { + self.hr_response == Self::S_OK + } +} + +impl Encode for MultitransportResponsePdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + self.security_header.encode(dst)?; + dst.write_u32(self.request_id); + dst.write_u32(self.hr_response); + + Ok(()) + } + + fn name(&self) -> &'static str { + Self::NAME + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl<'de> Decode<'de> for MultitransportResponsePdu { + fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let security_header = BasicSecurityHeader::decode(src)?; + + if !security_header.flags.contains(BasicSecurityHeaderFlags::TRANSPORT_RSP) { + return Err(invalid_field_err!("securityHeader", "expected TRANSPORT_RSP flag")); + } + + let request_id = src.read_u32(); + let hr_response = src.read_u32(); + + Ok(Self { + security_header, + request_id, + hr_response, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const REQUEST_WIRE: &[u8] = &[ + // BasicSecurityHeader (4 bytes) + 0x02, 0x00, // flags = TRANSPORT_REQ (0x0002) + 0x00, 0x00, // flagsHi = 0 + // Payload (24 bytes) + 0x2A, 0x00, 0x00, 0x00, // requestId = 42 + 0x01, 0x00, // requestedProtocol = UdpFecR (0x0001) + 0x00, 0x00, // reserved + 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, // securityCookie + 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, 0xAB, + ]; + + const RESPONSE_SUCCESS_WIRE: &[u8] = &[ + // BasicSecurityHeader (4 bytes) + 0x04, 0x00, // flags = TRANSPORT_RSP (0x0004) + 0x00, 0x00, // flagsHi = 0 + // Payload (8 bytes) + 0x2A, 0x00, 0x00, 0x00, // requestId = 42 + 0x00, 0x00, 0x00, 0x00, // hrResponse = S_OK + ]; + + const RESPONSE_ABORT_WIRE: &[u8] = &[ + // BasicSecurityHeader (4 bytes) + 0x04, 0x00, // flags = TRANSPORT_RSP (0x0004) + 0x00, 0x00, // flagsHi = 0 + // Payload (8 bytes) + 0x07, 0x00, 0x00, 0x00, // requestId = 7 + 0x04, 0x40, 0x00, 0x80, // hrResponse = E_ABORT (0x80004004) + ]; + + #[test] + fn decode_request() { + let pdu = ironrdp_core::decode::(REQUEST_WIRE).unwrap(); + assert_eq!(pdu.request_id, 42); + assert_eq!(pdu.requested_protocol, RequestedProtocol::UdpFecR); + assert_eq!(pdu.security_cookie, [0xAB; 16]); + } + + #[test] + fn encode_request() { + let pdu = MultitransportRequestPdu { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::TRANSPORT_REQ, + }, + request_id: 42, + requested_protocol: RequestedProtocol::UdpFecR, + security_cookie: [0xAB; 16], + }; + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + assert_eq!(encoded.as_slice(), REQUEST_WIRE); + } + + #[test] + fn request_round_trip() { + let original = MultitransportRequestPdu { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::TRANSPORT_REQ, + }, + request_id: 0xDEAD_BEEF, + requested_protocol: RequestedProtocol::UdpFecL, + security_cookie: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + }; + let encoded = ironrdp_core::encode_vec(&original).unwrap(); + let decoded = ironrdp_core::decode::(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn request_size() { + assert_eq!(MultitransportRequestPdu::FIXED_PART_SIZE, 28); + } + + #[test] + fn decode_response_success() { + let pdu = ironrdp_core::decode::(RESPONSE_SUCCESS_WIRE).unwrap(); + assert_eq!(pdu.request_id, 42); + assert!(pdu.is_success()); + } + + #[test] + fn decode_response_abort() { + let pdu = ironrdp_core::decode::(RESPONSE_ABORT_WIRE).unwrap(); + assert_eq!(pdu.request_id, 7); + assert_eq!(pdu.hr_response, MultitransportResponsePdu::E_ABORT); + assert!(!pdu.is_success()); + } + + #[test] + fn encode_response_success() { + let pdu = MultitransportResponsePdu::success(42); + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + assert_eq!(encoded.as_slice(), RESPONSE_SUCCESS_WIRE); + } + + #[test] + fn encode_response_abort() { + let pdu = MultitransportResponsePdu::abort(7); + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + assert_eq!(encoded.as_slice(), RESPONSE_ABORT_WIRE); + } + + #[test] + fn response_round_trip() { + let original = MultitransportResponsePdu::success(0xCAFE_BABE); + let encoded = ironrdp_core::encode_vec(&original).unwrap(); + let decoded = ironrdp_core::decode::(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn response_size() { + assert_eq!(MultitransportResponsePdu::FIXED_PART_SIZE, 12); + } + + #[test] + fn decode_request_wrong_flags() { + let bad_wire: &[u8] = &[ + 0x04, 0x00, // flags = TRANSPORT_RSP (wrong for request) + 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]; + assert!(ironrdp_core::decode::(bad_wire).is_err()); + } + + #[test] + fn decode_request_unknown_protocol() { + let bad_wire: &[u8] = &[ + 0x02, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0xFF, 0x00, // unknown protocol + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]; + assert!(ironrdp_core::decode::(bad_wire).is_err()); + } +} diff --git a/crates/ironrdp-pdu/src/rdp/refresh_rectangle.rs b/crates/ironrdp-pdu/src/rdp/refresh_rectangle.rs index 611c3c7886..f01dea8589 100644 --- a/crates/ironrdp-pdu/src/rdp/refresh_rectangle.rs +++ b/crates/ironrdp-pdu/src/rdp/refresh_rectangle.rs @@ -1,6 +1,6 @@ use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, read_padding, write_padding, Decode, DecodeResult, Encode, - EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, read_padding, write_padding, }; use crate::geometry::InclusiveRectangle; @@ -15,6 +15,7 @@ use crate::geometry::InclusiveRectangle; /// /// [2.2.11.2.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/fe04a39d-dc10-489f-bea7-08dad5538547 #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct RefreshRectanglePdu { pub areas_to_refresh: Vec, } @@ -54,10 +55,10 @@ impl<'de> Decode<'de> for RefreshRectanglePdu { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let number_of_areas = src.read_u8(); + let number_of_areas = usize::from(src.read_u8()); read_padding!(src, 3); - let areas_to_refresh = (0..number_of_areas) - .map(|_| InclusiveRectangle::decode(src)) + let areas_to_refresh = core::iter::repeat_with(|| InclusiveRectangle::decode(src)) + .take(number_of_areas) .collect::, _>>()?; Ok(Self { areas_to_refresh }) diff --git a/crates/ironrdp-pdu/src/rdp/server_error_info.rs b/crates/ironrdp-pdu/src/rdp/server_error_info.rs index 23c227e611..5aeb1f597c 100644 --- a/crates/ironrdp-pdu/src/rdp/server_error_info.rs +++ b/crates/ironrdp-pdu/src/rdp/server_error_info.rs @@ -1,10 +1,11 @@ use ironrdp_core::{ - ensure_fixed_part_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, invalid_field_err, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive, ToPrimitive}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ServerSetErrorInfoPdu(pub ErrorInfo); impl ServerSetErrorInfoPdu { @@ -17,7 +18,7 @@ impl Encode for ServerSetErrorInfoPdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u32(self.0.to_u32().unwrap()); + dst.write_u32(self.0.as_u32()); Ok(()) } @@ -44,6 +45,7 @@ impl<'de> Decode<'de> for ServerSetErrorInfoPdu { } #[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ErrorInfo { ProtocolIndependentCode(ProtocolIndependentCode), ProtocolIndependentLicensingCode(ProtocolIndependentLicensingCode), @@ -66,6 +68,15 @@ impl ErrorInfo { Self::RdpSpecificCode(c) => format!("[RDP specific code]: {}", c.description()), } } + + fn as_u32(self) -> u32 { + match self { + Self::ProtocolIndependentCode(c) => c.as_u32(), + Self::ProtocolIndependentLicensingCode(c) => c.as_u32(), + Self::ProtocolIndependentConnectionBrokerCode(c) => c.as_u32(), + Self::RdpSpecificCode(c) => c.as_u32(), + } + } } impl FromPrimitive for ErrorInfo { @@ -94,27 +105,9 @@ impl FromPrimitive for ErrorInfo { } } -impl ToPrimitive for ErrorInfo { - fn to_i64(&self) -> Option { - match self { - Self::ProtocolIndependentCode(c) => c.to_i64(), - Self::ProtocolIndependentLicensingCode(c) => c.to_i64(), - Self::ProtocolIndependentConnectionBrokerCode(c) => c.to_i64(), - Self::RdpSpecificCode(c) => c.to_i64(), - } - } - - fn to_u64(&self) -> Option { - match self { - Self::ProtocolIndependentCode(c) => c.to_u64(), - Self::ProtocolIndependentLicensingCode(c) => c.to_u64(), - Self::ProtocolIndependentConnectionBrokerCode(c) => c.to_u64(), - Self::RdpSpecificCode(c) => c.to_u64(), - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u32)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ProtocolIndependentCode { None = 0x0000_0000, RpcInitiatedDisconnect = 0x0000_0001, @@ -140,28 +133,58 @@ impl ProtocolIndependentCode { pub fn description(&self) -> &str { match self { Self::None => "No error has occurred", - Self::RpcInitiatedDisconnect => "The disconnection was initiated by an administrative tool on the server in another session", - Self::RpcInitiatedLogoff => "The disconnection was due to a forced logoff initiated by an administrative tool on the server in another session", + Self::RpcInitiatedDisconnect => { + "The disconnection was initiated by an administrative tool on the server in another session" + } + Self::RpcInitiatedLogoff => { + "The disconnection was due to a forced logoff initiated by an administrative tool on the server in another session" + } Self::IdleTimeout => "The idle session limit timer on the server has elapsed", Self::LogonTimeout => "The active session limit timer on the server has elapsed", - Self::DisconnectedByOtherconnection => "Another user connected to the server, forcing the disconnection of the current connection", + Self::DisconnectedByOtherconnection => { + "Another user connected to the server, forcing the disconnection of the current connection" + } Self::OutOfMemory => "The server ran out of available memory resources", Self::ServerDeniedConnection => "The server denied the connection", - Self::ServerInsufficientPrivileges => "The user cannot connect to the server due to insufficient access privileges", - Self::ServerFreshCredentialsRequired => "The server does not accept saved user credentials and requires that the user enter their credentials for each connection", - Self::RpcInitiatedDisconnectByuser => "The disconnection was initiated by an administrative tool on the server running in the user's session", - Self::LogoffByUser => "The disconnection was initiated by the user logging off his or her session on the server", - Self::CloseStackOnDriverNotReady => "The display driver in the remote session did not report any status within the time allotted for startup", + Self::ServerInsufficientPrivileges => { + "The user cannot connect to the server due to insufficient access privileges" + } + Self::ServerFreshCredentialsRequired => { + "The server does not accept saved user credentials and requires that the user enter their credentials for each connection" + } + Self::RpcInitiatedDisconnectByuser => { + "The disconnection was initiated by an administrative tool on the server running in the user's session" + } + Self::LogoffByUser => { + "The disconnection was initiated by the user logging off his or her session on the server" + } + Self::CloseStackOnDriverNotReady => { + "The display driver in the remote session did not report any status within the time allotted for startup" + } Self::ServerDwmCrash => "The DWM process running in the remote session terminated unexpectedly", - Self::CloseStackOnDriverFailure => "The display driver in the remote session was unable to complete all the tasks required for startup", - Self::CloseStackOnDriverIfaceFailure => "The display driver in the remote session started up successfully, but due to internal failures was not usable by the remoting stack", + Self::CloseStackOnDriverFailure => { + "The display driver in the remote session was unable to complete all the tasks required for startup" + } + Self::CloseStackOnDriverIfaceFailure => { + "The display driver in the remote session started up successfully, but due to internal failures was not usable by the remoting stack" + } Self::ServerWinlogonCrash => "The Winlogon process running in the remote session terminated unexpectedly", Self::ServerCsrssCrash => "The CSRSS process running in the remote session terminated unexpectedly", } } + + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u32(self) -> u32 { + self as u32 + } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u32)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ProtocolIndependentLicensingCode { Internal = 0x0000_0100, NoLicenseServer = 0x0000_0101, @@ -194,9 +217,19 @@ impl ProtocolIndependentLicensingCode { Self::NoRemoteConnections => "The remote computer is not licensed to accept remote connections", } } + + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u32)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ProtocolIndependentConnectionBrokerCode { DestinationNotFound = 0x0000_0400, LoadingDestination = 0x0000_0402, @@ -215,21 +248,49 @@ impl ProtocolIndependentConnectionBrokerCode { pub fn description(&self) -> &str { match self { Self::DestinationNotFound => "The target endpoint could not be found", - Self::LoadingDestination => "The target endpoint to which the client is being redirected is disconnecting from the Connection Broker", - Self::RedirectingToDestination => "An error occurred while the connection was being redirected to the target endpoint", - Self::SessionOnlineVmWake => "An error occurred while the target endpoint (a virtual machine) was being awakened", - Self::SessionOnlineVmBoot => "An error occurred while the target endpoint (a virtual machine) was being started", - Self::SessionOnlineVmNoDns => "The IP address of the target endpoint (a virtual machine) cannot be determined", - Self::DestinationPoolNotFree => "There are no available endpoints in the pool managed by the Connection Broker", + Self::LoadingDestination => { + "The target endpoint to which the client is being redirected is disconnecting from the Connection Broker" + } + Self::RedirectingToDestination => { + "An error occurred while the connection was being redirected to the target endpoint" + } + Self::SessionOnlineVmWake => { + "An error occurred while the target endpoint (a virtual machine) was being awakened" + } + Self::SessionOnlineVmBoot => { + "An error occurred while the target endpoint (a virtual machine) was being started" + } + Self::SessionOnlineVmNoDns => { + "The IP address of the target endpoint (a virtual machine) cannot be determined" + } + Self::DestinationPoolNotFree => { + "There are no available endpoints in the pool managed by the Connection Broker" + } Self::ConnectionCancelled => "Processing of the connection has been canceled", - Self::ConnectionErrorInvalidSettings => "The settings contained in the routingToken field of the X.224 Connection Request PDU cannot be validated", - Self::SessionOnlineVmBootTimeout => "A time-out occurred while the target endpoint (a virtual machine) was being started", - Self::SessionOnlineVmSessmonFailed => "A session monitoring error occurred while the target endpoint (a virtual machine) was being started", + Self::ConnectionErrorInvalidSettings => { + "The settings contained in the routingToken field of the X.224 Connection Request PDU cannot be validated" + } + Self::SessionOnlineVmBootTimeout => { + "A time-out occurred while the target endpoint (a virtual machine) was being started" + } + Self::SessionOnlineVmSessmonFailed => { + "A session monitoring error occurred while the target endpoint (a virtual machine) was being started" + } } } + + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u32)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum RdpSpecificCode { UnknownPduType2 = 0x0000_10C9, UnknownPduType = 0x0000_10CA, @@ -319,81 +380,211 @@ impl RdpSpecificCode { Self::DataPdusEquence => "An out-of-sequence Slow-Path Data PDU has been received", Self::ControlPduSequence => "An out-of-sequence Slow-Path Non-Data PDU has been received", Self::InvalidControlPduAction => "A Control PDU has been received with an invalid action field", - Self::InvalidInputPduType => "One of two possible errors: A Slow-Path Input Event has been received with an invalid messageType field; or A Fast-Path Input Event has been received with an invalid eventCode field", - Self::InvalidInputPduMouse => "One of two possible errors: A Slow-Path Mouse Event or Extended Mouse Event has been received with an invalid pointerFlags field; or A Fast-Path Mouse Event or Fast-Path Extended Mouse Event has been received with an invalid pointerFlags field", + Self::InvalidInputPduType => { + "One of two possible errors: A Slow-Path Input Event has been received with an invalid messageType field; or A Fast-Path Input Event has been received with an invalid eventCode field" + } + Self::InvalidInputPduMouse => { + "One of two possible errors: A Slow-Path Mouse Event or Extended Mouse Event has been received with an invalid pointerFlags field; or A Fast-Path Mouse Event or Fast-Path Extended Mouse Event has been received with an invalid pointerFlags field" + } Self::InvalidRefreshRectPdu => "An invalid Refresh Rect PDU has been received", Self::CreateUserDataFailed => "The server failed to construct the GCC Conference Create Response user data", - Self::ConnectFailed => "Processing during the Channel Connection phase of the RDP Connection Sequence has failed", - Self::ConfirmActiveWrongShareId => "A Confirm Active PDU was received from the client with an invalid shareID field", - Self::ConfirmActiveWrongOriginator => "A Confirm Active PDU was received from the client with an invalid originatorID field", + Self::ConnectFailed => { + "Processing during the Channel Connection phase of the RDP Connection Sequence has failed" + } + Self::ConfirmActiveWrongShareId => { + "A Confirm Active PDU was received from the client with an invalid shareID field" + } + Self::ConfirmActiveWrongOriginator => { + "A Confirm Active PDU was received from the client with an invalid originatorID field" + } Self::PersistentKeyPduBadLength => "There is not enough data to process a Persistent Key List PDU", - Self::PersistentKeyPduIllegalFirst => "A Persistent Key List PDU marked as PERSIST_PDU_FIRST (0x01) was received after the reception of a prior Persistent Key List PDU also marked as PERSIST_PDU_FIRST", - Self::PersistentKeyPduTooManyTotalKeys => "A Persistent Key List PDU was received which specified a total number of bitmap cache entries larger than 262144", - Self::PersistentKeyPduTooManyCacheKeys => "A Persistent Key List PDU was received which specified an invalid total number of keys for a bitmap cache (the number of entries that can be stored within each bitmap cache is specified in the Revision 1 or 2 Bitmap Cache Capability Set that is sent from client to server)", - Self::InputPduBadLength => "There is not enough data to process Input Event PDU Data or a Fast-Path Input Event PDU", - Self::BitmapCacheErrorPduBadLength => "There is not enough data to process the shareDataHeader, NumInfoBlocks, Pad1, and Pad2 fields of the Bitmap Cache Error PDU Data", - Self::SecurityDataTooShort => "One of two possible errors: The dataSignature field of the Fast-Path Input Event PDU does not contain enough data; or The fipsInformation and dataSignature fields of the Fast-Path Input Event PDU do not contain enough data", - Self::VcHannelDataTooShort => "One of two possible errors: There is not enough data in the Client Network Data to read the virtual channel configuration data; or There is not enough data to read a complete Channel PDU Header", - Self::ShareDataTooShort => "One of four possible errors: There is not enough data to process Control PDU Data; or There is not enough data to read a complete Share Control Header; or There is not enough data to read a complete Share Data Header of a Slow-Path Data PDU; or There is not enough data to process Font List PDU Data", - Self::BadSuppressOutputPdu => "One of two possible errors: There is not enough data to process Suppress Output PDU Data; or The allowDisplayUpdates field of the Suppress Output PDU Data is invalid", - Self::ConfirmActivePduTooShort => "One of two possible errors: There is not enough data to read the shareControlHeader, shareID, originatorID, lengthSourceDescriptor, and lengthCombinedCapabilities fields of the Confirm Active PDU Data; or There is not enough data to read the sourceDescriptor, numberCapabilities, pad2Octets, and capabilitySets fields of the Confirm Active PDU Data", - Self::CapabilitySetTooSmall => "There is not enough data to read the capabilitySetType and the lengthCapability fields in a received Capability Set", - Self::CapabilitySetTooLarge => "A Capability Set has been received with a lengthCapability field that contains a value greater than the total length of the data received", - Self::NoCursorCache => "One of two possible errors: Both the colorPointerCacheSize and pointerCacheSize fields in the Pointer Capability Set are set to zero; or The pointerCacheSize field in the Pointer Capability Set is not present, and the colorPointerCacheSize field is set to zero", - Self::BadCapabilities => "The capabilities received from the client in the Confirm Active PDU were not accepted by the server", - Self::VirtualChannelDecompressionError => "An error occurred while using the bulk compressor to decompress a Virtual Channel PDU", - Self::InvalidVcCompressionType => "An invalid bulk compression package was specified in the flags field of the Channel PDU Header", - Self::InvalidChannelId => "An invalid MCS channel ID was specified in the mcsPdu field of the Virtual Channel PDU)", - Self::VirtualChannelsTooMany => "The client requested more than the maximum allowed 31 static virtual channels in the Client Network Data", - Self::RemoteAppsNotEnabled => "The INFO_RAIL flag (0x0000_8000) MUST be set in the flags field of the Info Packet as the session on the remote server can only host remote applications", - Self::CacheCapabilityNotSet => "The client sent a Persistent Key List PDU without including the prerequisite Revision 2 Bitmap Cache Capability Set in the Confirm Active PDU", - Self::BitmapCacheErrorPduBadLength2 => "The NumInfoBlocks field in the Bitmap Cache Error PDU Data is inconsistent with the amount of data in the Info field", - Self::OffscrCacheErrorPduBadLength => "There is not enough data to process an Offscreen Bitmap Cache Error PDU", + Self::PersistentKeyPduIllegalFirst => { + "A Persistent Key List PDU marked as PERSIST_PDU_FIRST (0x01) was received after the reception of a prior Persistent Key List PDU also marked as PERSIST_PDU_FIRST" + } + Self::PersistentKeyPduTooManyTotalKeys => { + "A Persistent Key List PDU was received which specified a total number of bitmap cache entries larger than 262144" + } + Self::PersistentKeyPduTooManyCacheKeys => { + "A Persistent Key List PDU was received which specified an invalid total number of keys for a bitmap cache (the number of entries that can be stored within each bitmap cache is specified in the Revision 1 or 2 Bitmap Cache Capability Set that is sent from client to server)" + } + Self::InputPduBadLength => { + "There is not enough data to process Input Event PDU Data or a Fast-Path Input Event PDU" + } + Self::BitmapCacheErrorPduBadLength => { + "There is not enough data to process the shareDataHeader, NumInfoBlocks, Pad1, and Pad2 fields of the Bitmap Cache Error PDU Data" + } + Self::SecurityDataTooShort => { + "One of two possible errors: The dataSignature field of the Fast-Path Input Event PDU does not contain enough data; or The fipsInformation and dataSignature fields of the Fast-Path Input Event PDU do not contain enough data" + } + Self::VcHannelDataTooShort => { + "One of two possible errors: There is not enough data in the Client Network Data to read the virtual channel configuration data; or There is not enough data to read a complete Channel PDU Header" + } + Self::ShareDataTooShort => { + "One of four possible errors: There is not enough data to process Control PDU Data; or There is not enough data to read a complete Share Control Header; or There is not enough data to read a complete Share Data Header of a Slow-Path Data PDU; or There is not enough data to process Font List PDU Data" + } + Self::BadSuppressOutputPdu => { + "One of two possible errors: There is not enough data to process Suppress Output PDU Data; or The allowDisplayUpdates field of the Suppress Output PDU Data is invalid" + } + Self::ConfirmActivePduTooShort => { + "One of two possible errors: There is not enough data to read the shareControlHeader, shareID, originatorID, lengthSourceDescriptor, and lengthCombinedCapabilities fields of the Confirm Active PDU Data; or There is not enough data to read the sourceDescriptor, numberCapabilities, pad2Octets, and capabilitySets fields of the Confirm Active PDU Data" + } + Self::CapabilitySetTooSmall => { + "There is not enough data to read the capabilitySetType and the lengthCapability fields in a received Capability Set" + } + Self::CapabilitySetTooLarge => { + "A Capability Set has been received with a lengthCapability field that contains a value greater than the total length of the data received" + } + Self::NoCursorCache => { + "One of two possible errors: Both the colorPointerCacheSize and pointerCacheSize fields in the Pointer Capability Set are set to zero; or The pointerCacheSize field in the Pointer Capability Set is not present, and the colorPointerCacheSize field is set to zero" + } + Self::BadCapabilities => { + "The capabilities received from the client in the Confirm Active PDU were not accepted by the server" + } + Self::VirtualChannelDecompressionError => { + "An error occurred while using the bulk compressor to decompress a Virtual Channel PDU" + } + Self::InvalidVcCompressionType => { + "An invalid bulk compression package was specified in the flags field of the Channel PDU Header" + } + Self::InvalidChannelId => { + "An invalid MCS channel ID was specified in the mcsPdu field of the Virtual Channel PDU)" + } + Self::VirtualChannelsTooMany => { + "The client requested more than the maximum allowed 31 static virtual channels in the Client Network Data" + } + Self::RemoteAppsNotEnabled => { + "The INFO_RAIL flag (0x0000_8000) MUST be set in the flags field of the Info Packet as the session on the remote server can only host remote applications" + } + Self::CacheCapabilityNotSet => { + "The client sent a Persistent Key List PDU without including the prerequisite Revision 2 Bitmap Cache Capability Set in the Confirm Active PDU" + } + Self::BitmapCacheErrorPduBadLength2 => { + "The NumInfoBlocks field in the Bitmap Cache Error PDU Data is inconsistent with the amount of data in the Info field" + } + Self::OffscrCacheErrorPduBadLength => { + "There is not enough data to process an Offscreen Bitmap Cache Error PDU" + } Self::DngCacheErrorPduBadLength => "There is not enough data to process a DrawNineGrid Cache Error PDU", Self::GdiPlusPduBadLength => "There is not enough data to process a GDI+ Error PDU", Self::SecurityDataTooShort2 => "There is not enough data to read a Basic Security Header", - Self::SecurityDataTooShort3 => "There is not enough data to read a Non-FIPS Security Header or FIPS Security Header", - Self::SecurityDataTooShort4 => "There is not enough data to read the basicSecurityHeader and length fields of the Security Exchange PDU Data", - Self::SecurityDataTooShort5 => "There is not enough data to read the CodePage, flags, cbDomain, cbUserName, cbPassword, cbAlternateShell, cbWorkingDir, Domain, UserName, Password, AlternateShell, and WorkingDir fields in the Info Packet", - Self::SecurityDataTooShort6 => "There is not enough data to read the CodePage, flags, cbDomain, cbUserName, cbPassword, cbAlternateShell, and cbWorkingDir fields in the Info Packet", - Self::SecurityDataTooShort7 => "There is not enough data to read the clientAddressFamily and cbClientAddress fields in the Extended Info Packet", - Self::SecurityDataTooShort8 => "There is not enough data to read the clientAddress field in the Extended Info Packet", - Self::SecurityDataTooShort9 => "There is not enough data to read the cbClientDir field in the Extended Info Packet", - Self::SecurityDataTooShort10 => "There is not enough data to read the clientDir field in the Extended Info Packet", - Self::SecurityDataTooShort11 => "There is not enough data to read the clientTimeZone field in the Extended Info Packet", - Self::SecurityDataTooShort12 => "There is not enough data to read the clientSessionId field in the Extended Info Packet", - Self::SecurityDataTooShort13 => "There is not enough data to read the performanceFlags field in the Extended Info Packet", - Self::SecurityDataTooShort14 => "There is not enough data to read the cbAutoReconnectCookie field in the Extended Info Packet", - Self::SecurityDataTooShort15 => "There is not enough data to read the autoReconnectCookie field in the Extended Info Packet", - Self::SecurityDataTooShort16 => "The cbAutoReconnectCookie field in the Extended Info Packet contains a value which is larger than the maximum allowed length of 128 bytes", - Self::SecurityDataTooShort17 => "There is not enough data to read the clientAddressFamily and cbClientAddress fields in the Extended Info Packet", - Self::SecurityDataTooShort18 => "There is not enough data to read the clientAddress field in the Extended Info Packet", - Self::SecurityDataTooShort19 => "There is not enough data to read the cbClientDir field in the Extended Info Packet", - Self::SecurityDataTooShort20 => "There is not enough data to read the clientDir field in the Extended Info Packet", - Self::SecurityDataTooShort21 => "There is not enough data to read the clientTimeZone field in the Extended Info Packet", - Self::SecurityDataTooShort22 => "There is not enough data to read the clientSessionId field in the Extended Info Packet", + Self::SecurityDataTooShort3 => { + "There is not enough data to read a Non-FIPS Security Header or FIPS Security Header" + } + Self::SecurityDataTooShort4 => { + "There is not enough data to read the basicSecurityHeader and length fields of the Security Exchange PDU Data" + } + Self::SecurityDataTooShort5 => { + "There is not enough data to read the CodePage, flags, cbDomain, cbUserName, cbPassword, cbAlternateShell, cbWorkingDir, Domain, UserName, Password, AlternateShell, and WorkingDir fields in the Info Packet" + } + Self::SecurityDataTooShort6 => { + "There is not enough data to read the CodePage, flags, cbDomain, cbUserName, cbPassword, cbAlternateShell, and cbWorkingDir fields in the Info Packet" + } + Self::SecurityDataTooShort7 => { + "There is not enough data to read the clientAddressFamily and cbClientAddress fields in the Extended Info Packet" + } + Self::SecurityDataTooShort8 => { + "There is not enough data to read the clientAddress field in the Extended Info Packet" + } + Self::SecurityDataTooShort9 => { + "There is not enough data to read the cbClientDir field in the Extended Info Packet" + } + Self::SecurityDataTooShort10 => { + "There is not enough data to read the clientDir field in the Extended Info Packet" + } + Self::SecurityDataTooShort11 => { + "There is not enough data to read the clientTimeZone field in the Extended Info Packet" + } + Self::SecurityDataTooShort12 => { + "There is not enough data to read the clientSessionId field in the Extended Info Packet" + } + Self::SecurityDataTooShort13 => { + "There is not enough data to read the performanceFlags field in the Extended Info Packet" + } + Self::SecurityDataTooShort14 => { + "There is not enough data to read the cbAutoReconnectCookie field in the Extended Info Packet" + } + Self::SecurityDataTooShort15 => { + "There is not enough data to read the autoReconnectCookie field in the Extended Info Packet" + } + Self::SecurityDataTooShort16 => { + "The cbAutoReconnectCookie field in the Extended Info Packet contains a value which is larger than the maximum allowed length of 128 bytes" + } + Self::SecurityDataTooShort17 => { + "There is not enough data to read the clientAddressFamily and cbClientAddress fields in the Extended Info Packet" + } + Self::SecurityDataTooShort18 => { + "There is not enough data to read the clientAddress field in the Extended Info Packet" + } + Self::SecurityDataTooShort19 => { + "There is not enough data to read the cbClientDir field in the Extended Info Packet" + } + Self::SecurityDataTooShort20 => { + "There is not enough data to read the clientDir field in the Extended Info Packet" + } + Self::SecurityDataTooShort21 => { + "There is not enough data to read the clientTimeZone field in the Extended Info Packet" + } + Self::SecurityDataTooShort22 => { + "There is not enough data to read the clientSessionId field in the Extended Info Packet" + } Self::SecurityDataTooShort23 => "There is not enough data to read the Client Info PDU Data", - Self::BadMonitorData => "The number of TS_MONITOR_DEF structures present in the monitorDefArray field of the Client Monitor Data is less than the value specified in monitorCount field", - Self::VcDecompressedReassembleFailed => "The server-side decompression buffer is invalid, or the size of the decompressed VC data exceeds the chunking size specified in the Virtual Channel Capability Set", - Self::VcDataTooLong => "The size of a received Virtual Channel PDU exceeds the chunking size specified in the Virtual Channel Capability Set", + Self::BadMonitorData => { + "The number of TS_MONITOR_DEF structures present in the monitorDefArray field of the Client Monitor Data is less than the value specified in monitorCount field" + } + Self::VcDecompressedReassembleFailed => { + "The server-side decompression buffer is invalid, or the size of the decompressed VC data exceeds the chunking size specified in the Virtual Channel Capability Set" + } + Self::VcDataTooLong => { + "The size of a received Virtual Channel PDU exceeds the chunking size specified in the Virtual Channel Capability Set" + } Self::BadFrameAckData => "There is not enough data to read a TS_FRAME_ACKNOWLEDGE_PDU", - Self::GraphicsModeNotSupported => "The graphics mode requested by the client is not supported by the server", + Self::GraphicsModeNotSupported => { + "The graphics mode requested by the client is not supported by the server" + } Self::GraphicsSubsystemResetFailed => "The server-side graphics subsystem failed to reset", - Self::GraphicsSubsystemFailed => "The server-side graphics subsystem is in an error state and unable to continue graphics encoding", - Self::TimezoneKeyNameLengthTooShort => "There is not enough data to read the cbDynamicDSTTimeZoneKeyName field in the Extended Info Packet", - Self::TimezoneKeyNameLengthTooLong => "The length reported in the cbDynamicDSTTimeZoneKeyName field of the Extended Info Packet is too long", - Self::DynamicDstDisabledFieldMissing => "The dynamicDaylightTimeDisabled field is not present in the Extended Info Packet", + Self::GraphicsSubsystemFailed => { + "The server-side graphics subsystem is in an error state and unable to continue graphics encoding" + } + Self::TimezoneKeyNameLengthTooShort => { + "There is not enough data to read the cbDynamicDSTTimeZoneKeyName field in the Extended Info Packet" + } + Self::TimezoneKeyNameLengthTooLong => { + "The length reported in the cbDynamicDSTTimeZoneKeyName field of the Extended Info Packet is too long" + } + Self::DynamicDstDisabledFieldMissing => { + "The dynamicDaylightTimeDisabled field is not present in the Extended Info Packet" + } Self::VcDecodingError => "An error occurred when processing dynamic virtual channel data", - Self::VirtualDesktopTooLarge => "The width or height of the virtual desktop defined by the monitor layout in the Client Monitor Data is larger than the maximum allowed value of 32,766", - Self::MonitorGeometryValidationFailed => "The monitor geometry defined by the Client Monitor Data is invalid", + Self::VirtualDesktopTooLarge => { + "The width or height of the virtual desktop defined by the monitor layout in the Client Monitor Data is larger than the maximum allowed value of 32,766" + } + Self::MonitorGeometryValidationFailed => { + "The monitor geometry defined by the Client Monitor Data is invalid" + } Self::InvalidMonitorCount => "The monitorCount field in the Client Monitor Data is too large", - Self::UpdateSessionKeyFailed => "An attempt to update the session keys while using Standard RDP Security mechanisms failed", - Self::DecryptFailed => "One of two possible error conditions: Decryption using Standard RDP Security mechanisms failed; or Session key creation using Standard RDP Security mechanisms failed", + Self::UpdateSessionKeyFailed => { + "An attempt to update the session keys while using Standard RDP Security mechanisms failed" + } + Self::DecryptFailed => { + "One of two possible error conditions: Decryption using Standard RDP Security mechanisms failed; or Session key creation using Standard RDP Security mechanisms failed" + } Self::EncryptFailed => "Encryption using Standard RDP Security mechanisms failed", - Self::EncPkgMismatch => "Failed to find a usable Encryption Method in the encryptionMethods field of the Client Security Data", - Self::DecryptFailed2 => "Unencrypted data was encountered in a protocol stream which is meant to be encrypted with Standard RDP Security mechanisms", + Self::EncPkgMismatch => { + "Failed to find a usable Encryption Method in the encryptionMethods field of the Client Security Data" + } + Self::DecryptFailed2 => { + "Unencrypted data was encountered in a protocol stream which is meant to be encrypted with Standard RDP Security mechanisms" + } } } + + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } } #[cfg(test)] diff --git a/crates/ironrdp-pdu/src/rdp/server_license/client_license_info.rs b/crates/ironrdp-pdu/src/rdp/server_license/client_license_info.rs index 19bb963364..6434a0f3fb 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/client_license_info.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/client_license_info.rs @@ -2,7 +2,7 @@ use std::io; use byteorder::{LittleEndian, WriteBytesExt as _}; use ironrdp_core::{ - ensure_size, invalid_field_err, Decode as _, DecodeResult, Encode as _, EncodeResult, ReadCursor, WriteCursor, + Decode as _, DecodeResult, Encode as _, EncodeResult, ReadCursor, WriteCursor, ensure_size, invalid_field_err, }; use md5::Digest as _; @@ -12,9 +12,9 @@ use crate::rdp::headers::{BasicSecurityHeader, BasicSecurityHeaderFlags}; use crate::rdp::server_license::client_new_license_request::{compute_master_secret, compute_session_key_blob}; use crate::rdp::server_license::client_platform_challenge_response::CLIENT_HARDWARE_IDENTIFICATION_SIZE; use crate::rdp::server_license::{ - compute_mac_data, BlobHeader, BlobType, LicenseEncryptionData, LicenseHeader, PreambleFlags, PreambleType, - PreambleVersion, ServerLicenseError, ServerLicenseRequest, KEY_EXCHANGE_ALGORITHM_RSA, MAC_SIZE, PLATFORM_ID, - PREAMBLE_SIZE, RANDOM_NUMBER_SIZE, + BlobHeader, BlobType, KEY_EXCHANGE_ALGORITHM_RSA, LicenseEncryptionData, LicenseHeader, MAC_SIZE, PLATFORM_ID, + PREAMBLE_SIZE, PreambleFlags, PreambleType, PreambleVersion, RANDOM_NUMBER_SIZE, ServerLicenseError, + ServerLicenseRequest, compute_mac_data, }; const LICENSE_INFO_STATIC_FIELDS_SIZE: usize = 20; @@ -23,6 +23,7 @@ const LICENSE_INFO_STATIC_FIELDS_SIZE: usize = 20; /// /// [2.2.2.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpele/9407b2eb-f180-4827-9488-cdbff4a5d4ea #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientLicenseInfo { pub license_header: LicenseHeader, pub client_random: Vec, @@ -80,7 +81,7 @@ impl ClientLicenseInfo { let mut rc4 = Rc4::new(&license_key); let encrypted_hwid = rc4.process(&hardware_id); - let mac_data = compute_mac_data(mac_salt_key, &hardware_id); + let mac_data = compute_mac_data(mac_salt_key, &hardware_id)?; let size = RANDOM_NUMBER_SIZE + PREAMBLE_SIZE @@ -97,7 +98,8 @@ impl ClientLicenseInfo { preamble_message_type: PreambleType::LicenseInfo, preamble_flags: PreambleFlags::empty(), preamble_version: PreambleVersion::V3, - preamble_message_size: (size) as u16, + preamble_message_size: u16::try_from(size) + .map_err(|_| ServerLicenseError::InvalidField("preamble message size"))?, }; Ok(( @@ -116,9 +118,7 @@ impl ClientLicenseInfo { }, )) } -} -impl ClientLicenseInfo { pub fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); @@ -142,22 +142,6 @@ impl ClientLicenseInfo { Ok(()) } - pub fn name(&self) -> &'static str { - Self::NAME - } - - pub fn size(&self) -> usize { - self.license_header.size() - + LICENSE_INFO_STATIC_FIELDS_SIZE - + RANDOM_NUMBER_SIZE - + self.encrypted_premaster_secret.len() - + self.license_info.len() - + self.encrypted_hwid.len() - + MAC_SIZE - } -} - -impl ClientLicenseInfo { pub fn decode(license_header: LicenseHeader, src: &mut ReadCursor<'_>) -> DecodeResult { if license_header.preamble_message_type != PreambleType::LicenseInfo { return Err(invalid_field_err!("preambleMessageType", "unexpected preamble type")); @@ -207,4 +191,18 @@ impl ClientLicenseInfo { mac_data, }) } + + pub fn name(&self) -> &'static str { + Self::NAME + } + + pub fn size(&self) -> usize { + self.license_header.size() + + LICENSE_INFO_STATIC_FIELDS_SIZE + + RANDOM_NUMBER_SIZE + + self.encrypted_premaster_secret.len() + + self.license_info.len() + + self.encrypted_hwid.len() + + MAC_SIZE + } } diff --git a/crates/ironrdp-pdu/src/rdp/server_license/client_new_license_request.rs b/crates/ironrdp-pdu/src/rdp/server_license/client_new_license_request/mod.rs similarity index 87% rename from crates/ironrdp-pdu/src/rdp/server_license/client_new_license_request.rs rename to crates/ironrdp-pdu/src/rdp/server_license/client_new_license_request/mod.rs index 883353c0b8..f1fbaf17d8 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/client_new_license_request.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/client_new_license_request/mod.rs @@ -5,14 +5,14 @@ use std::io; use bitflags::bitflags; use ironrdp_core::{ - ensure_size, invalid_field_err, Decode as _, DecodeResult, Encode as _, EncodeResult, ReadCursor, WriteCursor, + Decode as _, DecodeResult, Encode as _, EncodeResult, ReadCursor, WriteCursor, ensure_size, invalid_field_err, }; use md5::Digest as _; use super::{ - BasicSecurityHeader, BasicSecurityHeaderFlags, BlobHeader, BlobType, LicenseEncryptionData, LicenseHeader, - PreambleFlags, PreambleType, PreambleVersion, ServerLicenseError, ServerLicenseRequest, KEY_EXCHANGE_ALGORITHM_RSA, - PREAMBLE_SIZE, RANDOM_NUMBER_SIZE, UTF8_NULL_TERMINATOR_SIZE, + BasicSecurityHeader, BasicSecurityHeaderFlags, BlobHeader, BlobType, KEY_EXCHANGE_ALGORITHM_RSA, + LicenseEncryptionData, LicenseHeader, PREAMBLE_SIZE, PreambleFlags, PreambleType, PreambleVersion, + RANDOM_NUMBER_SIZE, ServerLicenseError, ServerLicenseRequest, UTF8_NULL_TERMINATOR_SIZE, }; use crate::crypto::rsa::encrypt_with_public_key; use crate::utils::{self, CharacterSet}; @@ -23,6 +23,7 @@ pub const PLATFORM_ID: u32 = ClientOsType::NT_POST_52.bits() | Isv::MICROSOFT.bi bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientOsType: u32 { const NT_351 = 0x100_0000; const NT_40 = 0x200_0000; @@ -33,6 +34,7 @@ bitflags! { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Isv: u32 { const MICROSOFT = 0x10000; const CITRIX = 0x20000; @@ -43,6 +45,7 @@ bitflags! { /// /// [2.2.2.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpele/c57e4890-9049-421e-9fe8-9a6f9519675a #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientNewLicenseRequest { pub license_header: LicenseHeader, pub client_random: Vec, @@ -63,7 +66,7 @@ impl ClientNewLicenseRequest { ) -> Result<(Self, LicenseEncryptionData), ServerLicenseError> { let public_key = license_request.get_public_key()? .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, - "attempted to retrieve the server public key from a server license request message that does not have a certificate"))?; + "attempted to retrieve the server public key from a server license request message that does not have a certificate"))?; let encrypted_premaster_secret = encrypt_with_public_key(premaster_secret, &public_key)?; @@ -98,14 +101,17 @@ impl ClientNewLicenseRequest { preamble_message_type: PreambleType::NewLicenseRequest, preamble_flags: PreambleFlags::empty(), preamble_version: PreambleVersion::V3, - preamble_message_size: (RANDOM_NUMBER_SIZE - + PREAMBLE_SIZE - + LICENSE_REQUEST_STATIC_FIELDS_SIZE - + encrypted_premaster_secret.len() - + client_machine_name.len() - + UTF8_NULL_TERMINATOR_SIZE - + client_username.len() - + UTF8_NULL_TERMINATOR_SIZE) as u16, + preamble_message_size: u16::try_from( + RANDOM_NUMBER_SIZE + + PREAMBLE_SIZE + + LICENSE_REQUEST_STATIC_FIELDS_SIZE + + encrypted_premaster_secret.len() + + client_machine_name.len() + + UTF8_NULL_TERMINATOR_SIZE + + client_username.len() + + UTF8_NULL_TERMINATOR_SIZE, + ) + .map_err(|_| ServerLicenseError::InvalidField("preamble message size"))?, }; Ok(( @@ -123,9 +129,7 @@ impl ClientNewLicenseRequest { }, )) } -} -impl ClientNewLicenseRequest { pub fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); @@ -155,23 +159,6 @@ impl ClientNewLicenseRequest { Ok(()) } - pub fn name(&self) -> &'static str { - Self::NAME - } - - pub fn size(&self) -> usize { - self.license_header.size() - + LICENSE_REQUEST_STATIC_FIELDS_SIZE - + RANDOM_NUMBER_SIZE - + self.encrypted_premaster_secret.len() - + self.client_machine_name.len() - + UTF8_NULL_TERMINATOR_SIZE - + self.client_username.len() - + UTF8_NULL_TERMINATOR_SIZE - } -} - -impl ClientNewLicenseRequest { pub fn decode(license_header: LicenseHeader, src: &mut ReadCursor<'_>) -> DecodeResult { if license_header.preamble_message_type != PreambleType::NewLicenseRequest { return Err(invalid_field_err!("preambleMessageType", "unexpected preamble type")); @@ -217,6 +204,21 @@ impl ClientNewLicenseRequest { client_machine_name, }) } + + pub fn name(&self) -> &'static str { + Self::NAME + } + + pub fn size(&self) -> usize { + self.license_header.size() + + LICENSE_REQUEST_STATIC_FIELDS_SIZE + + RANDOM_NUMBER_SIZE + + self.encrypted_premaster_secret.len() + + self.client_machine_name.len() + + UTF8_NULL_TERMINATOR_SIZE + + self.client_username.len() + + UTF8_NULL_TERMINATOR_SIZE + } } fn salted_hash(salt: &[u8], salt_first: &[u8], salt_second: &[u8], input: &[u8]) -> Vec { diff --git a/crates/ironrdp-pdu/src/rdp/server_license/client_new_license_request/tests.rs b/crates/ironrdp-pdu/src/rdp/server_license/client_new_license_request/tests.rs index 302285e546..ac8eb9cd65 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/client_new_license_request/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/client_new_license_request/tests.rs @@ -1,11 +1,12 @@ +use std::sync::LazyLock; + use byteorder::{LittleEndian, WriteBytesExt as _}; use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; +use crate::rdp::server_license::LicensePdu; use crate::rdp::server_license::server_license_request::cert::{CertificateType, X509CertificateChain}; use crate::rdp::server_license::server_license_request::{ProductInfo, Scope, ServerCertificate}; -use crate::rdp::server_license::LicensePdu; const LICENSE_HEADER_BUFFER_NO_SIZE: [u8; 6] = [ 0x80, 0x00, // flags @@ -59,8 +60,8 @@ const LICENSE_KEY_BUFFER: [u8; 16] = [ const CLIENT_USERNAME: &str = "sample-user"; const CLIENT_MACHINE_NAME: &str = "sample-machine-name"; -lazy_static! { - pub static ref CLIENT_NEW_LICENSE_REQUEST: LicensePdu = ClientNewLicenseRequest { +static CLIENT_NEW_LICENSE_REQUEST: LazyLock = LazyLock::new(|| { + ClientNewLicenseRequest { license_header: LicenseHeader { security_header: BasicSecurityHeader { flags: BasicSecurityHeaderFlags::LICENSE_PKT, @@ -68,212 +69,238 @@ lazy_static! { preamble_message_type: PreambleType::NewLicenseRequest, preamble_flags: PreambleFlags::empty(), preamble_version: PreambleVersion::V3, - preamble_message_size: (PREAMBLE_SIZE - + RANDOM_NUMBER_SIZE - + LICENSE_REQUEST_STATIC_FIELDS_SIZE - + ENCRYPTED_PREMASTER_SECRET.len() - + CLIENT_MACHINE_NAME.len() - + UTF8_NULL_TERMINATOR_SIZE - + CLIENT_USERNAME.len() - + UTF8_NULL_TERMINATOR_SIZE) as u16, + preamble_message_size: u16::try_from( + PREAMBLE_SIZE + + RANDOM_NUMBER_SIZE + + LICENSE_REQUEST_STATIC_FIELDS_SIZE + + ENCRYPTED_PREMASTER_SECRET.len() + + CLIENT_MACHINE_NAME.len() + + UTF8_NULL_TERMINATOR_SIZE + + CLIENT_USERNAME.len() + + UTF8_NULL_TERMINATOR_SIZE, + ) + .expect("can't panic"), }, client_random: Vec::from(CLIENT_RANDOM_BUFFER.as_ref()), encrypted_premaster_secret: Vec::from(ENCRYPTED_PREMASTER_SECRET.as_ref()), client_username: CLIENT_USERNAME.to_owned(), client_machine_name: CLIENT_MACHINE_NAME.to_owned(), - }.into(); + } + .into() +}); - pub static ref REQUEST_BUFFER: Vec = { - let username_len = CLIENT_USERNAME.len() + UTF8_NULL_TERMINATOR_SIZE; - let mut username_len_buf = Vec::new(); - username_len_buf.write_u16::(username_len as u16).unwrap(); +static REQUEST_BUFFER: LazyLock> = LazyLock::new(|| { + let username_len = CLIENT_USERNAME.len() + UTF8_NULL_TERMINATOR_SIZE; + let mut username_len_buf = Vec::new(); + username_len_buf + .write_u16::(u16::try_from(username_len).expect("can't panic")) + .unwrap(); - let machine_name_len = CLIENT_MACHINE_NAME.len() + UTF8_NULL_TERMINATOR_SIZE; - let mut machine_name_len_buf = Vec::new(); - machine_name_len_buf.write_u16::(machine_name_len as u16).unwrap(); + let machine_name_len = CLIENT_MACHINE_NAME.len() + UTF8_NULL_TERMINATOR_SIZE; + let mut machine_name_len_buf = Vec::new(); + machine_name_len_buf + .write_u16::(u16::try_from(machine_name_len).unwrap()) + .unwrap(); - let buf = [ - &[0x01u8, 0x00, 0x00, 0x00, // preferred_key_exchange_algorithm - 0x00, 0x00, 0x01, 0x04], // platform_id - CLIENT_RANDOM_BUFFER.as_ref(), - &[0x02, 0x00, // blob type - 0x48, 0x00], // blob len - ENCRYPTED_PREMASTER_SECRET.as_ref(), - &[0x0f, 0x00], // blob type - username_len_buf.as_slice(), - CLIENT_USERNAME.as_bytes(), - &[0x00, // null - 0x10, 0x00], // blob type - machine_name_len_buf.as_slice(), // blob len - CLIENT_MACHINE_NAME.as_bytes(), - &[0x00]] // null - .concat(); + let buf = [ + &[ + 0x01u8, 0x00, 0x00, 0x00, // preferred_key_exchange_algorithm + 0x00, 0x00, 0x01, 0x04, + ], // platform_id + CLIENT_RANDOM_BUFFER.as_ref(), + &[ + 0x02, 0x00, // blob type + 0x48, 0x00, + ], // blob len + ENCRYPTED_PREMASTER_SECRET.as_ref(), + &[0x0f, 0x00], // blob type + username_len_buf.as_slice(), + CLIENT_USERNAME.as_bytes(), + &[ + 0x00, // null + 0x10, 0x00, + ], // blob type + machine_name_len_buf.as_slice(), // blob len + CLIENT_MACHINE_NAME.as_bytes(), + &[0x00], + ] // null + .concat(); - let preamble_size_field = (buf.len() + PREAMBLE_SIZE) as u16; + let preamble_size_field = u16::try_from(buf.len() + PREAMBLE_SIZE).expect("can't panic"); - [ - LICENSE_HEADER_BUFFER_NO_SIZE.as_ref(), - &preamble_size_field.to_le_bytes(), - buf.as_slice() - ] - .concat() - }; + [ + LICENSE_HEADER_BUFFER_NO_SIZE.as_ref(), + &preamble_size_field.to_le_bytes(), + buf.as_slice(), + ] + .concat() +}); - pub(crate) static ref SERVER_LICENSE_REQUEST: LicensePdu = { - let mut req = ServerLicenseRequest { - license_header: LicenseHeader { - security_header: BasicSecurityHeader { - flags: BasicSecurityHeaderFlags::LICENSE_PKT, - }, - preamble_message_type: PreambleType::LicenseRequest, - preamble_flags: PreambleFlags::empty(), - preamble_version: PreambleVersion::V3, - preamble_message_size: 0, - }, - server_random: Vec::from(SERVER_RANDOM_BUFFER.as_ref()), - product_info: ProductInfo { - version: 0x60000, - company_name: "Microsoft Corporation".to_owned(), - product_id: "A02".to_owned(), +pub(crate) static SERVER_LICENSE_REQUEST: LazyLock = LazyLock::new(|| { + let mut req = ServerLicenseRequest { + license_header: LicenseHeader { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::LICENSE_PKT, }, - server_certificate: Some(ServerCertificate { - issued_permanently: true, - certificate: CertificateType::X509(X509CertificateChain { - certificate_array: vec![ - vec![0x30, 0x82, 0x03, 0xda, 0x30, 0x82, 0x02, 0xc2, 0xa0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x13, 0x7f, - 0x00, 0x00, 0x01, 0x76, 0x00, 0x8f, 0x08, 0x64, 0x08, 0x68, 0xa7, 0x63, 0x00, 0x00, 0x00, 0x00, 0x01, - 0x76, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b, 0x05, 0x00, 0x30, - 0x1d, 0x31, 0x1b, 0x30, 0x19, 0x06, 0x03, 0x55, 0x04, 0x03, 0x13, 0x12, 0x50, 0x72, 0x6f, 0x64, 0x32, - 0x4c, 0x53, 0x52, 0x41, 0x73, 0x68, 0x61, 0x32, 0x52, 0x44, 0x53, 0x4c, 0x4d, 0x30, 0x1e, 0x17, 0x0d, - 0x31, 0x39, 0x31, 0x30, 0x32, 0x36, 0x32, 0x32, 0x35, 0x33, 0x34, 0x30, 0x5a, 0x17, 0x0d, 0x32, 0x37, - 0x30, 0x36, 0x30, 0x36, 0x32, 0x30, 0x34, 0x32, 0x33, 0x38, 0x5a, 0x30, 0x11, 0x31, 0x0f, 0x30, 0x0d, - 0x06, 0x03, 0x55, 0x04, 0x03, 0x13, 0x06, 0x42, 0x65, 0x63, 0x6b, 0x65, 0x72, 0x30, 0x82, 0x01, 0x22, - 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, - 0x01, 0x0f, 0x00, 0x30, 0x82, 0x01, 0x0a, 0x02, 0x82, 0x01, 0x01, 0x00, 0xa8, 0x6b, 0xda, 0xae, 0x08, - 0x1d, 0xc5, 0x05, 0x70, 0x7d, 0xa0, 0x41, 0x46, 0xb4, 0x14, 0xcf, 0xfb, 0x8e, 0x09, 0x0b, 0x0a, 0x52, - 0x8a, 0x7f, 0x7a, 0x35, 0xb6, 0xe3, 0x0d, 0x1c, 0xbe, 0x49, 0x63, 0x41, 0x92, 0x86, 0x00, 0xa2, 0xd3, - 0xff, 0x5b, 0x08, 0x7d, 0x2b, 0x65, 0xe4, 0xc3, 0x09, 0x68, 0x72, 0x21, 0xc4, 0xd8, 0x0a, 0x21, 0x9e, - 0x1f, 0xdf, 0xb2, 0xaa, 0x2b, 0x42, 0x68, 0xe7, 0xeb, 0x52, 0xf8, 0x9e, 0xfc, 0x7f, 0x0f, 0x55, 0x26, - 0x7d, 0x44, 0xfb, 0x35, 0xe5, 0xc2, 0x2c, 0xb6, 0x8d, 0x06, 0xc5, 0xdc, 0xbf, 0x66, 0xf6, 0xb2, 0xf2, - 0x9b, 0xe2, 0x49, 0xaf, 0xfd, 0x4c, 0x69, 0x46, 0x72, 0xe0, 0x2f, 0x31, 0x77, 0x86, 0x7b, 0x5b, 0x6d, - 0x49, 0xe6, 0xc7, 0x84, 0xd1, 0xdd, 0x56, 0x89, 0x8d, 0xbd, 0x07, 0x18, 0x01, 0x43, 0x70, 0x9b, 0x00, - 0x71, 0x16, 0x89, 0x66, 0x2e, 0xb6, 0x5f, 0x62, 0xeb, 0x96, 0xed, 0xf2, 0xdb, 0xdb, 0xcf, 0xdd, 0xa8, - 0xab, 0xde, 0x93, 0xb3, 0xdb, 0x54, 0xf0, 0x34, 0x4a, 0x28, 0xc3, 0x11, 0xf6, 0xb9, 0xd6, 0x45, 0x3f, - 0x07, 0xc0, 0x8e, 0x10, 0x7a, 0x2b, 0x56, 0x15, 0xbb, 0x00, 0x9d, 0x82, 0x27, 0xf2, 0x11, 0xa3, 0xda, - 0x03, 0xaa, 0x51, 0xc0, 0xfd, 0x90, 0xc8, 0x73, 0x81, 0xce, 0x97, 0x30, 0xa2, 0x54, 0x63, 0x6f, 0xfc, - 0x7f, 0x5b, 0x71, 0xec, 0x11, 0xb0, 0xa0, 0xc8, 0x74, 0x3a, 0xcc, 0x1b, 0x5e, 0xcd, 0x91, 0xa8, 0x18, - 0x92, 0xeb, 0x33, 0xc4, 0x6d, 0xb8, 0x16, 0x67, 0xe1, 0xc5, 0xa6, 0x26, 0x35, 0x48, 0xc4, 0xe7, 0x94, - 0xeb, 0xbb, 0xb8, 0xde, 0xd3, 0xe1, 0xc0, 0xcb, 0x00, 0x20, 0xf6, 0xbc, 0xa9, 0xc5, 0x70, 0xc4, 0xda, - 0x1b, 0x61, 0x0b, 0x9f, 0x0b, 0x19, 0x93, 0xaf, 0x8f, 0x40, 0xbb, 0x26, 0x79, 0x02, 0x03, 0x01, 0x00, - 0x01, 0xa3, 0x82, 0x01, 0x1d, 0x30, 0x82, 0x01, 0x19, 0x30, 0x1d, 0x06, 0x03, 0x55, 0x1d, 0x0e, 0x04, - 0x16, 0x04, 0x14, 0xa3, 0xda, 0xe5, 0xef, 0xc3, 0x1c, 0x7a, 0xcf, 0x34, 0x2b, 0xa2, 0x42, 0x2b, 0x77, - 0xcb, 0x62, 0xfb, 0x4c, 0x28, 0x51, 0x30, 0x1f, 0x06, 0x03, 0x55, 0x1d, 0x23, 0x04, 0x18, 0x30, 0x16, - 0x80, 0x14, 0x9c, 0xe1, 0xad, 0x8f, 0xd4, 0x86, 0xd2, 0x1c, 0x7e, 0x48, 0x32, 0xf2, 0x28, 0xfe, 0x87, - 0x90, 0xe3, 0xb1, 0xc5, 0x8e, 0x30, 0x4a, 0x06, 0x03, 0x55, 0x1d, 0x1f, 0x04, 0x43, 0x30, 0x41, 0x30, - 0x3f, 0xa0, 0x3d, 0xa0, 0x3b, 0x86, 0x39, 0x66, 0x69, 0x6c, 0x65, 0x3a, 0x2f, 0x2f, 0x2f, 0x2f, 0x52, - 0x44, 0x32, 0x38, 0x31, 0x38, 0x37, 0x38, 0x30, 0x45, 0x33, 0x45, 0x45, 0x43, 0x2f, 0x43, 0x65, 0x72, - 0x74, 0x45, 0x6e, 0x72, 0x6f, 0x6c, 0x6c, 0x2f, 0x50, 0x72, 0x6f, 0x64, 0x32, 0x4c, 0x53, 0x52, 0x41, - 0x73, 0x68, 0x61, 0x32, 0x52, 0x44, 0x53, 0x4c, 0x4d, 0x2e, 0x63, 0x72, 0x6c, 0x30, 0x64, 0x06, 0x08, - 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x01, 0x01, 0x04, 0x58, 0x30, 0x56, 0x30, 0x54, 0x06, 0x08, 0x2b, - 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x02, 0x86, 0x48, 0x66, 0x69, 0x6c, 0x65, 0x3a, 0x2f, 0x2f, 0x2f, - 0x2f, 0x52, 0x44, 0x32, 0x38, 0x31, 0x38, 0x37, 0x38, 0x30, 0x45, 0x33, 0x45, 0x45, 0x43, 0x2f, 0x43, - 0x65, 0x72, 0x74, 0x45, 0x6e, 0x72, 0x6f, 0x6c, 0x6c, 0x2f, 0x52, 0x44, 0x32, 0x38, 0x31, 0x38, 0x37, - 0x38, 0x30, 0x45, 0x33, 0x45, 0x45, 0x43, 0x5f, 0x50, 0x72, 0x6f, 0x64, 0x32, 0x4c, 0x53, 0x52, 0x41, - 0x73, 0x68, 0x61, 0x32, 0x52, 0x44, 0x53, 0x4c, 0x4d, 0x2e, 0x63, 0x72, 0x74, 0x30, 0x0c, 0x06, 0x03, - 0x55, 0x1d, 0x13, 0x01, 0x01, 0xff, 0x04, 0x02, 0x30, 0x00, 0x30, 0x17, 0x06, 0x08, 0x2b, 0x06, 0x01, - 0x04, 0x01, 0x82, 0x37, 0x12, 0x04, 0x0b, 0x16, 0x09, 0x54, 0x4c, 0x53, 0x7e, 0x42, 0x41, 0x53, 0x49, - 0x43, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b, 0x05, 0x00, 0x03, - 0x82, 0x01, 0x01, 0x00, 0x55, 0xd5, 0x94, 0x3b, 0x06, 0xef, 0xf2, 0xb0, 0xf9, 0xd7, 0x36, 0x2a, 0x36, - 0xe0, 0xf1, 0xd9, 0x18, 0xc1, 0x89, 0x7e, 0xa2, 0xcf, 0x01, 0x6f, 0x22, 0x7b, 0x34, 0x81, 0xf0, 0x7a, - 0x45, 0x11, 0x6e, 0x75, 0x4b, 0x0b, 0xa8, 0xcd, 0x92, 0x57, 0x19, 0x80, 0xb7, 0x6e, 0x1a, 0x4d, 0x12, - 0x65, 0x91, 0x56, 0x38, 0x17, 0x22, 0xa2, 0x75, 0xae, 0xf9, 0x12, 0x75, 0x38, 0xf3, 0x19, 0x74, 0xea, - 0x87, 0x46, 0x1f, 0x98, 0x2c, 0x2f, 0xf9, 0xfc, 0xb4, 0xdc, 0x25, 0xa0, 0xd3, 0x34, 0x1b, 0xbc, 0x21, - 0xbb, 0x3d, 0x82, 0xad, 0x15, 0xc6, 0x3d, 0x02, 0x75, 0x33, 0x70, 0x25, 0x0a, 0x1a, 0xf7, 0x4c, 0xcb, - 0x84, 0xa3, 0xc1, 0x78, 0xe6, 0xf5, 0xa1, 0x44, 0x54, 0xc8, 0x34, 0xfd, 0xef, 0xbf, 0x86, 0x81, 0x9d, - 0x9a, 0x7e, 0xb6, 0xad, 0x71, 0x7e, 0xe4, 0xd9, 0x71, 0x6c, 0xb9, 0xe7, 0xf2, 0xd6, 0xd7, 0xbb, 0x66, - 0x5a, 0x30, 0xf5, 0x29, 0xae, 0x02, 0x39, 0x3d, 0xea, 0x7a, 0x79, 0x1b, 0x53, 0xc5, 0xbe, 0x8d, 0xfb, - 0xe2, 0xe4, 0x8e, 0xc2, 0x04, 0xb3, 0x0a, 0x94, 0x75, 0xa3, 0xbf, 0xd4, 0x87, 0xd2, 0x74, 0x15, 0x05, - 0x5e, 0xd5, 0x8f, 0x94, 0x23, 0x41, 0x13, 0x3f, 0xbd, 0xed, 0x21, 0x55, 0x96, 0xe9, 0xc4, 0x93, 0x34, - 0x7f, 0xaa, 0xea, 0xe7, 0xb1, 0x9a, 0xca, 0x25, 0x91, 0x18, 0xdf, 0x28, 0x05, 0x8e, 0x53, 0xb3, 0x8c, - 0x8d, 0xcc, 0xf3, 0xf4, 0x78, 0x76, 0x76, 0x7b, 0x82, 0xd6, 0x75, 0x7a, 0x7d, 0xb3, 0x23, 0x2c, 0xc7, - 0xbe, 0xa6, 0xb0, 0x50, 0x4d, 0x6c, 0xe2, 0x90, 0x85, 0x97, 0x77, 0x0d, 0x2f, 0xf5, 0x7b, 0xb0, 0xc6, - 0xad, 0xfa, 0x9a, 0x2c, 0xdf, 0xeb, 0x0d, 0x60, 0xd3, 0x0e, 0xa8, 0x5c, 0x43, 0xab, 0x09, 0x85, 0xa3, - 0xa9, 0x31, 0x66, 0xbd, 0xe4], - vec![0x30, 0x82, 0x04, 0x59, 0x30, 0x82, 0x03, 0x45, 0xa0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x05, 0x01, - 0x00, 0x00, 0x00, 0x02, 0x30, 0x09, 0x06, 0x05, 0x2b, 0x0e, 0x03, 0x02, 0x1d, 0x05, 0x00, 0x30, 0x11, - 0x31, 0x0f, 0x30, 0x0d, 0x06, 0x03, 0x55, 0x04, 0x03, 0x13, 0x06, 0x42, 0x65, 0x63, 0x6b, 0x65, 0x72, - 0x30, 0x1e, 0x17, 0x0d, 0x31, 0x39, 0x31, 0x30, 0x32, 0x36, 0x32, 0x33, 0x32, 0x36, 0x34, 0x35, 0x5a, - 0x17, 0x0d, 0x33, 0x38, 0x30, 0x31, 0x31, 0x39, 0x30, 0x33, 0x31, 0x34, 0x30, 0x37, 0x5a, 0x30, 0x81, - 0xa6, 0x31, 0x81, 0xa3, 0x30, 0x27, 0x06, 0x03, 0x55, 0x04, 0x03, 0x1e, 0x20, 0x00, 0x6e, 0x00, 0x63, - 0x00, 0x61, 0x00, 0x63, 0x00, 0x6e, 0x00, 0x5f, 0x00, 0x69, 0x00, 0x70, 0x00, 0x5f, 0x00, 0x74, 0x00, - 0x63, 0x00, 0x70, 0x00, 0x3a, 0x00, 0x31, 0x00, 0x32, 0x00, 0x37, 0x30, 0x33, 0x06, 0x03, 0x55, 0x04, - 0x07, 0x1e, 0x2c, 0x00, 0x6e, 0x00, 0x63, 0x00, 0x61, 0x00, 0x63, 0x00, 0x6e, 0x00, 0x5f, 0x00, 0x69, - 0x00, 0x70, 0x00, 0x5f, 0x00, 0x74, 0x00, 0x63, 0x00, 0x70, 0x00, 0x3a, 0x00, 0x31, 0x00, 0x32, 0x00, - 0x37, 0x00, 0x2e, 0x00, 0x30, 0x00, 0x2e, 0x00, 0x30, 0x00, 0x2e, 0x00, 0x31, 0x30, 0x43, 0x06, 0x03, - 0x55, 0x04, 0x05, 0x1e, 0x3c, 0x00, 0x31, 0x00, 0x42, 0x00, 0x63, 0x00, 0x4b, 0x00, 0x65, 0x00, 0x56, - 0x00, 0x33, 0x00, 0x4d, 0x00, 0x67, 0x00, 0x74, 0x00, 0x6a, 0x00, 0x55, 0x00, 0x74, 0x00, 0x6f, 0x00, - 0x32, 0x00, 0x50, 0x00, 0x49, 0x00, 0x68, 0x00, 0x35, 0x00, 0x52, 0x00, 0x57, 0x00, 0x56, 0x00, 0x36, - 0x00, 0x42, 0x00, 0x58, 0x00, 0x48, 0x00, 0x77, 0x00, 0x3d, 0x00, 0x0d, 0x00, 0x0a, 0x30, 0x58, 0x30, - 0x09, 0x06, 0x05, 0x2b, 0x0e, 0x03, 0x02, 0x0f, 0x05, 0x00, 0x03, 0x4b, 0x00, 0x30, 0x48, 0x02, 0x41, - 0x00, 0xab, 0xac, 0x87, 0x11, 0x83, 0xbf, 0xe9, 0x48, 0x25, 0x00, 0x2c, 0x33, 0x31, 0x5e, 0x3d, 0x78, - 0xc8, 0x5f, 0x82, 0xcb, 0x36, 0x41, 0xf5, 0xb4, 0x65, 0x15, 0xee, 0x04, 0x31, 0xae, 0xe2, 0x48, 0x58, - 0x99, 0x7f, 0x4f, 0x90, 0x1d, 0xf7, 0x7c, 0xd7, 0xf8, 0x47, 0x93, 0xa0, 0xca, 0x9c, 0xdf, 0x91, 0xb0, - 0x41, 0xe8, 0x05, 0x4b, 0xdc, 0x24, 0x5b, 0x72, 0xf7, 0x68, 0x91, 0x84, 0xfb, 0x19, 0x02, 0x03, 0x01, - 0x00, 0x01, 0xa3, 0x82, 0x01, 0xf4, 0x30, 0x82, 0x01, 0xf0, 0x30, 0x14, 0x06, 0x09, 0x2b, 0x06, 0x01, - 0x04, 0x01, 0x82, 0x37, 0x12, 0x04, 0x01, 0x01, 0xff, 0x04, 0x04, 0x01, 0x00, 0x05, 0x00, 0x30, 0x3c, - 0x06, 0x09, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x12, 0x02, 0x01, 0x01, 0xff, 0x04, 0x2c, 0x4d, - 0x00, 0x69, 0x00, 0x63, 0x00, 0x72, 0x00, 0x6f, 0x00, 0x73, 0x00, 0x6f, 0x00, 0x66, 0x00, 0x74, 0x00, - 0x20, 0x00, 0x43, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x70, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x61, 0x00, 0x74, - 0x00, 0x69, 0x00, 0x6f, 0x00, 0x6e, 0x00, 0x00, 0x00, 0x30, 0x81, 0xdd, 0x06, 0x09, 0x2b, 0x06, 0x01, - 0x04, 0x01, 0x82, 0x37, 0x12, 0x05, 0x01, 0x01, 0xff, 0x04, 0x81, 0xcc, 0x00, 0x30, 0x00, 0x00, 0x01, - 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x22, 0x04, 0x00, 0x00, 0x1c, 0x00, 0x4a, 0x00, 0x66, 0x00, - 0x4a, 0x00, 0xb0, 0x00, 0x03, 0x00, 0x33, 0x00, 0x64, 0x00, 0x32, 0x00, 0x36, 0x00, 0x37, 0x00, 0x39, - 0x00, 0x35, 0x00, 0x34, 0x00, 0x2d, 0x00, 0x65, 0x00, 0x65, 0x00, 0x62, 0x00, 0x37, 0x00, 0x2d, 0x00, - 0x31, 0x00, 0x31, 0x00, 0x64, 0x00, 0x31, 0x00, 0x2d, 0x00, 0x62, 0x00, 0x39, 0x00, 0x34, 0x00, 0x65, - 0x00, 0x2d, 0x00, 0x30, 0x00, 0x30, 0x00, 0x63, 0x00, 0x30, 0x00, 0x34, 0x00, 0x66, 0x00, 0x61, 0x00, - 0x33, 0x00, 0x30, 0x00, 0x38, 0x00, 0x30, 0x00, 0x64, 0x00, 0x00, 0x00, 0x33, 0x00, 0x64, 0x00, 0x32, - 0x00, 0x36, 0x00, 0x37, 0x00, 0x39, 0x00, 0x35, 0x00, 0x34, 0x00, 0x2d, 0x00, 0x65, 0x00, 0x65, 0x00, - 0x62, 0x00, 0x37, 0x00, 0x2d, 0x00, 0x31, 0x00, 0x31, 0x00, 0x64, 0x00, 0x31, 0x00, 0x2d, 0x00, 0x62, - 0x00, 0x39, 0x00, 0x34, 0x00, 0x65, 0x00, 0x2d, 0x00, 0x30, 0x00, 0x30, 0x00, 0x63, 0x00, 0x30, 0x00, - 0x34, 0x00, 0x66, 0x00, 0x61, 0x00, 0x33, 0x00, 0x30, 0x00, 0x38, 0x00, 0x30, 0x00, 0x64, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x80, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x81, 0x80, 0x06, 0x09, - 0x2b, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x12, 0x06, 0x01, 0x01, 0xff, 0x04, 0x70, 0x00, 0x30, 0x00, - 0x00, 0x00, 0x00, 0x20, 0x00, 0x50, 0x00, 0x57, 0x00, 0x49, 0x00, 0x4e, 0x00, 0x2d, 0x00, 0x34, 0x00, - 0x4c, 0x00, 0x34, 0x00, 0x4c, 0x00, 0x36, 0x00, 0x41, 0x00, 0x4d, 0x00, 0x42, 0x00, 0x43, 0x00, 0x53, - 0x00, 0x51, 0x00, 0x00, 0x00, 0x30, 0x00, 0x30, 0x00, 0x34, 0x00, 0x32, 0x00, 0x39, 0x00, 0x2d, 0x00, - 0x30, 0x00, 0x30, 0x00, 0x30, 0x00, 0x30, 0x00, 0x30, 0x00, 0x2d, 0x00, 0x33, 0x00, 0x34, 0x00, 0x39, - 0x00, 0x37, 0x00, 0x32, 0x00, 0x2d, 0x00, 0x41, 0x00, 0x54, 0x00, 0x33, 0x00, 0x35, 0x00, 0x33, 0x00, - 0x00, 0x00, 0x57, 0x00, 0x4f, 0x00, 0x52, 0x00, 0x4b, 0x00, 0x47, 0x00, 0x52, 0x00, 0x4f, 0x00, 0x55, - 0x00, 0x50, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x37, 0x06, 0x03, 0x55, 0x1d, 0x23, 0x01, 0x01, 0xff, - 0x04, 0x2d, 0x30, 0x2b, 0xa1, 0x22, 0xa4, 0x20, 0x57, 0x00, 0x49, 0x00, 0x4e, 0x00, 0x2d, 0x00, 0x34, - 0x00, 0x4c, 0x00, 0x34, 0x00, 0x4c, 0x00, 0x36, 0x00, 0x41, 0x00, 0x4d, 0x00, 0x42, 0x00, 0x43, 0x00, - 0x53, 0x00, 0x51, 0x00, 0x00, 0x00, 0x82, 0x05, 0x01, 0x00, 0x00, 0x00, 0x02, 0x30, 0x09, 0x06, 0x05, - 0x2b, 0x0e, 0x03, 0x02, 0x1d, 0x05, 0x00, 0x03, 0x82, 0x01, 0x01, 0x00, 0x3e, 0xd3, 0xd5, 0x61, 0x8a, - 0x87, 0x7b, 0x98, 0x2c, 0x6d, 0x20, 0x38, 0x12, 0x08, 0xd8, 0xf7, 0x83, 0x08, 0xf8, 0xe6, 0xb2, 0xe1, - 0x21, 0xe1, 0x30, 0x61, 0x12, 0x19, 0xe8, 0xc1, 0x41, 0xaf, 0x59, 0x7c, 0x1e, 0x3e, 0xc8, 0x40, 0x9e, - 0x24, 0xe8, 0x8d, 0x0c, 0x41, 0xfd, 0xf8, 0x3e, 0xa1, 0xb3, 0xac, 0x56, 0xac, 0x52, 0x91, 0x5a, 0xf8, - 0xd0, 0x40, 0x8e, 0x13, 0x47, 0xa9, 0x8a, 0x0a, 0x62, 0x6d, 0x11, 0x89, 0x20, 0x56, 0xe7, 0xd6, 0x5f, - 0x12, 0x44, 0x94, 0xbf, 0x63, 0x99, 0xa3, 0x42, 0x40, 0xd5, 0xc6, 0x8c, 0x1f, 0x4b, 0xf8, 0xaf, 0x83, - 0x8e, 0xf6, 0x74, 0xb2, 0x0b, 0x55, 0x13, 0x4a, 0x76, 0xed, 0x37, 0xd8, 0x3d, 0x13, 0xe7, 0xae, 0x43, - 0x4c, 0x9a, 0x61, 0x6c, 0x7b, 0x1b, 0xd1, 0xaa, 0x00, 0x97, 0xdf, 0x5b, 0x85, 0x9f, 0xc8, 0xee, 0x6c, - 0xe5, 0xa2, 0x63, 0x76, 0xe4, 0x06, 0xd3, 0x2a, 0xe0, 0x55, 0xe1, 0x92, 0x78, 0xed, 0x03, 0x7b, 0x7d, - 0x1a, 0x6e, 0xc2, 0x56, 0xdc, 0xad, 0x6e, 0xd7, 0xa9, 0xfe, 0xa7, 0xfd, 0x09, 0x0a, 0xa6, 0xd5, 0x8a, - 0x99, 0xa4, 0x75, 0x89, 0xad, 0x84, 0xc7, 0x09, 0xf7, 0x4c, 0x6e, 0xd0, 0xe2, 0x80, 0x17, 0x62, 0xfa, - 0x86, 0xfe, 0x43, 0x51, 0xf2, 0xb4, 0xf6, 0xef, 0x3b, 0xb3, 0x3d, 0x1f, 0xef, 0xa3, 0xcb, 0xa2, 0x57, - 0x25, 0x7c, 0x02, 0xf2, 0x27, 0x1c, 0x87, 0x70, 0x8e, 0x84, 0x20, 0xfe, 0x1d, 0x4a, 0xc4, 0x87, 0x24, - 0x3b, 0xba, 0xff, 0x34, 0x1a, 0xe2, 0xff, 0xa2, 0x43, 0x39, 0xd8, 0x19, 0x97, 0xf8, 0xf0, 0xf9, 0x73, - 0xa6, 0xb6, 0x55, 0x64, 0xa6, 0xca, 0xa3, 0x48, 0x22, 0xb7, 0x1a, 0x9b, 0x98, 0x1a, 0x8e, 0x2f, 0xaa, - 0xec, 0xc1, 0xfe, 0x25, 0x36, 0x2b, 0x70, 0x97, 0x8c, 0x5b, 0x62, 0x21, 0xc3], + preamble_message_type: PreambleType::LicenseRequest, + preamble_flags: PreambleFlags::empty(), + preamble_version: PreambleVersion::V3, + preamble_message_size: 0, + }, + server_random: Vec::from(SERVER_RANDOM_BUFFER.as_ref()), + product_info: ProductInfo { + version: 0x60000, + company_name: "Microsoft Corporation".to_owned(), + product_id: "A02".to_owned(), + }, + server_certificate: Some(ServerCertificate { + issued_permanently: true, + certificate: CertificateType::X509(X509CertificateChain { + certificate_array: vec![ + vec![ + 0x30, 0x82, 0x03, 0xda, 0x30, 0x82, 0x02, 0xc2, 0xa0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x13, 0x7f, + 0x00, 0x00, 0x01, 0x76, 0x00, 0x8f, 0x08, 0x64, 0x08, 0x68, 0xa7, 0x63, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x76, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b, 0x05, + 0x00, 0x30, 0x1d, 0x31, 0x1b, 0x30, 0x19, 0x06, 0x03, 0x55, 0x04, 0x03, 0x13, 0x12, 0x50, 0x72, + 0x6f, 0x64, 0x32, 0x4c, 0x53, 0x52, 0x41, 0x73, 0x68, 0x61, 0x32, 0x52, 0x44, 0x53, 0x4c, 0x4d, + 0x30, 0x1e, 0x17, 0x0d, 0x31, 0x39, 0x31, 0x30, 0x32, 0x36, 0x32, 0x32, 0x35, 0x33, 0x34, 0x30, + 0x5a, 0x17, 0x0d, 0x32, 0x37, 0x30, 0x36, 0x30, 0x36, 0x32, 0x30, 0x34, 0x32, 0x33, 0x38, 0x5a, + 0x30, 0x11, 0x31, 0x0f, 0x30, 0x0d, 0x06, 0x03, 0x55, 0x04, 0x03, 0x13, 0x06, 0x42, 0x65, 0x63, + 0x6b, 0x65, 0x72, 0x30, 0x82, 0x01, 0x22, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, + 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0f, 0x00, 0x30, 0x82, 0x01, 0x0a, 0x02, + 0x82, 0x01, 0x01, 0x00, 0xa8, 0x6b, 0xda, 0xae, 0x08, 0x1d, 0xc5, 0x05, 0x70, 0x7d, 0xa0, 0x41, + 0x46, 0xb4, 0x14, 0xcf, 0xfb, 0x8e, 0x09, 0x0b, 0x0a, 0x52, 0x8a, 0x7f, 0x7a, 0x35, 0xb6, 0xe3, + 0x0d, 0x1c, 0xbe, 0x49, 0x63, 0x41, 0x92, 0x86, 0x00, 0xa2, 0xd3, 0xff, 0x5b, 0x08, 0x7d, 0x2b, + 0x65, 0xe4, 0xc3, 0x09, 0x68, 0x72, 0x21, 0xc4, 0xd8, 0x0a, 0x21, 0x9e, 0x1f, 0xdf, 0xb2, 0xaa, + 0x2b, 0x42, 0x68, 0xe7, 0xeb, 0x52, 0xf8, 0x9e, 0xfc, 0x7f, 0x0f, 0x55, 0x26, 0x7d, 0x44, 0xfb, + 0x35, 0xe5, 0xc2, 0x2c, 0xb6, 0x8d, 0x06, 0xc5, 0xdc, 0xbf, 0x66, 0xf6, 0xb2, 0xf2, 0x9b, 0xe2, + 0x49, 0xaf, 0xfd, 0x4c, 0x69, 0x46, 0x72, 0xe0, 0x2f, 0x31, 0x77, 0x86, 0x7b, 0x5b, 0x6d, 0x49, + 0xe6, 0xc7, 0x84, 0xd1, 0xdd, 0x56, 0x89, 0x8d, 0xbd, 0x07, 0x18, 0x01, 0x43, 0x70, 0x9b, 0x00, + 0x71, 0x16, 0x89, 0x66, 0x2e, 0xb6, 0x5f, 0x62, 0xeb, 0x96, 0xed, 0xf2, 0xdb, 0xdb, 0xcf, 0xdd, + 0xa8, 0xab, 0xde, 0x93, 0xb3, 0xdb, 0x54, 0xf0, 0x34, 0x4a, 0x28, 0xc3, 0x11, 0xf6, 0xb9, 0xd6, + 0x45, 0x3f, 0x07, 0xc0, 0x8e, 0x10, 0x7a, 0x2b, 0x56, 0x15, 0xbb, 0x00, 0x9d, 0x82, 0x27, 0xf2, + 0x11, 0xa3, 0xda, 0x03, 0xaa, 0x51, 0xc0, 0xfd, 0x90, 0xc8, 0x73, 0x81, 0xce, 0x97, 0x30, 0xa2, + 0x54, 0x63, 0x6f, 0xfc, 0x7f, 0x5b, 0x71, 0xec, 0x11, 0xb0, 0xa0, 0xc8, 0x74, 0x3a, 0xcc, 0x1b, + 0x5e, 0xcd, 0x91, 0xa8, 0x18, 0x92, 0xeb, 0x33, 0xc4, 0x6d, 0xb8, 0x16, 0x67, 0xe1, 0xc5, 0xa6, + 0x26, 0x35, 0x48, 0xc4, 0xe7, 0x94, 0xeb, 0xbb, 0xb8, 0xde, 0xd3, 0xe1, 0xc0, 0xcb, 0x00, 0x20, + 0xf6, 0xbc, 0xa9, 0xc5, 0x70, 0xc4, 0xda, 0x1b, 0x61, 0x0b, 0x9f, 0x0b, 0x19, 0x93, 0xaf, 0x8f, + 0x40, 0xbb, 0x26, 0x79, 0x02, 0x03, 0x01, 0x00, 0x01, 0xa3, 0x82, 0x01, 0x1d, 0x30, 0x82, 0x01, + 0x19, 0x30, 0x1d, 0x06, 0x03, 0x55, 0x1d, 0x0e, 0x04, 0x16, 0x04, 0x14, 0xa3, 0xda, 0xe5, 0xef, + 0xc3, 0x1c, 0x7a, 0xcf, 0x34, 0x2b, 0xa2, 0x42, 0x2b, 0x77, 0xcb, 0x62, 0xfb, 0x4c, 0x28, 0x51, + 0x30, 0x1f, 0x06, 0x03, 0x55, 0x1d, 0x23, 0x04, 0x18, 0x30, 0x16, 0x80, 0x14, 0x9c, 0xe1, 0xad, + 0x8f, 0xd4, 0x86, 0xd2, 0x1c, 0x7e, 0x48, 0x32, 0xf2, 0x28, 0xfe, 0x87, 0x90, 0xe3, 0xb1, 0xc5, + 0x8e, 0x30, 0x4a, 0x06, 0x03, 0x55, 0x1d, 0x1f, 0x04, 0x43, 0x30, 0x41, 0x30, 0x3f, 0xa0, 0x3d, + 0xa0, 0x3b, 0x86, 0x39, 0x66, 0x69, 0x6c, 0x65, 0x3a, 0x2f, 0x2f, 0x2f, 0x2f, 0x52, 0x44, 0x32, + 0x38, 0x31, 0x38, 0x37, 0x38, 0x30, 0x45, 0x33, 0x45, 0x45, 0x43, 0x2f, 0x43, 0x65, 0x72, 0x74, + 0x45, 0x6e, 0x72, 0x6f, 0x6c, 0x6c, 0x2f, 0x50, 0x72, 0x6f, 0x64, 0x32, 0x4c, 0x53, 0x52, 0x41, + 0x73, 0x68, 0x61, 0x32, 0x52, 0x44, 0x53, 0x4c, 0x4d, 0x2e, 0x63, 0x72, 0x6c, 0x30, 0x64, 0x06, + 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x01, 0x01, 0x04, 0x58, 0x30, 0x56, 0x30, 0x54, 0x06, + 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x02, 0x86, 0x48, 0x66, 0x69, 0x6c, 0x65, 0x3a, + 0x2f, 0x2f, 0x2f, 0x2f, 0x52, 0x44, 0x32, 0x38, 0x31, 0x38, 0x37, 0x38, 0x30, 0x45, 0x33, 0x45, + 0x45, 0x43, 0x2f, 0x43, 0x65, 0x72, 0x74, 0x45, 0x6e, 0x72, 0x6f, 0x6c, 0x6c, 0x2f, 0x52, 0x44, + 0x32, 0x38, 0x31, 0x38, 0x37, 0x38, 0x30, 0x45, 0x33, 0x45, 0x45, 0x43, 0x5f, 0x50, 0x72, 0x6f, + 0x64, 0x32, 0x4c, 0x53, 0x52, 0x41, 0x73, 0x68, 0x61, 0x32, 0x52, 0x44, 0x53, 0x4c, 0x4d, 0x2e, + 0x63, 0x72, 0x74, 0x30, 0x0c, 0x06, 0x03, 0x55, 0x1d, 0x13, 0x01, 0x01, 0xff, 0x04, 0x02, 0x30, + 0x00, 0x30, 0x17, 0x06, 0x08, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x12, 0x04, 0x0b, 0x16, + 0x09, 0x54, 0x4c, 0x53, 0x7e, 0x42, 0x41, 0x53, 0x49, 0x43, 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, + 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b, 0x05, 0x00, 0x03, 0x82, 0x01, 0x01, 0x00, 0x55, 0xd5, + 0x94, 0x3b, 0x06, 0xef, 0xf2, 0xb0, 0xf9, 0xd7, 0x36, 0x2a, 0x36, 0xe0, 0xf1, 0xd9, 0x18, 0xc1, + 0x89, 0x7e, 0xa2, 0xcf, 0x01, 0x6f, 0x22, 0x7b, 0x34, 0x81, 0xf0, 0x7a, 0x45, 0x11, 0x6e, 0x75, + 0x4b, 0x0b, 0xa8, 0xcd, 0x92, 0x57, 0x19, 0x80, 0xb7, 0x6e, 0x1a, 0x4d, 0x12, 0x65, 0x91, 0x56, + 0x38, 0x17, 0x22, 0xa2, 0x75, 0xae, 0xf9, 0x12, 0x75, 0x38, 0xf3, 0x19, 0x74, 0xea, 0x87, 0x46, + 0x1f, 0x98, 0x2c, 0x2f, 0xf9, 0xfc, 0xb4, 0xdc, 0x25, 0xa0, 0xd3, 0x34, 0x1b, 0xbc, 0x21, 0xbb, + 0x3d, 0x82, 0xad, 0x15, 0xc6, 0x3d, 0x02, 0x75, 0x33, 0x70, 0x25, 0x0a, 0x1a, 0xf7, 0x4c, 0xcb, + 0x84, 0xa3, 0xc1, 0x78, 0xe6, 0xf5, 0xa1, 0x44, 0x54, 0xc8, 0x34, 0xfd, 0xef, 0xbf, 0x86, 0x81, + 0x9d, 0x9a, 0x7e, 0xb6, 0xad, 0x71, 0x7e, 0xe4, 0xd9, 0x71, 0x6c, 0xb9, 0xe7, 0xf2, 0xd6, 0xd7, + 0xbb, 0x66, 0x5a, 0x30, 0xf5, 0x29, 0xae, 0x02, 0x39, 0x3d, 0xea, 0x7a, 0x79, 0x1b, 0x53, 0xc5, + 0xbe, 0x8d, 0xfb, 0xe2, 0xe4, 0x8e, 0xc2, 0x04, 0xb3, 0x0a, 0x94, 0x75, 0xa3, 0xbf, 0xd4, 0x87, + 0xd2, 0x74, 0x15, 0x05, 0x5e, 0xd5, 0x8f, 0x94, 0x23, 0x41, 0x13, 0x3f, 0xbd, 0xed, 0x21, 0x55, + 0x96, 0xe9, 0xc4, 0x93, 0x34, 0x7f, 0xaa, 0xea, 0xe7, 0xb1, 0x9a, 0xca, 0x25, 0x91, 0x18, 0xdf, + 0x28, 0x05, 0x8e, 0x53, 0xb3, 0x8c, 0x8d, 0xcc, 0xf3, 0xf4, 0x78, 0x76, 0x76, 0x7b, 0x82, 0xd6, + 0x75, 0x7a, 0x7d, 0xb3, 0x23, 0x2c, 0xc7, 0xbe, 0xa6, 0xb0, 0x50, 0x4d, 0x6c, 0xe2, 0x90, 0x85, + 0x97, 0x77, 0x0d, 0x2f, 0xf5, 0x7b, 0xb0, 0xc6, 0xad, 0xfa, 0x9a, 0x2c, 0xdf, 0xeb, 0x0d, 0x60, + 0xd3, 0x0e, 0xa8, 0x5c, 0x43, 0xab, 0x09, 0x85, 0xa3, 0xa9, 0x31, 0x66, 0xbd, 0xe4, ], - }), + vec![ + 0x30, 0x82, 0x04, 0x59, 0x30, 0x82, 0x03, 0x45, 0xa0, 0x03, 0x02, 0x01, 0x02, 0x02, 0x05, 0x01, + 0x00, 0x00, 0x00, 0x02, 0x30, 0x09, 0x06, 0x05, 0x2b, 0x0e, 0x03, 0x02, 0x1d, 0x05, 0x00, 0x30, + 0x11, 0x31, 0x0f, 0x30, 0x0d, 0x06, 0x03, 0x55, 0x04, 0x03, 0x13, 0x06, 0x42, 0x65, 0x63, 0x6b, + 0x65, 0x72, 0x30, 0x1e, 0x17, 0x0d, 0x31, 0x39, 0x31, 0x30, 0x32, 0x36, 0x32, 0x33, 0x32, 0x36, + 0x34, 0x35, 0x5a, 0x17, 0x0d, 0x33, 0x38, 0x30, 0x31, 0x31, 0x39, 0x30, 0x33, 0x31, 0x34, 0x30, + 0x37, 0x5a, 0x30, 0x81, 0xa6, 0x31, 0x81, 0xa3, 0x30, 0x27, 0x06, 0x03, 0x55, 0x04, 0x03, 0x1e, + 0x20, 0x00, 0x6e, 0x00, 0x63, 0x00, 0x61, 0x00, 0x63, 0x00, 0x6e, 0x00, 0x5f, 0x00, 0x69, 0x00, + 0x70, 0x00, 0x5f, 0x00, 0x74, 0x00, 0x63, 0x00, 0x70, 0x00, 0x3a, 0x00, 0x31, 0x00, 0x32, 0x00, + 0x37, 0x30, 0x33, 0x06, 0x03, 0x55, 0x04, 0x07, 0x1e, 0x2c, 0x00, 0x6e, 0x00, 0x63, 0x00, 0x61, + 0x00, 0x63, 0x00, 0x6e, 0x00, 0x5f, 0x00, 0x69, 0x00, 0x70, 0x00, 0x5f, 0x00, 0x74, 0x00, 0x63, + 0x00, 0x70, 0x00, 0x3a, 0x00, 0x31, 0x00, 0x32, 0x00, 0x37, 0x00, 0x2e, 0x00, 0x30, 0x00, 0x2e, + 0x00, 0x30, 0x00, 0x2e, 0x00, 0x31, 0x30, 0x43, 0x06, 0x03, 0x55, 0x04, 0x05, 0x1e, 0x3c, 0x00, + 0x31, 0x00, 0x42, 0x00, 0x63, 0x00, 0x4b, 0x00, 0x65, 0x00, 0x56, 0x00, 0x33, 0x00, 0x4d, 0x00, + 0x67, 0x00, 0x74, 0x00, 0x6a, 0x00, 0x55, 0x00, 0x74, 0x00, 0x6f, 0x00, 0x32, 0x00, 0x50, 0x00, + 0x49, 0x00, 0x68, 0x00, 0x35, 0x00, 0x52, 0x00, 0x57, 0x00, 0x56, 0x00, 0x36, 0x00, 0x42, 0x00, + 0x58, 0x00, 0x48, 0x00, 0x77, 0x00, 0x3d, 0x00, 0x0d, 0x00, 0x0a, 0x30, 0x58, 0x30, 0x09, 0x06, + 0x05, 0x2b, 0x0e, 0x03, 0x02, 0x0f, 0x05, 0x00, 0x03, 0x4b, 0x00, 0x30, 0x48, 0x02, 0x41, 0x00, + 0xab, 0xac, 0x87, 0x11, 0x83, 0xbf, 0xe9, 0x48, 0x25, 0x00, 0x2c, 0x33, 0x31, 0x5e, 0x3d, 0x78, + 0xc8, 0x5f, 0x82, 0xcb, 0x36, 0x41, 0xf5, 0xb4, 0x65, 0x15, 0xee, 0x04, 0x31, 0xae, 0xe2, 0x48, + 0x58, 0x99, 0x7f, 0x4f, 0x90, 0x1d, 0xf7, 0x7c, 0xd7, 0xf8, 0x47, 0x93, 0xa0, 0xca, 0x9c, 0xdf, + 0x91, 0xb0, 0x41, 0xe8, 0x05, 0x4b, 0xdc, 0x24, 0x5b, 0x72, 0xf7, 0x68, 0x91, 0x84, 0xfb, 0x19, + 0x02, 0x03, 0x01, 0x00, 0x01, 0xa3, 0x82, 0x01, 0xf4, 0x30, 0x82, 0x01, 0xf0, 0x30, 0x14, 0x06, + 0x09, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x12, 0x04, 0x01, 0x01, 0xff, 0x04, 0x04, 0x01, + 0x00, 0x05, 0x00, 0x30, 0x3c, 0x06, 0x09, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x12, 0x02, + 0x01, 0x01, 0xff, 0x04, 0x2c, 0x4d, 0x00, 0x69, 0x00, 0x63, 0x00, 0x72, 0x00, 0x6f, 0x00, 0x73, + 0x00, 0x6f, 0x00, 0x66, 0x00, 0x74, 0x00, 0x20, 0x00, 0x43, 0x00, 0x6f, 0x00, 0x72, 0x00, 0x70, + 0x00, 0x6f, 0x00, 0x72, 0x00, 0x61, 0x00, 0x74, 0x00, 0x69, 0x00, 0x6f, 0x00, 0x6e, 0x00, 0x00, + 0x00, 0x30, 0x81, 0xdd, 0x06, 0x09, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x12, 0x05, 0x01, + 0x01, 0xff, 0x04, 0x81, 0xcc, 0x00, 0x30, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, + 0x00, 0x22, 0x04, 0x00, 0x00, 0x1c, 0x00, 0x4a, 0x00, 0x66, 0x00, 0x4a, 0x00, 0xb0, 0x00, 0x03, + 0x00, 0x33, 0x00, 0x64, 0x00, 0x32, 0x00, 0x36, 0x00, 0x37, 0x00, 0x39, 0x00, 0x35, 0x00, 0x34, + 0x00, 0x2d, 0x00, 0x65, 0x00, 0x65, 0x00, 0x62, 0x00, 0x37, 0x00, 0x2d, 0x00, 0x31, 0x00, 0x31, + 0x00, 0x64, 0x00, 0x31, 0x00, 0x2d, 0x00, 0x62, 0x00, 0x39, 0x00, 0x34, 0x00, 0x65, 0x00, 0x2d, + 0x00, 0x30, 0x00, 0x30, 0x00, 0x63, 0x00, 0x30, 0x00, 0x34, 0x00, 0x66, 0x00, 0x61, 0x00, 0x33, + 0x00, 0x30, 0x00, 0x38, 0x00, 0x30, 0x00, 0x64, 0x00, 0x00, 0x00, 0x33, 0x00, 0x64, 0x00, 0x32, + 0x00, 0x36, 0x00, 0x37, 0x00, 0x39, 0x00, 0x35, 0x00, 0x34, 0x00, 0x2d, 0x00, 0x65, 0x00, 0x65, + 0x00, 0x62, 0x00, 0x37, 0x00, 0x2d, 0x00, 0x31, 0x00, 0x31, 0x00, 0x64, 0x00, 0x31, 0x00, 0x2d, + 0x00, 0x62, 0x00, 0x39, 0x00, 0x34, 0x00, 0x65, 0x00, 0x2d, 0x00, 0x30, 0x00, 0x30, 0x00, 0x63, + 0x00, 0x30, 0x00, 0x34, 0x00, 0x66, 0x00, 0x61, 0x00, 0x33, 0x00, 0x30, 0x00, 0x38, 0x00, 0x30, + 0x00, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x10, 0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x30, 0x81, 0x80, 0x06, 0x09, 0x2b, 0x06, 0x01, 0x04, 0x01, 0x82, 0x37, 0x12, 0x06, 0x01, + 0x01, 0xff, 0x04, 0x70, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x50, 0x00, 0x57, 0x00, + 0x49, 0x00, 0x4e, 0x00, 0x2d, 0x00, 0x34, 0x00, 0x4c, 0x00, 0x34, 0x00, 0x4c, 0x00, 0x36, 0x00, + 0x41, 0x00, 0x4d, 0x00, 0x42, 0x00, 0x43, 0x00, 0x53, 0x00, 0x51, 0x00, 0x00, 0x00, 0x30, 0x00, + 0x30, 0x00, 0x34, 0x00, 0x32, 0x00, 0x39, 0x00, 0x2d, 0x00, 0x30, 0x00, 0x30, 0x00, 0x30, 0x00, + 0x30, 0x00, 0x30, 0x00, 0x2d, 0x00, 0x33, 0x00, 0x34, 0x00, 0x39, 0x00, 0x37, 0x00, 0x32, 0x00, + 0x2d, 0x00, 0x41, 0x00, 0x54, 0x00, 0x33, 0x00, 0x35, 0x00, 0x33, 0x00, 0x00, 0x00, 0x57, 0x00, + 0x4f, 0x00, 0x52, 0x00, 0x4b, 0x00, 0x47, 0x00, 0x52, 0x00, 0x4f, 0x00, 0x55, 0x00, 0x50, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x37, 0x06, 0x03, 0x55, 0x1d, 0x23, 0x01, 0x01, 0xff, 0x04, 0x2d, + 0x30, 0x2b, 0xa1, 0x22, 0xa4, 0x20, 0x57, 0x00, 0x49, 0x00, 0x4e, 0x00, 0x2d, 0x00, 0x34, 0x00, + 0x4c, 0x00, 0x34, 0x00, 0x4c, 0x00, 0x36, 0x00, 0x41, 0x00, 0x4d, 0x00, 0x42, 0x00, 0x43, 0x00, + 0x53, 0x00, 0x51, 0x00, 0x00, 0x00, 0x82, 0x05, 0x01, 0x00, 0x00, 0x00, 0x02, 0x30, 0x09, 0x06, + 0x05, 0x2b, 0x0e, 0x03, 0x02, 0x1d, 0x05, 0x00, 0x03, 0x82, 0x01, 0x01, 0x00, 0x3e, 0xd3, 0xd5, + 0x61, 0x8a, 0x87, 0x7b, 0x98, 0x2c, 0x6d, 0x20, 0x38, 0x12, 0x08, 0xd8, 0xf7, 0x83, 0x08, 0xf8, + 0xe6, 0xb2, 0xe1, 0x21, 0xe1, 0x30, 0x61, 0x12, 0x19, 0xe8, 0xc1, 0x41, 0xaf, 0x59, 0x7c, 0x1e, + 0x3e, 0xc8, 0x40, 0x9e, 0x24, 0xe8, 0x8d, 0x0c, 0x41, 0xfd, 0xf8, 0x3e, 0xa1, 0xb3, 0xac, 0x56, + 0xac, 0x52, 0x91, 0x5a, 0xf8, 0xd0, 0x40, 0x8e, 0x13, 0x47, 0xa9, 0x8a, 0x0a, 0x62, 0x6d, 0x11, + 0x89, 0x20, 0x56, 0xe7, 0xd6, 0x5f, 0x12, 0x44, 0x94, 0xbf, 0x63, 0x99, 0xa3, 0x42, 0x40, 0xd5, + 0xc6, 0x8c, 0x1f, 0x4b, 0xf8, 0xaf, 0x83, 0x8e, 0xf6, 0x74, 0xb2, 0x0b, 0x55, 0x13, 0x4a, 0x76, + 0xed, 0x37, 0xd8, 0x3d, 0x13, 0xe7, 0xae, 0x43, 0x4c, 0x9a, 0x61, 0x6c, 0x7b, 0x1b, 0xd1, 0xaa, + 0x00, 0x97, 0xdf, 0x5b, 0x85, 0x9f, 0xc8, 0xee, 0x6c, 0xe5, 0xa2, 0x63, 0x76, 0xe4, 0x06, 0xd3, + 0x2a, 0xe0, 0x55, 0xe1, 0x92, 0x78, 0xed, 0x03, 0x7b, 0x7d, 0x1a, 0x6e, 0xc2, 0x56, 0xdc, 0xad, + 0x6e, 0xd7, 0xa9, 0xfe, 0xa7, 0xfd, 0x09, 0x0a, 0xa6, 0xd5, 0x8a, 0x99, 0xa4, 0x75, 0x89, 0xad, + 0x84, 0xc7, 0x09, 0xf7, 0x4c, 0x6e, 0xd0, 0xe2, 0x80, 0x17, 0x62, 0xfa, 0x86, 0xfe, 0x43, 0x51, + 0xf2, 0xb4, 0xf6, 0xef, 0x3b, 0xb3, 0x3d, 0x1f, 0xef, 0xa3, 0xcb, 0xa2, 0x57, 0x25, 0x7c, 0x02, + 0xf2, 0x27, 0x1c, 0x87, 0x70, 0x8e, 0x84, 0x20, 0xfe, 0x1d, 0x4a, 0xc4, 0x87, 0x24, 0x3b, 0xba, + 0xff, 0x34, 0x1a, 0xe2, 0xff, 0xa2, 0x43, 0x39, 0xd8, 0x19, 0x97, 0xf8, 0xf0, 0xf9, 0x73, 0xa6, + 0xb6, 0x55, 0x64, 0xa6, 0xca, 0xa3, 0x48, 0x22, 0xb7, 0x1a, 0x9b, 0x98, 0x1a, 0x8e, 0x2f, 0xaa, + 0xec, 0xc1, 0xfe, 0x25, 0x36, 0x2b, 0x70, 0x97, 0x8c, 0x5b, 0x62, 0x21, 0xc3, + ], + ], }), - scope_list: vec![Scope(String::from("microsoft.com"))], - }; - req.license_header.preamble_message_size = req.size() as u16; - req.into() + }), + scope_list: vec![Scope(String::from("microsoft.com"))], }; -} + req.license_header.preamble_message_size = u16::try_from(req.size()).expect("can't panic"); + req.into() +}); #[test] fn from_buffer_correctly_parses_client_new_license_request() { diff --git a/crates/ironrdp-pdu/src/rdp/server_license/client_platform_challenge_response.rs b/crates/ironrdp-pdu/src/rdp/server_license/client_platform_challenge_response/mod.rs similarity index 80% rename from crates/ironrdp-pdu/src/rdp/server_license/client_platform_challenge_response.rs rename to crates/ironrdp-pdu/src/rdp/server_license/client_platform_challenge_response/mod.rs index 3a6e8cb39b..abd1a8773c 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/client_platform_challenge_response.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/client_platform_challenge_response/mod.rs @@ -5,16 +5,16 @@ use std::io::Write as _; use byteorder::{LittleEndian, WriteBytesExt as _}; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, - ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; use super::{ - BasicSecurityHeader, BasicSecurityHeaderFlags, BlobHeader, BlobType, LicenseEncryptionData, LicenseHeader, - PreambleFlags, PreambleType, PreambleVersion, ServerLicenseError, ServerPlatformChallenge, BLOB_LENGTH_SIZE, - BLOB_TYPE_SIZE, MAC_SIZE, PLATFORM_ID, PREAMBLE_SIZE, + BLOB_LENGTH_SIZE, BLOB_TYPE_SIZE, BasicSecurityHeader, BasicSecurityHeaderFlags, BlobHeader, BlobType, + LicenseEncryptionData, LicenseHeader, MAC_SIZE, PLATFORM_ID, PREAMBLE_SIZE, PreambleFlags, PreambleType, + PreambleVersion, ServerLicenseError, ServerPlatformChallenge, }; use crate::crypto::rc4::Rc4; @@ -27,6 +27,7 @@ pub(crate) const CLIENT_HARDWARE_IDENTIFICATION_SIZE: usize = 20; /// /// [2.2.2.5]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpele/f53ab87c-d07d-4bf9-a2ac-79542f7b456c #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientPlatformChallengeResponse { pub license_header: LicenseHeader, pub encrypted_challenge_response_data: Vec, @@ -46,7 +47,7 @@ impl ClientPlatformChallengeResponse { let decrypted_challenge = rc4.process(platform_challenge.encrypted_platform_challenge.as_slice()); let decrypted_challenge_mac = - super::compute_mac_data(encryption_data.mac_salt_key.as_slice(), decrypted_challenge.as_slice()); + super::compute_mac_data(encryption_data.mac_salt_key.as_slice(), decrypted_challenge.as_slice())?; if decrypted_challenge_mac != platform_challenge.mac_data { return Err(ServerLicenseError::InvalidMacData); @@ -54,9 +55,12 @@ impl ClientPlatformChallengeResponse { let mut challenge_response_data = vec![0u8; RESPONSE_DATA_STATIC_FIELDS_SIZE]; challenge_response_data.write_u16::(RESPONSE_DATA_VERSION)?; - challenge_response_data.write_u16::(ClientType::Other.to_u16().unwrap())?; - challenge_response_data.write_u16::(LicenseDetailLevel::Detail.to_u16().unwrap())?; - challenge_response_data.write_u16::(decrypted_challenge.len() as u16)?; + challenge_response_data.write_u16::(ClientType::Other.as_u16())?; + challenge_response_data.write_u16::(LicenseDetailLevel::Detail.as_u16())?; + challenge_response_data.write_u16::( + u16::try_from(decrypted_challenge.len()) + .map_err(|_| ServerLicenseError::InvalidField("decrypted challenge len"))?, + )?; challenge_response_data.write_all(&decrypted_challenge)?; let mut hardware_id = Vec::with_capacity(CLIENT_HARDWARE_IDENTIFICATION_SIZE); @@ -75,7 +79,7 @@ impl ClientPlatformChallengeResponse { let mac_data = super::compute_mac_data( encryption_data.mac_salt_key.as_slice(), challenge_response_data.as_slice(), - ); + )?; let license_header = LicenseHeader { security_header: BasicSecurityHeader { @@ -84,10 +88,12 @@ impl ClientPlatformChallengeResponse { preamble_message_type: PreambleType::PlatformChallengeResponse, preamble_flags: PreambleFlags::empty(), preamble_version: PreambleVersion::V3, - preamble_message_size: (PREAMBLE_SIZE + preamble_message_size: u16::try_from( + PREAMBLE_SIZE + (BLOB_TYPE_SIZE + BLOB_LENGTH_SIZE) * 2 // 2 blobs in this structure - + MAC_SIZE + encrypted_challenge_response_data.len() + encrypted_hwid.len()) - as u16, + + MAC_SIZE + encrypted_challenge_response_data.len() + encrypted_hwid.len(), + ) + .map_err(|_| ServerLicenseError::InvalidField("preamble message size"))?, }; Ok(Self { @@ -97,9 +103,7 @@ impl ClientPlatformChallengeResponse { mac_data, }) } -} -impl ClientPlatformChallengeResponse { pub fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); @@ -116,18 +120,6 @@ impl ClientPlatformChallengeResponse { Ok(()) } - pub fn name(&self) -> &'static str { - Self::NAME - } - - pub fn size(&self) -> usize { - self.license_header.size() - + (BLOB_TYPE_SIZE + BLOB_LENGTH_SIZE) * 2 // 2 blobs in this structure - + MAC_SIZE + self.encrypted_challenge_response_data.len() + self.encrypted_hwid.len() - } -} - -impl ClientPlatformChallengeResponse { pub fn decode(license_header: LicenseHeader, src: &mut ReadCursor<'_>) -> DecodeResult { if license_header.preamble_message_type != PreambleType::PlatformChallengeResponse { return Err(invalid_field_err!( @@ -160,9 +152,21 @@ impl ClientPlatformChallengeResponse { mac_data, }) } + + pub fn name(&self) -> &'static str { + Self::NAME + } + + pub fn size(&self) -> usize { + self.license_header.size() + + (BLOB_TYPE_SIZE + BLOB_LENGTH_SIZE) * 2 // 2 blobs in this structure + + MAC_SIZE + self.encrypted_challenge_response_data.len() + self.encrypted_hwid.len() + } } -#[derive(Debug, PartialEq, FromPrimitive, ToPrimitive)] +#[repr(u16)] +#[derive(Debug, Copy, Clone, PartialEq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum ClientType { Win32 = 0x0100, Win16 = 0x0200, @@ -170,14 +174,37 @@ pub enum ClientType { Other = 0xff00, } -#[derive(Debug, PartialEq, FromPrimitive, ToPrimitive)] +impl ClientType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + +#[repr(u16)] +#[derive(Debug, Copy, Clone, PartialEq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum LicenseDetailLevel { Simple = 1, Moderate = 2, Detail = 3, } +impl LicenseDetailLevel { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u16(self) -> u16 { + self as u16 + } +} + #[derive(Debug, PartialEq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct PlatformChallengeResponseData { pub client_type: ClientType, pub license_detail_level: LicenseDetailLevel, @@ -195,8 +222,8 @@ impl Encode for PlatformChallengeResponseData { ensure_size!(in: dst, size: self.size()); dst.write_u16(RESPONSE_DATA_VERSION); - dst.write_u16(self.client_type.to_u16().unwrap()); - dst.write_u16(self.license_detail_level.to_u16().unwrap()); + dst.write_u16(self.client_type.as_u16()); + dst.write_u16(self.license_detail_level.as_u16()); dst.write_u16(cast_length!("len", self.challenge.len())?); dst.write_slice(&self.challenge); @@ -240,6 +267,7 @@ impl<'de> Decode<'de> for PlatformChallengeResponseData { } #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ClientHardwareIdentification { pub platform_id: u32, pub data: Vec, diff --git a/crates/ironrdp-pdu/src/rdp/server_license/client_platform_challenge_response/test.rs b/crates/ironrdp-pdu/src/rdp/server_license/client_platform_challenge_response/test.rs index ce298e8124..648f44a17f 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/client_platform_challenge_response/test.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/client_platform_challenge_response/test.rs @@ -1,8 +1,9 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; -use crate::rdp::server_license::{LicensePdu, BASIC_SECURITY_HEADER_SIZE}; +use crate::rdp::server_license::{BASIC_SECURITY_HEADER_SIZE, LicensePdu}; const PLATFORM_CHALLENGE_RESPONSE_DATA_BUFFER: [u8; 18] = [ 0x00, 0x01, // version @@ -44,35 +45,37 @@ const DATA_BUFFER: [u8; 16] = [ 0xf1, 0x59, 0x87, 0x3e, 0xc9, 0xd8, 0x98, 0xaf, 0x24, 0x02, 0xf8, 0xf3, 0x29, 0x3a, 0xf0, 0x26, ]; -lazy_static! { - pub(crate) static ref RESPONSE: PlatformChallengeResponseData = PlatformChallengeResponseData { - client_type: ClientType::Win32, - license_detail_level: LicenseDetailLevel::Detail, - challenge: Vec::from(CHALLENGE_BUFFER.as_ref()), - }; - pub(crate) static ref CLIENT_HARDWARE_IDENTIFICATION: ClientHardwareIdentification = ClientHardwareIdentification { +pub(crate) static RESPONSE: LazyLock = LazyLock::new(|| PlatformChallengeResponseData { + client_type: ClientType::Win32, + license_detail_level: LicenseDetailLevel::Detail, + challenge: Vec::from(CHALLENGE_BUFFER.as_ref()), +}); +pub(crate) static CLIENT_HARDWARE_IDENTIFICATION: LazyLock = + LazyLock::new(|| ClientHardwareIdentification { platform_id: HARDWARE_ID, data: Vec::from(DATA_BUFFER.as_ref()), - }; - pub(crate) static ref CLIENT_PLATFORM_CHALLENGE_RESPONSE: LicensePdu = - LicensePdu::ClientPlatformChallengeResponse(ClientPlatformChallengeResponse { - license_header: LicenseHeader { - security_header: BasicSecurityHeader { - flags: BasicSecurityHeaderFlags::LICENSE_PKT, - }, - preamble_message_type: PreambleType::PlatformChallengeResponse, - preamble_flags: PreambleFlags::empty(), - preamble_version: PreambleVersion::V3, - preamble_message_size: (CLIENT_PLATFORM_CHALLENGE_RESPONSE_BUFFER.len() - BASIC_SECURITY_HEADER_SIZE) - as u16, + }); +pub(crate) static CLIENT_PLATFORM_CHALLENGE_RESPONSE: LazyLock = LazyLock::new(|| { + LicensePdu::ClientPlatformChallengeResponse(ClientPlatformChallengeResponse { + license_header: LicenseHeader { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::LICENSE_PKT, }, - encrypted_challenge_response_data: Vec::from(&CLIENT_PLATFORM_CHALLENGE_RESPONSE_BUFFER[12..30]), - encrypted_hwid: Vec::from(&CLIENT_PLATFORM_CHALLENGE_RESPONSE_BUFFER[34..54]), - mac_data: Vec::from( - &CLIENT_PLATFORM_CHALLENGE_RESPONSE_BUFFER[CLIENT_PLATFORM_CHALLENGE_RESPONSE_BUFFER.len() - 16..] - ), - }); -} + preamble_message_type: PreambleType::PlatformChallengeResponse, + preamble_flags: PreambleFlags::empty(), + preamble_version: PreambleVersion::V3, + preamble_message_size: u16::try_from( + CLIENT_PLATFORM_CHALLENGE_RESPONSE_BUFFER.len() - BASIC_SECURITY_HEADER_SIZE, + ) + .expect("can't panic"), + }, + encrypted_challenge_response_data: Vec::from(&CLIENT_PLATFORM_CHALLENGE_RESPONSE_BUFFER[12..30]), + encrypted_hwid: Vec::from(&CLIENT_PLATFORM_CHALLENGE_RESPONSE_BUFFER[34..54]), + mac_data: Vec::from( + &CLIENT_PLATFORM_CHALLENGE_RESPONSE_BUFFER[CLIENT_PLATFORM_CHALLENGE_RESPONSE_BUFFER.len() - 16..], + ), + }) +}); #[test] fn from_buffer_correctly_parses_platform_challenge_response_data() { @@ -165,7 +168,8 @@ fn challenge_response_creates_from_server_challenge_and_encryption_data_correctl preamble_message_type: PreambleType::PlatformChallenge, preamble_flags: PreambleFlags::empty(), preamble_version: PreambleVersion::V3, - preamble_message_size: (encrypted_platform_challenge.len() + mac_data.len() + PREAMBLE_SIZE) as u16, + preamble_message_size: u16::try_from(encrypted_platform_challenge.len() + mac_data.len() + PREAMBLE_SIZE) + .expect("can't panic"), }, encrypted_platform_challenge, mac_data, @@ -200,7 +204,8 @@ fn challenge_response_creates_from_server_challenge_and_encryption_data_correctl let mac_data = crate::rdp::server_license::compute_mac_data( encryption_data.mac_salt_key.as_slice(), [response_data.as_ref(), hardware_id.as_slice()].concat().as_slice(), - ); + ) + .expect("can't panic"); let correct_challenge_response = ClientPlatformChallengeResponse { license_header: LicenseHeader { @@ -210,10 +215,12 @@ fn challenge_response_creates_from_server_challenge_and_encryption_data_correctl preamble_message_type: PreambleType::PlatformChallengeResponse, preamble_flags: PreambleFlags::empty(), preamble_version: PreambleVersion::V3, - preamble_message_size: (PREAMBLE_SIZE + preamble_message_size: u16::try_from( + PREAMBLE_SIZE + (BLOB_TYPE_SIZE + BLOB_LENGTH_SIZE) * 2 // 2 blobs in this structure - + MAC_SIZE + encrypted_challenge_response_data.len() + encrypted_hwid.len()) - as u16, + + MAC_SIZE + encrypted_challenge_response_data.len() + encrypted_hwid.len(), + ) + .expect("can't panic"), }, encrypted_challenge_response_data, encrypted_hwid, diff --git a/crates/ironrdp-pdu/src/rdp/server_license/licensing_error_message.rs b/crates/ironrdp-pdu/src/rdp/server_license/licensing_error_message/mod.rs similarity index 73% rename from crates/ironrdp-pdu/src/rdp/server_license/licensing_error_message.rs rename to crates/ironrdp-pdu/src/rdp/server_license/licensing_error_message/mod.rs index dd2a07b7ee..ffacf42fee 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/licensing_error_message.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/licensing_error_message/mod.rs @@ -2,14 +2,14 @@ mod test; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode as _, DecodeResult, Encode as _, - EncodeResult, ReadCursor, WriteCursor, + Decode as _, DecodeResult, Encode as _, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; -use super::{BlobHeader, BlobType, LicenseHeader, PreambleFlags, PreambleVersion, BLOB_LENGTH_SIZE, BLOB_TYPE_SIZE}; -use crate::rdp::headers::{BasicSecurityHeader, BasicSecurityHeaderFlags, BASIC_SECURITY_HEADER_SIZE}; +use super::{BLOB_LENGTH_SIZE, BLOB_TYPE_SIZE, BlobHeader, BlobType, LicenseHeader, PreambleFlags, PreambleVersion}; +use crate::rdp::headers::{BASIC_SECURITY_HEADER_SIZE, BasicSecurityHeader, BasicSecurityHeaderFlags}; use crate::rdp::server_license::PreambleType; const ERROR_CODE_SIZE: usize = 4; @@ -19,6 +19,7 @@ const STATE_TRANSITION_SIZE: usize = 4; /// /// [2.2.1.12.1.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/f18b6c9f-f3d8-4a0e-8398-f9b153233dca #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct LicensingErrorMessage { pub license_header: LicenseHeader, pub error_code: LicenseErrorCode, @@ -53,16 +54,14 @@ impl LicensingErrorMessage { )?; Ok(this) } -} -impl LicensingErrorMessage { pub fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); self.license_header.encode(dst)?; - dst.write_u32(self.error_code.to_u32().unwrap()); - dst.write_u32(self.state_transition.to_u32().unwrap()); + dst.write_u32(self.error_code.as_u32()); + dst.write_u32(self.state_transition.as_u32()); BlobHeader::new(BlobType::ERROR, self.error_info.len()).encode(dst)?; dst.write_slice(&self.error_info); @@ -70,16 +69,6 @@ impl LicensingErrorMessage { Ok(()) } - pub fn name(&self) -> &'static str { - Self::NAME - } - - pub fn size(&self) -> usize { - self.license_header.size() + Self::FIXED_PART_SIZE + self.error_info.len() + BLOB_LENGTH_SIZE + BLOB_TYPE_SIZE - } -} - -impl LicensingErrorMessage { pub fn decode(license_header: LicenseHeader, src: &mut ReadCursor<'_>) -> DecodeResult { if license_header.preamble_message_type != PreambleType::ErrorAlert { return Err(invalid_field_err!("preambleMessageType", "unexpected preamble type")); @@ -105,9 +94,19 @@ impl LicensingErrorMessage { error_info, }) } + + pub fn name(&self) -> &'static str { + Self::NAME + } + + pub fn size(&self) -> usize { + self.license_header.size() + Self::FIXED_PART_SIZE + self.error_info.len() + BLOB_LENGTH_SIZE + BLOB_TYPE_SIZE + } } -#[derive(Debug, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u32)] +#[derive(Debug, PartialEq, Eq, FromPrimitive, Copy, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum LicenseErrorCode { InvalidServerCertificate = 0x01, NoLicense = 0x02, @@ -120,10 +119,32 @@ pub enum LicenseErrorCode { InvalidFieldLen = 0x0c, } -#[derive(Debug, PartialEq, Eq, FromPrimitive, ToPrimitive)] +impl LicenseErrorCode { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} + +#[repr(u32)] +#[derive(Debug, PartialEq, Eq, FromPrimitive, Copy, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum LicensingStateTransition { TotalAbort = 1, NoTransition = 2, ResetPhaseToStart = 3, ResendLastMessage = 4, } + +impl LicensingStateTransition { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} diff --git a/crates/ironrdp-pdu/src/rdp/server_license/licensing_error_message/test.rs b/crates/ironrdp-pdu/src/rdp/server_license/licensing_error_message/test.rs index 588e6d183e..7ccfc467ae 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/licensing_error_message/test.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/licensing_error_message/test.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; use crate::rdp::server_license::LicensePdu; @@ -10,26 +11,24 @@ const LICENSE_MESSAGE_BUFFER: [u8; 12] = [ 0x07, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, // message ]; -lazy_static! { - pub static ref LICENSING_ERROR_MESSAGE: LicensePdu = { - let mut pdu = LicensingErrorMessage { - license_header: LicenseHeader { - security_header: BasicSecurityHeader { - flags: BasicSecurityHeaderFlags::LICENSE_PKT, - }, - preamble_message_type: PreambleType::ErrorAlert, - preamble_flags: PreambleFlags::empty(), - preamble_version: PreambleVersion::V3, - preamble_message_size: 0, +static LICENSING_ERROR_MESSAGE: LazyLock = LazyLock::new(|| { + let mut pdu = LicensingErrorMessage { + license_header: LicenseHeader { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::LICENSE_PKT, }, - error_code: LicenseErrorCode::StatusValidClient, - state_transition: LicensingStateTransition::NoTransition, - error_info: Vec::new(), - }; - pdu.license_header.preamble_message_size = pdu.size() as u16; - pdu.into() + preamble_message_type: PreambleType::ErrorAlert, + preamble_flags: PreambleFlags::empty(), + preamble_version: PreambleVersion::V3, + preamble_message_size: 0, + }, + error_code: LicenseErrorCode::StatusValidClient, + state_transition: LicensingStateTransition::NoTransition, + error_info: Vec::new(), }; -} + pdu.license_header.preamble_message_size = u16::try_from(pdu.size()).expect("can't panic"); + pdu.into() +}); #[test] fn from_buffer_correctly_parses_licensing_error_message() { diff --git a/crates/ironrdp-pdu/src/rdp/server_license.rs b/crates/ironrdp-pdu/src/rdp/server_license/mod.rs similarity index 56% rename from crates/ironrdp-pdu/src/rdp/server_license.rs rename to crates/ironrdp-pdu/src/rdp/server_license/mod.rs index 2ec3dd6d36..9d2fbec6a1 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/mod.rs @@ -1,18 +1,18 @@ +use core::fmt; use std::io; use bitflags::bitflags; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, invalid_field_err, unsupported_value_err, Decode, DecodeResult, Encode, - EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + invalid_field_err, unsupported_value_err, }; use md5::Digest as _; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; -use thiserror::Error; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; -use crate::rdp::headers::{BasicSecurityHeader, BasicSecurityHeaderFlags, BASIC_SECURITY_HEADER_SIZE}; -pub use crate::rdp::server_license::client_license_info::ClientLicenseInfo; use crate::PduError; +use crate::rdp::headers::{BASIC_SECURITY_HEADER_SIZE, BasicSecurityHeader, BasicSecurityHeaderFlags}; +pub use crate::rdp::server_license::client_license_info::ClientLicenseInfo; #[cfg(test)] mod tests; @@ -30,7 +30,7 @@ pub use self::client_platform_challenge_response::{ ClientHardwareIdentification, ClientPlatformChallengeResponse, PlatformChallengeResponseData, }; pub use self::licensing_error_message::{LicenseErrorCode, LicensingErrorMessage, LicensingStateTransition}; -pub use self::server_license_request::{cert, ProductInfo, Scope, ServerCertificate, ServerLicenseRequest}; +pub use self::server_license_request::{ProductInfo, Scope, ServerCertificate, ServerLicenseRequest, cert}; pub use self::server_platform_challenge::ServerPlatformChallenge; pub use self::server_upgrade_license::{LicenseInformation, ServerUpgradeLicense}; @@ -51,6 +51,7 @@ const KEY_EXCHANGE_ALGORITHM_RSA: u32 = 1; const MAC_SIZE: usize = 16; #[derive(Debug, PartialEq, Eq, Clone, Default)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct LicenseEncryptionData { pub premaster_secret: Vec, pub mac_salt_key: Vec, @@ -58,6 +59,7 @@ pub struct LicenseEncryptionData { } #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct LicenseHeader { pub security_header: BasicSecurityHeader, pub preamble_message_type: PreambleType, @@ -78,9 +80,9 @@ impl Encode for LicenseHeader { self.security_header.encode(dst)?; - let flags_with_version = self.preamble_flags.bits() | self.preamble_version.to_u8().unwrap(); + let flags_with_version = self.preamble_flags.bits() | self.preamble_version.as_u8(); - dst.write_u8(self.preamble_message_type.to_u8().unwrap()); + dst.write_u8(self.preamble_message_type.as_u8()); dst.write_u8(flags_with_version); dst.write_u16(self.preamble_message_size); // msg size @@ -135,7 +137,8 @@ impl<'de> Decode<'de> for LicenseHeader { /// /// [2.2.1.12.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/73170ca2-5f82-4a2d-9d1b-b439f3d8dadc #[repr(u8)] -#[derive(Debug, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, PartialEq, Eq, FromPrimitive, Copy, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum PreambleType { LicenseRequest = 0x01, PlatformChallenge = 0x02, @@ -147,20 +150,44 @@ pub enum PreambleType { ErrorAlert = 0xff, } +impl PreambleType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct PreambleFlags: u8 { const EXTENDED_ERROR_MSG_SUPPORTED = 0x80; } } -#[derive(Debug, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u8)] +#[derive(Debug, PartialEq, Eq, FromPrimitive, Copy, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum PreambleVersion { V2 = 2, // RDP 4.0 V3 = 3, // RDP 5.0, 5.1, 5.2, 6.0, 6.1, 7.0, 7.1, 8.0, 8.1, 10.0, 10.1, 10.2, 10.3, 10.4, and 10.5 } +impl PreambleVersion { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BlobType(u16); impl BlobType { @@ -178,85 +205,175 @@ impl BlobType { pub const CLIENT_MACHINE_NAME_BLOB: Self = Self(0x10); } -#[derive(Debug, Error)] +// FIXME: licensing logic and any code that is not purely about PDU +// encoding/decoding concerns should be moved out of ironrdp-pdu. +#[derive(Debug)] pub enum ServerLicenseError { - #[error("IO error: {0}")] - IOError(#[from] io::Error), - #[error("UTF-8 error: {0}")] - Utf8Error(#[from] std::string::FromUtf8Error), - #[error("invalid preamble field: {0}")] + IOError(io::Error), + Utf8Error(std::string::FromUtf8Error), + DerError(pkcs1::der::Error), + InvalidField(&'static str), InvalidPreamble(String), - #[error("invalid preamble message type field")] InvalidLicenseType, - #[error("invalid error code field")] InvalidErrorCode, - #[error("invalid state transition field")] InvalidStateTransition, - #[error("invalid blob type field")] InvalidBlobType, - #[error("unable to generate random number {0}")] RandomNumberGenerationError(String), - #[error("unable to retrieve public key from the certificate")] UnableToGetPublicKey, - #[error("unable to encrypt RSA public key")] RsaKeyEncryptionError, - #[error("invalid License Request key exchange algorithm value")] InvalidKeyExchangeValue, - #[error("MAC checksum generated over decrypted data does not match the server's checksum")] InvalidMacData, - #[error("invalid platform challenge response data version")] InvalidChallengeResponseDataVersion, - #[error("invalid platform challenge response data client type")] InvalidChallengeResponseDataClientType, - #[error("invalid platform challenge response data license detail level")] InvalidChallengeResponseDataLicenseDetail, - #[error("invalid x509 certificate")] InvalidX509Certificate { source: x509_cert::der::Error, cert_der: Vec, }, - #[error("invalid certificate version")] InvalidCertificateVersion, - #[error("invalid x509 certificates amount")] InvalidX509CertificatesAmount, - #[error("invalid proprietary certificate signature algorithm ID")] InvalidPropCertSignatureAlgorithmId, - #[error("invalid proprietary certificate key algorithm ID")] InvalidPropCertKeyAlgorithmId, - #[error("invalid RSA public key magic")] InvalidRsaPublicKeyMagic, - #[error("invalid RSA public key length")] InvalidRsaPublicKeyLength, - #[error("invalid RSA public key data length")] InvalidRsaPublicKeyDataLength, - #[error("invalid RSA public key bit length")] InvalidRsaPublicKeyBitLength, - #[error("invalid License Header security flags")] InvalidSecurityFlags, - #[error("the server returned unexpected error: {0:?}")] UnexpectedError(LicensingErrorMessage), - #[error("got unexpected license message")] UnexpectedLicenseMessage, - #[error("the server has returned an unexpected error")] UnexpectedServerError(LicensingErrorMessage), - #[error("the server has returned STATUS_VALID_CLIENT (not an error)")] ValidClientStatus(LicensingErrorMessage), - #[error("invalid Key Exchange List field")] InvalidKeyExchangeAlgorithm, - #[error("received invalid company name length (Product Information): {0}")] InvalidCompanyNameLength(u32), - #[error("received invalid product ID length (Product Information): {0}")] InvalidProductIdLength(u32), - #[error("received invalid scope count field: {0}")] InvalidScopeCount(u32), - #[error("received invalid certificate length: {0}")] InvalidCertificateLength(u32), - #[error("blob too small")] BlobTooSmall, - #[error("PDU error: {0}")] Pdu(PduError), } +impl fmt::Display for ServerLicenseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::IOError(e) => write!(f, "IO error: {e}"), + Self::Utf8Error(e) => write!(f, "UTF-8 error: {e}"), + Self::DerError(e) => write!(f, "DER error: {e}"), + Self::InvalidField(name) => write!(f, "invalid `{name}`: out of range integral type conversion"), + Self::InvalidPreamble(s) => write!(f, "invalid preamble field: {s}"), + Self::InvalidLicenseType => f.write_str("invalid preamble message type field"), + Self::InvalidErrorCode => f.write_str("invalid error code field"), + Self::InvalidStateTransition => f.write_str("invalid state transition field"), + Self::InvalidBlobType => f.write_str("invalid blob type field"), + Self::RandomNumberGenerationError(s) => write!(f, "unable to generate random number {s}"), + Self::UnableToGetPublicKey => f.write_str("unable to retrieve public key from the certificate"), + Self::RsaKeyEncryptionError => f.write_str("unable to encrypt RSA public key"), + Self::InvalidKeyExchangeValue => f.write_str("invalid License Request key exchange algorithm value"), + Self::InvalidMacData => { + f.write_str("MAC checksum generated over decrypted data does not match the server's checksum") + } + Self::InvalidChallengeResponseDataVersion => { + f.write_str("invalid platform challenge response data version") + } + Self::InvalidChallengeResponseDataClientType => { + f.write_str("invalid platform challenge response data client type") + } + Self::InvalidChallengeResponseDataLicenseDetail => { + f.write_str("invalid platform challenge response data license detail level") + } + Self::InvalidX509Certificate { .. } => f.write_str("invalid x509 certificate"), + Self::InvalidCertificateVersion => f.write_str("invalid certificate version"), + Self::InvalidX509CertificatesAmount => f.write_str("invalid x509 certificates amount"), + Self::InvalidPropCertSignatureAlgorithmId => { + f.write_str("invalid proprietary certificate signature algorithm ID") + } + Self::InvalidPropCertKeyAlgorithmId => f.write_str("invalid proprietary certificate key algorithm ID"), + Self::InvalidRsaPublicKeyMagic => f.write_str("invalid RSA public key magic"), + Self::InvalidRsaPublicKeyLength => f.write_str("invalid RSA public key length"), + Self::InvalidRsaPublicKeyDataLength => f.write_str("invalid RSA public key data length"), + Self::InvalidRsaPublicKeyBitLength => f.write_str("invalid RSA public key bit length"), + Self::InvalidSecurityFlags => f.write_str("invalid License Header security flags"), + Self::UnexpectedError(msg) => write!(f, "the server returned unexpected error: {msg:?}"), + Self::UnexpectedLicenseMessage => f.write_str("got unexpected license message"), + Self::UnexpectedServerError(_) => f.write_str("the server has returned an unexpected error"), + Self::ValidClientStatus(_) => f.write_str("the server has returned STATUS_VALID_CLIENT (not an error)"), + Self::InvalidKeyExchangeAlgorithm => f.write_str("invalid Key Exchange List field"), + Self::InvalidCompanyNameLength(n) => { + write!(f, "received invalid company name length (Product Information): {n}") + } + Self::InvalidProductIdLength(n) => { + write!(f, "received invalid product ID length (Product Information): {n}") + } + Self::InvalidScopeCount(n) => write!(f, "received invalid scope count field: {n}"), + Self::InvalidCertificateLength(n) => write!(f, "received invalid certificate length: {n}"), + Self::BlobTooSmall => f.write_str("blob too small"), + Self::Pdu(e) => write!(f, "PDU error: {e}"), + } + } +} + +impl core::error::Error for ServerLicenseError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + match self { + Self::IOError(e) => Some(e), + Self::Utf8Error(e) => Some(e), + Self::DerError(e) => Some(e), + Self::InvalidX509Certificate { source, .. } => Some(source), + Self::InvalidField(_) + | Self::InvalidPreamble(_) + | Self::InvalidLicenseType + | Self::InvalidErrorCode + | Self::InvalidStateTransition + | Self::InvalidBlobType + | Self::RandomNumberGenerationError(_) + | Self::UnableToGetPublicKey + | Self::RsaKeyEncryptionError + | Self::InvalidKeyExchangeValue + | Self::InvalidMacData + | Self::InvalidChallengeResponseDataVersion + | Self::InvalidChallengeResponseDataClientType + | Self::InvalidChallengeResponseDataLicenseDetail + | Self::InvalidCertificateVersion + | Self::InvalidX509CertificatesAmount + | Self::InvalidPropCertSignatureAlgorithmId + | Self::InvalidPropCertKeyAlgorithmId + | Self::InvalidRsaPublicKeyMagic + | Self::InvalidRsaPublicKeyLength + | Self::InvalidRsaPublicKeyDataLength + | Self::InvalidRsaPublicKeyBitLength + | Self::InvalidSecurityFlags + | Self::UnexpectedError(_) + | Self::UnexpectedLicenseMessage + | Self::UnexpectedServerError(_) + | Self::ValidClientStatus(_) + | Self::InvalidKeyExchangeAlgorithm + | Self::InvalidCompanyNameLength(_) + | Self::InvalidProductIdLength(_) + | Self::InvalidScopeCount(_) + | Self::InvalidCertificateLength(_) + | Self::BlobTooSmall + | Self::Pdu(_) => None, + } + } +} + +impl From for ServerLicenseError { + fn from(e: io::Error) -> Self { + Self::IOError(e) + } +} + +impl From for ServerLicenseError { + fn from(e: std::string::FromUtf8Error) -> Self { + Self::Utf8Error(e) + } +} + +impl From for ServerLicenseError { + fn from(e: pkcs1::der::Error) -> Self { + Self::DerError(e) + } +} + impl From for ServerLicenseError { fn from(e: PduError) -> Self { Self::Pdu(e) @@ -270,6 +387,7 @@ impl From for ServerLicenseError { } #[derive(Debug, PartialEq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct BlobHeader { pub blob_type: BlobType, pub length: usize, @@ -315,8 +433,10 @@ impl<'de> Decode<'de> for BlobHeader { } } -fn compute_mac_data(mac_salt_key: &[u8], data: &[u8]) -> Vec { - let data_len_buffer = (data.len() as u32).to_le_bytes(); +fn compute_mac_data(mac_salt_key: &[u8], data: &[u8]) -> Result, ServerLicenseError> { + let data_len_buffer = u32::try_from(data.len()) + .map_err(|_| ServerLicenseError::InvalidField("MAC data length"))? + .to_le_bytes(); let pad_one: [u8; 40] = [0x36; 40]; @@ -337,10 +457,11 @@ fn compute_mac_data(mac_salt_key: &[u8], data: &[u8]) -> Vec { .as_slice(), ); - md5.finalize().to_vec() + Ok(md5.finalize().to_vec()) } #[derive(Debug, PartialEq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum LicensePdu { ClientNewLicenseRequest(ClientNewLicenseRequest), ClientLicenseInfo(ClientLicenseInfo), @@ -377,13 +498,13 @@ impl<'de> Decode<'de> for LicensePdu { impl Encode for LicensePdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { match self { - Self::ClientNewLicenseRequest(ref pdu) => pdu.encode(dst), - Self::ClientLicenseInfo(ref pdu) => pdu.encode(dst), - Self::ClientPlatformChallengeResponse(ref pdu) => pdu.encode(dst), - Self::ServerLicenseRequest(ref pdu) => pdu.encode(dst), - Self::ServerPlatformChallenge(ref pdu) => pdu.encode(dst), - Self::ServerUpgradeLicense(ref pdu) => pdu.encode(dst), - Self::LicensingErrorMessage(ref pdu) => pdu.encode(dst), + Self::ClientNewLicenseRequest(pdu) => pdu.encode(dst), + Self::ClientLicenseInfo(pdu) => pdu.encode(dst), + Self::ClientPlatformChallengeResponse(pdu) => pdu.encode(dst), + Self::ServerLicenseRequest(pdu) => pdu.encode(dst), + Self::ServerPlatformChallenge(pdu) => pdu.encode(dst), + Self::ServerUpgradeLicense(pdu) => pdu.encode(dst), + Self::LicensingErrorMessage(pdu) => pdu.encode(dst), } } diff --git a/crates/ironrdp-pdu/src/rdp/server_license/server_license_request/cert.rs b/crates/ironrdp-pdu/src/rdp/server_license/server_license_request/cert.rs index b27e9eed44..8f2a372752 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/server_license_request/cert.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/server_license_request/cert.rs @@ -1,6 +1,6 @@ use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, write_padding, Decode, - DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, read_padding, write_padding, }; use super::{BlobHeader, BlobType, KEY_EXCHANGE_ALGORITHM_RSA}; @@ -19,6 +19,7 @@ const MAX_CERTIFICATE_AMOUNT: usize = 200; const MAX_CERTIFICATE_LEN: usize = 4096; #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum CertificateType { Proprietary(ProprietaryCertificate), X509(X509CertificateChain), @@ -28,6 +29,7 @@ pub enum CertificateType { /// /// [2.2.1.4.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpele/bf2cc9cc-2b01-442e-a288-6ddfa3b80d59 #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct X509CertificateChain { pub certificate_array: Vec>, } @@ -76,20 +78,20 @@ impl<'de> Decode<'de> for X509CertificateChain { return Err(invalid_field_err!("certArrayLen", "invalid x509 certificate amount")); } - let certificate_array: Vec<_> = (0..certificate_count) - .map(|_| { - ensure_size!(in: src, size: 4); - let certificate_len = cast_length!("certLen", src.read_u32())?; - if certificate_len > MAX_CERTIFICATE_LEN { - return Err(invalid_field_err!("certLen", "invalid x509 certificate length")); - } + let certificate_array: Vec<_> = core::iter::repeat_with(|| { + ensure_size!(in: src, size: 4); + let certificate_len = cast_length!("certLen", src.read_u32())?; + if certificate_len > MAX_CERTIFICATE_LEN { + return Err(invalid_field_err!("certLen", "invalid x509 certificate length")); + } - ensure_size!(in: src, size: certificate_len); - let certificate = src.read_slice(certificate_len).into(); + ensure_size!(in: src, size: certificate_len); + let certificate = src.read_slice(certificate_len).into(); - Ok(certificate) - }) - .collect::>()?; + Ok(certificate) + }) + .take(certificate_count) + .collect::>()?; let padding = 8 + 4 * certificate_count; // MSDN: A byte array of the length 8 + 4*NumCertBlobs ensure_size!(in: src, size: padding); @@ -103,6 +105,7 @@ impl<'de> Decode<'de> for X509CertificateChain { /// /// [2.2.1.4.3.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/a37d449a-73ac-4f00-9b9d-56cefc954634 #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ProprietaryCertificate { pub public_key: RsaPublicKey, pub signature: Vec, @@ -171,6 +174,7 @@ impl<'de> Decode<'de> for ProprietaryCertificate { } #[derive(PartialEq, Eq, Debug, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct RsaPublicKey { pub public_exponent: u32, pub modulus: Vec, diff --git a/crates/ironrdp-pdu/src/rdp/server_license/server_license_request.rs b/crates/ironrdp-pdu/src/rdp/server_license/server_license_request/mod.rs similarity index 92% rename from crates/ironrdp-pdu/src/rdp/server_license/server_license_request.rs rename to crates/ironrdp-pdu/src/rdp/server_license/server_license_request/mod.rs index 2fd7948d3a..86e5e7328b 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/server_license_request.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/server_license_request/mod.rs @@ -5,13 +5,13 @@ mod tests; use cert::{CertificateType, ProprietaryCertificate, X509CertificateChain}; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, - ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, }; use super::{ - BlobHeader, BlobType, LicenseHeader, PreambleType, ServerLicenseError, BLOB_LENGTH_SIZE, BLOB_TYPE_SIZE, - KEY_EXCHANGE_ALGORITHM_RSA, RANDOM_NUMBER_SIZE, UTF16_NULL_TERMINATOR_SIZE, UTF8_NULL_TERMINATOR_SIZE, + BLOB_LENGTH_SIZE, BLOB_TYPE_SIZE, BlobHeader, BlobType, KEY_EXCHANGE_ALGORITHM_RSA, LicenseHeader, PreambleType, + RANDOM_NUMBER_SIZE, ServerLicenseError, UTF8_NULL_TERMINATOR_SIZE, UTF16_NULL_TERMINATOR_SIZE, }; use crate::utils; @@ -31,6 +31,7 @@ const RSA_EXCHANGE_ALGORITHM: u32 = 1; /// /// [2.2.2.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpele/e17772e9-9642-4bb6-a2bc-82875dd6da7c #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ServerLicenseRequest { pub license_header: LicenseHeader, pub server_random: Vec, @@ -42,12 +43,6 @@ pub struct ServerLicenseRequest { impl ServerLicenseRequest { const NAME: &'static str = "ServerLicenseRequest"; - pub fn get_public_key(&self) -> Result>, ServerLicenseError> { - self.server_certificate.as_ref().map(|c| c.get_public_key()).transpose() - } -} - -impl ServerLicenseRequest { pub fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); @@ -75,24 +70,6 @@ impl ServerLicenseRequest { Ok(()) } - pub fn name(&self) -> &'static str { - Self::NAME - } - - pub fn size(&self) -> usize { - self.license_header.size() - + RANDOM_NUMBER_SIZE - + self.product_info.size() - + BLOB_LENGTH_SIZE * 2 // KeyExchangeBlob + CertificateBlob - + BLOB_TYPE_SIZE * 2 // KeyExchangeBlob + CertificateBlob - + KEY_EXCHANGE_FIELD_SIZE - + self.server_certificate.as_ref().map(|c| c.size()).unwrap_or(0) - + SCOPE_ARRAY_SIZE_FIELD_SIZE - + self.scope_list.iter().map(|s| s.size()).sum::() - } -} - -impl ServerLicenseRequest { pub fn decode(license_header: LicenseHeader, src: &mut ReadCursor<'_>) -> DecodeResult { if license_header.preamble_message_type != PreambleType::LicenseRequest { return Err(invalid_field_err!("preambleMessageType", "unexpected preamble type")); @@ -132,7 +109,10 @@ impl ServerLicenseRequest { return Err(invalid_field_err!("scopeCount", "invalid scope count")); } - let mut scope_list = Vec::with_capacity(scope_count as usize); + let mut scope_list = Vec::with_capacity( + #[expect(clippy::missing_panics_doc, reason = "unreachable panic (checked integer underflow)")] + usize::try_from(scope_count).expect("scope_count is guaranteed to fit into usize due to the prior check"), + ); for _ in 0..scope_count { scope_list.push(Scope::decode(src)?); @@ -146,9 +126,30 @@ impl ServerLicenseRequest { scope_list, }) } + + pub fn get_public_key(&self) -> Result>, ServerLicenseError> { + self.server_certificate.as_ref().map(|c| c.get_public_key()).transpose() + } + + pub fn name(&self) -> &'static str { + Self::NAME + } + + pub fn size(&self) -> usize { + self.license_header.size() + + RANDOM_NUMBER_SIZE + + self.product_info.size() + + BLOB_LENGTH_SIZE * 2 // KeyExchangeBlob + CertificateBlob + + BLOB_TYPE_SIZE * 2 // KeyExchangeBlob + CertificateBlob + + KEY_EXCHANGE_FIELD_SIZE + + self.server_certificate.as_ref().map(|c| c.size()).unwrap_or(0) + + SCOPE_ARRAY_SIZE_FIELD_SIZE + + self.scope_list.iter().map(|s| s.size()).sum::() + } } #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct Scope(pub String); impl Scope { @@ -203,6 +204,7 @@ impl<'de> Decode<'de> for Scope { /// /// [2.2.1.4.3.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/54e72cc6-3422-404c-a6b4-2486db125342 #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ServerCertificate { pub issued_permanently: bool, pub certificate: CertificateType, @@ -221,11 +223,11 @@ impl ServerCertificate { let public_exponent = certificate.public_key.public_exponent.to_le_bytes(); let rsa_public_key = pkcs1::RsaPublicKey { - modulus: pkcs1::UintRef::new(&certificate.public_key.modulus).unwrap(), - public_exponent: pkcs1::UintRef::new(&public_exponent).unwrap(), + modulus: pkcs1::UintRef::new(&certificate.public_key.modulus)?, + public_exponent: pkcs1::UintRef::new(&public_exponent)?, }; - let public_key = pkcs1::der::Encode::to_der(&rsa_public_key).unwrap(); + let public_key = pkcs1::der::Encode::to_der(&rsa_public_key)?; Ok(public_key) } @@ -315,6 +317,7 @@ impl<'de> Decode<'de> for ServerCertificate { } #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ProductInfo { pub version: u32, pub company_name: String, diff --git a/crates/ironrdp-pdu/src/rdp/server_license/server_license_request/tests.rs b/crates/ironrdp-pdu/src/rdp/server_license/server_license_request/tests.rs index a29e4226e9..c5aa22ab33 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/server_license_request/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/server_license_request/tests.rs @@ -1,7 +1,8 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; -use super::cert::{RsaPublicKey, PROP_CERT_BLOBS_HEADERS_SIZE, PROP_CERT_NO_BLOBS_SIZE, RSA_KEY_SIZE_WITHOUT_MODULUS}; +use super::cert::{PROP_CERT_BLOBS_HEADERS_SIZE, PROP_CERT_NO_BLOBS_SIZE, RSA_KEY_SIZE_WITHOUT_MODULUS, RsaPublicKey}; use super::*; use crate::rdp::headers::{BasicSecurityHeader, BasicSecurityHeaderFlags}; use crate::rdp::server_license::{LicensePdu, PreambleFlags, PreambleVersion}; @@ -210,62 +211,60 @@ const SCOPE_BUFFER: [u8; 18] = [ 0x00, // scope array ]; -lazy_static! { - pub static ref PROPRIETARY_CERTIFICATE: ProprietaryCertificate = ProprietaryCertificate { - public_key: RsaPublicKey { - public_exponent: 0x0001_0001, - modulus: Vec::from(MODULUS.as_ref()), - }, - signature: Vec::from(SIGNATURE.as_ref()), - }; - pub static ref PRODUCT_INFO: ProductInfo = ProductInfo { - version: 0x60000, - company_name: "Microsoft Corporation".to_owned(), - product_id: "A02".to_owned(), - }; - pub static ref PUBLIC_KEY: RsaPublicKey = RsaPublicKey { +static PROPRIETARY_CERTIFICATE: LazyLock = LazyLock::new(|| ProprietaryCertificate { + public_key: RsaPublicKey { public_exponent: 0x0001_0001, modulus: Vec::from(MODULUS.as_ref()), - }; - pub static ref SERVER_LICENSE_REQUEST: LicensePdu = { - let mut req = ServerLicenseRequest { - license_header: LicenseHeader { - security_header: BasicSecurityHeader { - flags: BasicSecurityHeaderFlags::LICENSE_PKT, - }, - preamble_message_type: PreambleType::LicenseRequest, - preamble_flags: PreambleFlags::empty(), - preamble_version: PreambleVersion::V3, - preamble_message_size: 0, - }, - server_random: Vec::from(SERVER_RANDOM_BUFFER.as_ref()), - product_info: ProductInfo { - version: 0x60000, - company_name: "Microsoft Corporation".to_owned(), - product_id: "A02".to_owned(), + }, + signature: Vec::from(SIGNATURE.as_ref()), +}); +static PRODUCT_INFO: LazyLock = LazyLock::new(|| ProductInfo { + version: 0x60000, + company_name: "Microsoft Corporation".to_owned(), + product_id: "A02".to_owned(), +}); +static PUBLIC_KEY: LazyLock = LazyLock::new(|| RsaPublicKey { + public_exponent: 0x0001_0001, + modulus: Vec::from(MODULUS.as_ref()), +}); +static SERVER_LICENSE_REQUEST: LazyLock = LazyLock::new(|| { + let mut req = ServerLicenseRequest { + license_header: LicenseHeader { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::LICENSE_PKT, }, - server_certificate: Some(ServerCertificate { - issued_permanently: true, - certificate: CertificateType::X509(X509CertificateChain { - certificate_array: vec![Vec::from(CERT_1_BUFFER.as_ref()), Vec::from(CERT_2_BUFFER.as_ref())], - }), + preamble_message_type: PreambleType::LicenseRequest, + preamble_flags: PreambleFlags::empty(), + preamble_version: PreambleVersion::V3, + preamble_message_size: 0, + }, + server_random: Vec::from(SERVER_RANDOM_BUFFER.as_ref()), + product_info: ProductInfo { + version: 0x60000, + company_name: "Microsoft Corporation".to_owned(), + product_id: "A02".to_owned(), + }, + server_certificate: Some(ServerCertificate { + issued_permanently: true, + certificate: CertificateType::X509(X509CertificateChain { + certificate_array: vec![Vec::from(CERT_1_BUFFER.as_ref()), Vec::from(CERT_2_BUFFER.as_ref())], }), - scope_list: vec![Scope(String::from("microsoft.com"))], - }; - req.license_header.preamble_message_size = req.size() as u16; - req.into() - }; - pub static ref X509_CERTIFICATE: ServerCertificate = ServerCertificate { - issued_permanently: true, - certificate: CertificateType::X509(X509CertificateChain { - certificate_array: vec![Vec::from(CERT_1_BUFFER.as_ref()), Vec::from(CERT_2_BUFFER.as_ref()),], }), + scope_list: vec![Scope(String::from("microsoft.com"))], }; - pub static ref SCOPE: Scope = Scope(String::from("microsoft.com")); - static ref CERT_CHAIN: X509CertificateChain = X509CertificateChain { - certificate_array: vec![Vec::from(CERT_1_BUFFER.as_ref()), Vec::from(CERT_2_BUFFER.as_ref()),], - }; -} + req.license_header.preamble_message_size = u16::try_from(req.size()).expect("can't panic"); + req.into() +}); +static X509_CERTIFICATE: LazyLock = LazyLock::new(|| ServerCertificate { + issued_permanently: true, + certificate: CertificateType::X509(X509CertificateChain { + certificate_array: vec![Vec::from(CERT_1_BUFFER.as_ref()), Vec::from(CERT_2_BUFFER.as_ref())], + }), +}); +static SCOPE: LazyLock = LazyLock::new(|| Scope(String::from("microsoft.com"))); +static CERT_CHAIN: LazyLock = LazyLock::new(|| X509CertificateChain { + certificate_array: vec![Vec::from(CERT_1_BUFFER.as_ref()), Vec::from(CERT_2_BUFFER.as_ref())], +}); #[test] fn from_buffer_correctly_parses_server_license_request() { @@ -323,7 +322,7 @@ fn from_buffer_correctly_parses_server_license_request_no_certificate() { server_certificate: None, scope_list: vec![Scope(String::from("microsoft.com"))], }; - request.license_header.preamble_message_size = request.size() as u16; + request.license_header.preamble_message_size = u16::try_from(request.size()).expect("can't panic"); let request: LicensePdu = request.into(); assert_eq!(request, decode(&request_buffer).unwrap()); @@ -370,7 +369,7 @@ fn to_buffer_correctly_serializes_server_license_request() { }), scope_list: vec![Scope(String::from("microsoft.com"))], }; - request.license_header.preamble_message_size = request.size() as u16; + request.license_header.preamble_message_size = u16::try_from(request.size()).unwrap(); let request: LicensePdu = request.into(); let serialized_request = encode_vec(&request).unwrap(); diff --git a/crates/ironrdp-pdu/src/rdp/server_license/server_platform_challenge.rs b/crates/ironrdp-pdu/src/rdp/server_license/server_platform_challenge/mod.rs similarity index 88% rename from crates/ironrdp-pdu/src/rdp/server_license/server_platform_challenge.rs rename to crates/ironrdp-pdu/src/rdp/server_license/server_platform_challenge/mod.rs index eae89b9058..0073959060 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/server_platform_challenge.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/server_platform_challenge/mod.rs @@ -2,10 +2,10 @@ mod test; use ironrdp_core::{ - ensure_size, invalid_field_err, Decode as _, DecodeResult, Encode as _, EncodeResult, ReadCursor, WriteCursor, + Decode as _, DecodeResult, Encode as _, EncodeResult, ReadCursor, WriteCursor, ensure_size, invalid_field_err, }; -use super::{BlobHeader, BlobType, LicenseHeader, PreambleType, BLOB_LENGTH_SIZE, BLOB_TYPE_SIZE, MAC_SIZE}; +use super::{BLOB_LENGTH_SIZE, BLOB_TYPE_SIZE, BlobHeader, BlobType, LicenseHeader, MAC_SIZE, PreambleType}; const CONNECT_FLAGS_FIELD_SIZE: usize = 4; @@ -13,6 +13,7 @@ const CONNECT_FLAGS_FIELD_SIZE: usize = 4; /// /// [2.2.2.4]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpele/41e129ad-0f35-43ad-a399-1b10e7d007a9 #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ServerPlatformChallenge { pub license_header: LicenseHeader, pub encrypted_platform_challenge: Vec, @@ -23,9 +24,7 @@ impl ServerPlatformChallenge { const NAME: &'static str = "ServerPlatformChallenge"; const FIXED_PART_SIZE: usize = CONNECT_FLAGS_FIELD_SIZE + MAC_SIZE + BLOB_LENGTH_SIZE + BLOB_TYPE_SIZE; -} -impl ServerPlatformChallenge { pub fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); @@ -38,16 +37,6 @@ impl ServerPlatformChallenge { Ok(()) } - pub fn name(&self) -> &'static str { - Self::NAME - } - - pub fn size(&self) -> usize { - Self::FIXED_PART_SIZE + self.license_header.size() + self.encrypted_platform_challenge.len() - } -} - -impl ServerPlatformChallenge { pub fn decode(license_header: LicenseHeader, src: &mut ReadCursor<'_>) -> DecodeResult { if license_header.preamble_message_type != PreambleType::PlatformChallenge { return Err(invalid_field_err!("preambleMessageType", "unexpected preamble type")); @@ -67,4 +56,12 @@ impl ServerPlatformChallenge { mac_data, }) } + + pub fn name(&self) -> &'static str { + Self::NAME + } + + pub fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.license_header.size() + self.encrypted_platform_challenge.len() + } } diff --git a/crates/ironrdp-pdu/src/rdp/server_license/server_platform_challenge/test.rs b/crates/ironrdp-pdu/src/rdp/server_license/server_platform_challenge/test.rs index ac61d2600d..1096476c66 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/server_platform_challenge/test.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/server_platform_challenge/test.rs @@ -1,10 +1,11 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; use crate::rdp::server_license::{ - BasicSecurityHeader, BasicSecurityHeaderFlags, LicensePdu, PreambleFlags, PreambleVersion, - BASIC_SECURITY_HEADER_SIZE, + BASIC_SECURITY_HEADER_SIZE, BasicSecurityHeader, BasicSecurityHeaderFlags, LicensePdu, PreambleFlags, + PreambleVersion, }; const PLATFORM_CHALLENGE_BUFFER: [u8; 42] = [ @@ -26,8 +27,8 @@ const MAC_DATA_BUFFER: [u8; MAC_SIZE] = [ 0x38, 0x23, 0x62, 0x5d, 0x10, 0x8b, 0x93, 0xc3, 0xf1, 0xe4, 0x67, 0x1f, 0x4a, 0xb6, 0x00, 0x0a, // mac data ]; -lazy_static! { - pub static ref PLATFORM_CHALLENGE: LicensePdu = ServerPlatformChallenge { +static PLATFORM_CHALLENGE: LazyLock = LazyLock::new(|| { + ServerPlatformChallenge { license_header: LicenseHeader { security_header: BasicSecurityHeader { flags: BasicSecurityHeaderFlags::LICENSE_PKT, @@ -35,13 +36,14 @@ lazy_static! { preamble_message_type: PreambleType::PlatformChallenge, preamble_flags: PreambleFlags::empty(), preamble_version: PreambleVersion::V3, - preamble_message_size: (PLATFORM_CHALLENGE_BUFFER.len() - BASIC_SECURITY_HEADER_SIZE) as u16, + preamble_message_size: u16::try_from(PLATFORM_CHALLENGE_BUFFER.len() - BASIC_SECURITY_HEADER_SIZE) + .expect("can't panic"), }, encrypted_platform_challenge: Vec::from(CHALLENGE_BUFFER.as_ref()), mac_data: Vec::from(MAC_DATA_BUFFER.as_ref()), } - .into(); -} + .into() +}); #[test] fn from_buffer_correctly_parses_server_platform_challenge() { diff --git a/crates/ironrdp-pdu/src/rdp/server_license/server_upgrade_license.rs b/crates/ironrdp-pdu/src/rdp/server_license/server_upgrade_license/mod.rs similarity index 92% rename from crates/ironrdp-pdu/src/rdp/server_license/server_upgrade_license.rs rename to crates/ironrdp-pdu/src/rdp/server_license/server_upgrade_license/mod.rs index 6504f86d89..bc1b4f3510 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/server_upgrade_license.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/server_upgrade_license/mod.rs @@ -2,13 +2,13 @@ mod tests; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, - ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, }; use super::{ - BlobHeader, BlobType, LicenseEncryptionData, LicenseHeader, PreambleType, ServerLicenseError, BLOB_LENGTH_SIZE, - BLOB_TYPE_SIZE, MAC_SIZE, UTF16_NULL_TERMINATOR_SIZE, UTF8_NULL_TERMINATOR_SIZE, + BLOB_LENGTH_SIZE, BLOB_TYPE_SIZE, BlobHeader, BlobType, LicenseEncryptionData, LicenseHeader, MAC_SIZE, + PreambleType, ServerLicenseError, UTF8_NULL_TERMINATOR_SIZE, UTF16_NULL_TERMINATOR_SIZE, }; use crate::crypto::rc4::Rc4; use crate::utils; @@ -20,41 +20,16 @@ const LICENSE_INFO_STATIC_FIELDS_SIZE: usize = 20; /// /// [2.2.2.6]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpele/e8339fbd-1fe3-42c2-a599-27c04407166d #[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ServerUpgradeLicense { pub license_header: LicenseHeader, pub encrypted_license_info: Vec, pub mac_data: Vec, } -impl ServerUpgradeLicense { - pub fn verify_server_license(&self, encryption_data: &LicenseEncryptionData) -> Result<(), ServerLicenseError> { - let decrypted_license_info = self.decrypted_license_info(encryption_data); - let mac_data = - super::compute_mac_data(encryption_data.mac_salt_key.as_slice(), decrypted_license_info.as_ref()); - - if mac_data != self.mac_data { - return Err(ServerLicenseError::InvalidMacData); - } - - Ok(()) - } - - pub fn new_license_info(&self, encryption_data: &LicenseEncryptionData) -> DecodeResult { - let data = self.decrypted_license_info(encryption_data); - LicenseInformation::decode(&mut ReadCursor::new(&data)) - } - - fn decrypted_license_info(&self, encryption_data: &LicenseEncryptionData) -> Vec { - let mut rc4 = Rc4::new(encryption_data.license_key.as_slice()); - rc4.process(self.encrypted_license_info.as_slice()) - } -} - impl ServerUpgradeLicense { const NAME: &'static str = "ServerUpgradeLicense"; -} -impl ServerUpgradeLicense { pub fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); @@ -66,16 +41,6 @@ impl ServerUpgradeLicense { Ok(()) } - pub fn name(&self) -> &'static str { - Self::NAME - } - - pub fn size(&self) -> usize { - self.license_header.size() + BLOB_LENGTH_SIZE + BLOB_TYPE_SIZE + self.encrypted_license_info.len() + MAC_SIZE - } -} - -impl ServerUpgradeLicense { pub fn decode(license_header: LicenseHeader, src: &mut ReadCursor<'_>) -> DecodeResult { if license_header.preamble_message_type != PreambleType::UpgradeLicense && license_header.preamble_message_type != PreambleType::NewLicense @@ -101,9 +66,40 @@ impl ServerUpgradeLicense { mac_data, }) } + + pub fn verify_server_license(&self, encryption_data: &LicenseEncryptionData) -> Result<(), ServerLicenseError> { + let decrypted_license_info = self.decrypted_license_info(encryption_data); + let mac_data = + super::compute_mac_data(encryption_data.mac_salt_key.as_slice(), decrypted_license_info.as_ref())?; + + if mac_data != self.mac_data { + return Err(ServerLicenseError::InvalidMacData); + } + + Ok(()) + } + + pub fn new_license_info(&self, encryption_data: &LicenseEncryptionData) -> DecodeResult { + let data = self.decrypted_license_info(encryption_data); + LicenseInformation::decode(&mut ReadCursor::new(&data)) + } + + fn decrypted_license_info(&self, encryption_data: &LicenseEncryptionData) -> Vec { + let mut rc4 = Rc4::new(encryption_data.license_key.as_slice()); + rc4.process(self.encrypted_license_info.as_slice()) + } + + pub fn name(&self) -> &'static str { + Self::NAME + } + + pub fn size(&self) -> usize { + self.license_header.size() + BLOB_LENGTH_SIZE + BLOB_TYPE_SIZE + self.encrypted_license_info.len() + MAC_SIZE + } } #[derive(Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct LicenseInformation { pub version: u32, pub scope: String, diff --git a/crates/ironrdp-pdu/src/rdp/server_license/server_upgrade_license/tests.rs b/crates/ironrdp-pdu/src/rdp/server_license/server_upgrade_license/tests.rs index 6aa58641d0..1a112dd994 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/server_upgrade_license/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/server_upgrade_license/tests.rs @@ -1,10 +1,11 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; use crate::rdp::server_license::{ - BasicSecurityHeader, BasicSecurityHeaderFlags, LicensePdu, PreambleFlags, PreambleVersion, - BASIC_SECURITY_HEADER_SIZE, PREAMBLE_SIZE, + BASIC_SECURITY_HEADER_SIZE, BasicSecurityHeader, BasicSecurityHeaderFlags, LicensePdu, PREAMBLE_SIZE, + PreambleFlags, PreambleVersion, }; const SERVER_UPGRADE_LICENSE_BUFFER: [u8; 2059] = [ @@ -244,15 +245,15 @@ const NEW_LICENSE_INFORMATION_BUFFER: [u8; 2031] = [ 0xb8, 0x1b, 0xb9, 0xcd, 0xfb, 0x31, 0x00, // license info ]; -lazy_static! { - pub static ref NEW_LICENSE_INFORMATION: LicenseInformation = LicenseInformation { - version: 0x0006_0000, - scope: "microsoft.com".to_owned(), - company_name: "Microsoft Corporation".to_owned(), - product_id: "A02".to_owned(), - license_info: Vec::from(&NEW_LICENSE_INFORMATION_BUFFER[NEW_LICENSE_INFORMATION_BUFFER.len() - 0x0799..]), - }; - pub static ref SERVER_UPGRADE_LICENSE: LicensePdu = ServerUpgradeLicense { +static NEW_LICENSE_INFORMATION: LazyLock = LazyLock::new(|| LicenseInformation { + version: 0x0006_0000, + scope: "microsoft.com".to_owned(), + company_name: "Microsoft Corporation".to_owned(), + product_id: "A02".to_owned(), + license_info: Vec::from(&NEW_LICENSE_INFORMATION_BUFFER[NEW_LICENSE_INFORMATION_BUFFER.len() - 0x0799..]), +}); +static SERVER_UPGRADE_LICENSE: LazyLock = LazyLock::new(|| { + ServerUpgradeLicense { license_header: LicenseHeader { security_header: BasicSecurityHeader { flags: BasicSecurityHeaderFlags::LICENSE_PKT, @@ -260,15 +261,16 @@ lazy_static! { preamble_message_type: PreambleType::NewLicense, preamble_flags: PreambleFlags::empty(), preamble_version: PreambleVersion::V3, - preamble_message_size: (SERVER_UPGRADE_LICENSE_BUFFER.len() - BASIC_SECURITY_HEADER_SIZE) as u16, + preamble_message_size: u16::try_from(SERVER_UPGRADE_LICENSE_BUFFER.len() - BASIC_SECURITY_HEADER_SIZE) + .expect("buffer size is too large"), }, encrypted_license_info: Vec::from( - &SERVER_UPGRADE_LICENSE_BUFFER[12..SERVER_UPGRADE_LICENSE_BUFFER.len() - MAC_SIZE] + &SERVER_UPGRADE_LICENSE_BUFFER[12..SERVER_UPGRADE_LICENSE_BUFFER.len() - MAC_SIZE], ), mac_data: Vec::from(MAC_DATA.as_ref()), } - .into(); -} + .into() +}); #[test] fn from_buffer_correctly_parses_new_license_information() { @@ -618,11 +620,10 @@ fn upgrade_license_verifies_correctly() { preamble_message_type: PreambleType::NewLicense, preamble_flags: PreambleFlags::empty(), preamble_version: PreambleVersion::V3, - preamble_message_size: (PREAMBLE_SIZE - + BLOB_LENGTH_SIZE - + BLOB_TYPE_SIZE - + encrypted_license_info.len() - + MAC_SIZE) as u16, + preamble_message_size: u16::try_from( + PREAMBLE_SIZE + BLOB_LENGTH_SIZE + BLOB_TYPE_SIZE + encrypted_license_info.len() + MAC_SIZE, + ) + .expect("can't panic"), }, encrypted_license_info, mac_data, diff --git a/crates/ironrdp-pdu/src/rdp/server_license/tests.rs b/crates/ironrdp-pdu/src/rdp/server_license/tests.rs index 27e12c61ad..84bbb408fa 100644 --- a/crates/ironrdp-pdu/src/rdp/server_license/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/server_license/tests.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; @@ -35,17 +36,15 @@ const STATUS_VALID_CLIENT_BUFFER: [u8; 20] = [ 0xff, 0x03, 0x10, 0x00, 0x07, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, ]; -lazy_static! { - pub static ref LICENSE_HEADER: LicenseHeader = LicenseHeader { - security_header: BasicSecurityHeader { - flags: BasicSecurityHeaderFlags::LICENSE_PKT, - }, - preamble_message_type: PreambleType::ErrorAlert, - preamble_flags: PreambleFlags::empty(), - preamble_version: PreambleVersion::V3, - preamble_message_size: 0x10, - }; -} +static LICENSE_HEADER: LazyLock = LazyLock::new(|| LicenseHeader { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::LICENSE_PKT, + }, + preamble_message_type: PreambleType::ErrorAlert, + preamble_flags: PreambleFlags::empty(), + preamble_version: PreambleVersion::V3, + preamble_message_size: 0x10, +}); #[test] fn read_blob_header_handles_wrong_type_correctly() { @@ -106,7 +105,7 @@ fn mac_data_computes_correctly() { let decrypted_server_challenge: [u8; 10] = [0x54, 0x0, 0x45, 0x0, 0x53, 0x0, 0x54, 0x0, 0x0, 0x0]; assert_eq!( - compute_mac_data(mac_salt_key.as_ref(), decrypted_server_challenge.as_ref()), + compute_mac_data(mac_salt_key.as_ref(), decrypted_server_challenge.as_ref()).unwrap(), server_mac_data.as_ref() ); } diff --git a/crates/ironrdp-pdu/src/rdp/session_info/logon_extended.rs b/crates/ironrdp-pdu/src/rdp/session_info/logon_extended.rs index b9fbb7a665..b9a266de8b 100644 --- a/crates/ironrdp-pdu/src/rdp/session_info/logon_extended.rs +++ b/crates/ironrdp-pdu/src/rdp/session_info/logon_extended.rs @@ -1,10 +1,10 @@ use bitflags::bitflags; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, Decode, DecodeResult, Encode, - EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, read_padding, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; const LOGON_EX_LENGTH_FIELD_SIZE: usize = 2; const LOGON_EX_FLAGS_FIELD_SIZE: usize = 4; @@ -18,6 +18,7 @@ const AUTO_RECONNECT_RANDOM_BITS_SIZE: usize = 16; const LOGON_ERRORS_INFO_SIZE: usize = 8; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct LogonInfoExtended { pub present_fields_flags: LogonExFlags, pub auto_reconnect: Option, @@ -71,7 +72,7 @@ impl<'de> Decode<'de> for LogonInfoExtended { ensure_fixed_part_size!(in: src); let _self_length = src.read_u16(); - let present_fields_flags = LogonExFlags::from_bits_truncate(src.read_u32()); + let present_fields_flags = LogonExFlags::from_bits_retain(src.read_u32()); let auto_reconnect = if present_fields_flags.contains(LogonExFlags::AUTO_RECONNECT_COOKIE) { Some(ServerAutoReconnect::decode(src)?) @@ -97,6 +98,7 @@ impl<'de> Decode<'de> for LogonInfoExtended { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ServerAutoReconnect { pub logon_id: u32, pub random_bits: [u8; AUTO_RECONNECT_RANDOM_BITS_SIZE], @@ -112,8 +114,8 @@ impl Encode for ServerAutoReconnect { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u32(AUTO_RECONNECT_PACKET_SIZE as u32); - dst.write_u32(AUTO_RECONNECT_PACKET_SIZE as u32); + dst.write_u32(u32::try_from(AUTO_RECONNECT_PACKET_SIZE).expect("AUTO_RECONNECT_PACKET_SIZE fits into u32")); + dst.write_u32(u32::try_from(AUTO_RECONNECT_PACKET_SIZE).expect("AUTO_RECONNECT_PACKET_SIZE fits into u32")); dst.write_u32(AUTO_RECONNECT_VERSION_1); dst.write_u32(self.logon_id); dst.write_slice(self.random_bits.as_ref()); @@ -136,7 +138,8 @@ impl<'de> Decode<'de> for ServerAutoReconnect { let _data_length = src.read_u32(); let packet_length = src.read_u32(); - if packet_length != AUTO_RECONNECT_PACKET_SIZE as u32 { + if packet_length != u32::try_from(AUTO_RECONNECT_PACKET_SIZE).expect("AUTO_RECONNECT_PACKET_SIZE fits into u32") + { return Err(invalid_field_err!("packetLen", "invalid auto-reconnect packet size")); } @@ -156,6 +159,7 @@ impl<'de> Decode<'de> for ServerAutoReconnect { /// /// [Doc](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/845eb789-6edf-453a-8b0e-c976823d1f72) #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct LogonErrorsInfo { pub error_type: LogonErrorNotificationType, pub error_data: LogonErrorNotificationData, @@ -171,8 +175,8 @@ impl Encode for LogonErrorsInfo { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u32(LOGON_ERRORS_INFO_SIZE as u32); - dst.write_u32(self.error_type.to_u32().unwrap()); + dst.write_u32(u32::try_from(LOGON_ERRORS_INFO_SIZE).expect("LOGON_ERRORS_INFO_SIZE fits into u32")); + dst.write_u32(self.error_type.as_u32()); dst.write_u32(self.error_data.to_u32()); Ok(()) @@ -206,14 +210,18 @@ impl<'de> Decode<'de> for LogonErrorsInfo { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct LogonExFlags: u32 { const AUTO_RECONNECT_COOKIE = 0x0000_0001; const LOGON_ERRORS = 0x0000_0002; + + const _ = !0; } } #[repr(u32)] -#[derive(Debug, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum LogonErrorNotificationType { SessionBusyOptions = 0xFFFF_FFF8, DisconnectRefused = 0xFFFF_FFF9, @@ -225,8 +233,19 @@ pub enum LogonErrorNotificationType { AccessDenied = 0xFFFF_FFFF, } +impl LogonErrorNotificationType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} + #[repr(u32)] -#[derive(Debug, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum LogonErrorNotificationDataErrorCode { FailedBadPassword = 0x0000_0000, FailedUpdatePassword = 0x0000_0001, @@ -234,7 +253,18 @@ pub enum LogonErrorNotificationDataErrorCode { Warning = 0x0000_0003, } +impl LogonErrorNotificationDataErrorCode { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} + #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum LogonErrorNotificationData { ErrorCode(LogonErrorNotificationDataErrorCode), SessionId(u32), @@ -243,7 +273,7 @@ pub enum LogonErrorNotificationData { impl LogonErrorNotificationData { pub fn to_u32(&self) -> u32 { match self { - LogonErrorNotificationData::ErrorCode(code) => code.to_u32().unwrap(), + LogonErrorNotificationData::ErrorCode(code) => code.as_u32(), LogonErrorNotificationData::SessionId(id) => *id, } } diff --git a/crates/ironrdp-pdu/src/rdp/session_info/logon_info.rs b/crates/ironrdp-pdu/src/rdp/session_info/logon_info.rs index 8555a5fe2b..b93109d540 100644 --- a/crates/ironrdp-pdu/src/rdp/session_info/logon_info.rs +++ b/crates/ironrdp-pdu/src/rdp/session_info/logon_info.rs @@ -1,6 +1,6 @@ use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, write_padding, Decode, - DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, + ensure_size, invalid_field_err, read_padding, write_padding, }; use crate::utils; @@ -18,6 +18,7 @@ const DOMAIN_NAME_SIZE_V2: usize = 52; const USER_NAME_SIZE_V2: usize = 512; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct LogonInfoVersion1 { pub logon_info: LogonInfo, } @@ -96,6 +97,7 @@ impl<'de> Decode<'de> for LogonInfoVersion1 { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct LogonInfoVersion2 { pub logon_info: LogonInfo, } @@ -111,7 +113,7 @@ impl Encode for LogonInfoVersion2 { ensure_size!(in: dst, size: self.size()); dst.write_u16(SAVE_SESSION_PDU_VERSION_ONE); - dst.write_u32(LOGON_INFO_V2_SIZE as u32); + dst.write_u32(u32::try_from(LOGON_INFO_V2_SIZE).expect("LOGON_INFO_V2_SIZE fits into u32")); dst.write_u32(self.logon_info.session_id); dst.write_u32(cast_length!( "domainNameSize", @@ -189,6 +191,7 @@ impl<'de> Decode<'de> for LogonInfoVersion2 { } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct LogonInfo { pub session_id: u32, pub user_name: String, diff --git a/crates/ironrdp-pdu/src/rdp/session_info.rs b/crates/ironrdp-pdu/src/rdp/session_info/mod.rs similarity index 69% rename from crates/ironrdp-pdu/src/rdp/session_info.rs rename to crates/ironrdp-pdu/src/rdp/session_info/mod.rs index bb347801f0..9dcc2573a7 100644 --- a/crates/ironrdp-pdu/src/rdp/session_info.rs +++ b/crates/ironrdp-pdu/src/rdp/session_info/mod.rs @@ -1,14 +1,9 @@ -use std::io; - use ironrdp_core::{ - ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, write_padding, Decode, DecodeResult, Encode, - EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, + invalid_field_err, read_padding, write_padding, }; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; -use thiserror::Error; - -use crate::PduError; +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; #[cfg(test)] mod tests; @@ -26,6 +21,7 @@ const INFO_TYPE_FIELD_SIZE: usize = 4; const PLAIN_NOTIFY_PADDING_SIZE: usize = 576; #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SaveSessionInfoPdu { pub info_type: InfoType, pub info_data: InfoData, @@ -41,7 +37,7 @@ impl Encode for SaveSessionInfoPdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_fixed_part_size!(in: dst); - dst.write_u32(self.info_type.to_u32().unwrap()); + dst.write_u32(self.info_type.as_u32()); match self.info_data { InfoData::LogonInfoV1(ref info_v1) => { info_v1.encode(dst)?; @@ -101,7 +97,8 @@ impl<'de> Decode<'de> for SaveSessionInfoPdu { } #[repr(u32)] -#[derive(Debug, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum InfoType { Logon = 0x0000_0000, LogonLong = 0x0000_0001, @@ -109,42 +106,21 @@ pub enum InfoType { LogonExtended = 0x0000_0003, } +impl InfoType { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u32(self) -> u32 { + self as u32 + } +} + #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum InfoData { LogonInfoV1(LogonInfoVersion1), LogonInfoV2(LogonInfoVersion2), PlainNotify, LogonExtended(LogonInfoExtended), } - -#[derive(Debug, Error)] -pub enum SessionError { - #[error("IO error")] - IOError(#[from] io::Error), - #[error("invalid save session info type value")] - InvalidSaveSessionInfoType, - #[error("invalid domain name size value")] - InvalidDomainNameSize, - #[error("invalid user name size value")] - InvalidUserNameSize, - #[error("invalid logon version value")] - InvalidLogonVersion2, - #[error("invalid logon info version2 size value")] - InvalidLogonVersion2Size, - #[error("invalid server auto-reconnect packet size value")] - InvalidAutoReconnectPacketSize, - #[error("invalid server auto-reconnect version")] - InvalidAutoReconnectVersion, - #[error("invalid logon error type value")] - InvalidLogonErrorType, - #[error("invalid logon error data value")] - InvalidLogonErrorData, - #[error("PDU error: {0}")] - Pdu(PduError), -} - -impl From for SessionError { - fn from(e: PduError) -> Self { - Self::Pdu(e) - } -} diff --git a/crates/ironrdp-pdu/src/rdp/session_info/tests.rs b/crates/ironrdp-pdu/src/rdp/session_info/tests.rs index b230f6d288..96dac776df 100644 --- a/crates/ironrdp-pdu/src/rdp/session_info/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/session_info/tests.rs @@ -1,5 +1,6 @@ -use ironrdp_core::{decode, encode_vec, DecodeErrorKind}; -use lazy_static::lazy_static; +use std::sync::LazyLock; + +use ironrdp_core::{DecodeErrorKind, decode, encode_vec}; use super::*; @@ -234,39 +235,37 @@ const DOMAIN_NAME: &str = "NTDEV"; const USER_NAME: &str = "eltons"; const SESSION_ID: u32 = 0x02; -lazy_static! { - static ref LOGON_INFO_V1: LogonInfoVersion1 = LogonInfoVersion1 { - logon_info: LogonInfo { - domain_name: DOMAIN_NAME.to_owned(), - user_name: USER_NAME.to_owned(), - session_id: SESSION_ID, - }, - }; - static ref LOGON_INFO_V2: LogonInfoVersion2 = LogonInfoVersion2 { - logon_info: LogonInfo { - domain_name: DOMAIN_NAME.to_owned(), - user_name: USER_NAME.to_owned(), - session_id: SESSION_ID, - }, - }; - static ref LOGON_EXTENDED: LogonInfoExtended = LogonInfoExtended { - present_fields_flags: LogonExFlags::AUTO_RECONNECT_COOKIE | LogonExFlags::LOGON_ERRORS, - auto_reconnect: Some(ServerAutoReconnect { - logon_id: SESSION_ID, - random_bits: [ - 0xa8, 0x02, 0xe7, 0x25, 0xe2, 0x4c, 0x82, 0xb7, 0x52, 0xa5, 0x53, 0x50, 0x34, 0x98, 0xa1, 0xa8 - ], - }), - errors_info: Some(LogonErrorsInfo { - error_type: LogonErrorNotificationType::NoPermission, - error_data: LogonErrorNotificationData::ErrorCode(LogonErrorNotificationDataErrorCode::FailedOther), - }), - }; - static ref SESSION_PLAIN_NOTIFY: SaveSessionInfoPdu = SaveSessionInfoPdu { - info_type: InfoType::PlainNotify, - info_data: InfoData::PlainNotify, - }; -} +static LOGON_INFO_V1: LazyLock = LazyLock::new(|| LogonInfoVersion1 { + logon_info: LogonInfo { + domain_name: DOMAIN_NAME.to_owned(), + user_name: USER_NAME.to_owned(), + session_id: SESSION_ID, + }, +}); +static LOGON_INFO_V2: LazyLock = LazyLock::new(|| LogonInfoVersion2 { + logon_info: LogonInfo { + domain_name: DOMAIN_NAME.to_owned(), + user_name: USER_NAME.to_owned(), + session_id: SESSION_ID, + }, +}); +static LOGON_EXTENDED: LazyLock = LazyLock::new(|| LogonInfoExtended { + present_fields_flags: LogonExFlags::AUTO_RECONNECT_COOKIE | LogonExFlags::LOGON_ERRORS, + auto_reconnect: Some(ServerAutoReconnect { + logon_id: SESSION_ID, + random_bits: [ + 0xa8, 0x02, 0xe7, 0x25, 0xe2, 0x4c, 0x82, 0xb7, 0x52, 0xa5, 0x53, 0x50, 0x34, 0x98, 0xa1, 0xa8, + ], + }), + errors_info: Some(LogonErrorsInfo { + error_type: LogonErrorNotificationType::NoPermission, + error_data: LogonErrorNotificationData::ErrorCode(LogonErrorNotificationDataErrorCode::FailedOther), + }), +}); +static SESSION_PLAIN_NOTIFY: LazyLock = LazyLock::new(|| SaveSessionInfoPdu { + info_type: InfoType::PlainNotify, + info_data: InfoData::PlainNotify, +}); #[test] fn from_buffer_correct_parses_logon_info_v1() { diff --git a/crates/ironrdp-pdu/src/rdp/suppress_output.rs b/crates/ironrdp-pdu/src/rdp/suppress_output.rs index 86f543368b..468876794c 100644 --- a/crates/ironrdp-pdu/src/rdp/suppress_output.rs +++ b/crates/ironrdp-pdu/src/rdp/suppress_output.rs @@ -1,12 +1,13 @@ use ironrdp_core::{ - ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, write_padding, Decode, DecodeResult, Encode, - EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, + invalid_field_err, read_padding, write_padding, }; use crate::geometry::InclusiveRectangle; #[repr(u8)] -#[derive(Debug, PartialEq, Eq, Clone)] +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub enum AllowDisplayUpdatesType { SuppressDisplayUpdates = 0x00, AllowDisplayUpdates = 0x01, @@ -21,6 +22,10 @@ impl AllowDisplayUpdatesType { } } + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] pub fn as_u8(self) -> u8 { self as u8 } @@ -36,6 +41,7 @@ impl AllowDisplayUpdatesType { /// /// [2.2.11.3.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/0be71491-0b01-402c-947d-080706ccf91b #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct SuppressOutputPdu { pub desktop_rect: Option, } diff --git a/crates/ironrdp-pdu/src/rdp/vc/dvc.rs b/crates/ironrdp-pdu/src/rdp/vc/dvc.rs deleted file mode 100644 index 4fcc11bbbb..0000000000 --- a/crates/ironrdp-pdu/src/rdp/vc/dvc.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod gfx; diff --git a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx.rs b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx.rs deleted file mode 100644 index 90afd4a7f5..0000000000 --- a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx.rs +++ /dev/null @@ -1,287 +0,0 @@ -mod graphics_messages; - -pub use graphics_messages::{ - Avc420BitmapStream, Avc444BitmapStream, CacheImportReplyPdu, CacheToSurfacePdu, CapabilitiesAdvertisePdu, - CapabilitiesConfirmPdu, CapabilitiesV103Flags, CapabilitiesV104Flags, CapabilitiesV107Flags, CapabilitiesV10Flags, - CapabilitiesV81Flags, CapabilitiesV8Flags, CapabilitySet, Codec1Type, Codec2Type, Color, CreateSurfacePdu, - DeleteEncodingContextPdu, DeleteSurfacePdu, Encoding, EndFramePdu, EvictCacheEntryPdu, FrameAcknowledgePdu, - MapSurfaceToOutputPdu, MapSurfaceToScaledOutputPdu, MapSurfaceToScaledWindowPdu, PixelFormat, Point, QuantQuality, - QueueDepth, ResetGraphicsPdu, SolidFillPdu, StartFramePdu, SurfaceToCachePdu, SurfaceToSurfacePdu, Timestamp, - WireToSurface1Pdu, WireToSurface2Pdu, -}; -use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, - ReadCursor, WriteCursor, -}; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ServerPdu { - WireToSurface1(WireToSurface1Pdu), - WireToSurface2(WireToSurface2Pdu), - DeleteEncodingContext(DeleteEncodingContextPdu), - SolidFill(SolidFillPdu), - SurfaceToSurface(SurfaceToSurfacePdu), - SurfaceToCache(SurfaceToCachePdu), - CacheToSurface(CacheToSurfacePdu), - EvictCacheEntry(EvictCacheEntryPdu), - CreateSurface(CreateSurfacePdu), - DeleteSurface(DeleteSurfacePdu), - StartFrame(StartFramePdu), - EndFrame(EndFramePdu), - ResetGraphics(ResetGraphicsPdu), - MapSurfaceToOutput(MapSurfaceToOutputPdu), - CapabilitiesConfirm(CapabilitiesConfirmPdu), - CacheImportReply(CacheImportReplyPdu), - MapSurfaceToScaledOutput(MapSurfaceToScaledOutputPdu), - MapSurfaceToScaledWindow(MapSurfaceToScaledWindowPdu), -} - -const RDP_GFX_HEADER_SIZE: usize = 2 /* PduType */ + 2 /* flags */ + 4 /* bufferLen */; - -impl ServerPdu { - const NAME: &'static str = "GfxServerPdu"; - - const FIXED_PART_SIZE: usize = RDP_GFX_HEADER_SIZE; -} - -impl Encode for ServerPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - let buffer_length = self.size(); - - dst.write_u16(ServerPduType::from(self).to_u16().unwrap()); - dst.write_u16(0); // flags - dst.write_u32(cast_length!("bufferLen", buffer_length)?); - - match self { - ServerPdu::WireToSurface1(pdu) => pdu.encode(dst), - ServerPdu::WireToSurface2(pdu) => pdu.encode(dst), - ServerPdu::DeleteEncodingContext(pdu) => pdu.encode(dst), - ServerPdu::SolidFill(pdu) => pdu.encode(dst), - ServerPdu::SurfaceToSurface(pdu) => pdu.encode(dst), - ServerPdu::SurfaceToCache(pdu) => pdu.encode(dst), - ServerPdu::CacheToSurface(pdu) => pdu.encode(dst), - ServerPdu::CreateSurface(pdu) => pdu.encode(dst), - ServerPdu::DeleteSurface(pdu) => pdu.encode(dst), - ServerPdu::ResetGraphics(pdu) => pdu.encode(dst), - ServerPdu::MapSurfaceToOutput(pdu) => pdu.encode(dst), - ServerPdu::MapSurfaceToScaledOutput(pdu) => pdu.encode(dst), - ServerPdu::MapSurfaceToScaledWindow(pdu) => pdu.encode(dst), - ServerPdu::StartFrame(pdu) => pdu.encode(dst), - ServerPdu::EndFrame(pdu) => pdu.encode(dst), - ServerPdu::EvictCacheEntry(pdu) => pdu.encode(dst), - ServerPdu::CapabilitiesConfirm(pdu) => pdu.encode(dst), - ServerPdu::CacheImportReply(pdu) => pdu.encode(dst), - } - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - + match self { - ServerPdu::WireToSurface1(pdu) => pdu.size(), - ServerPdu::WireToSurface2(pdu) => pdu.size(), - ServerPdu::DeleteEncodingContext(pdu) => pdu.size(), - ServerPdu::SolidFill(pdu) => pdu.size(), - ServerPdu::SurfaceToSurface(pdu) => pdu.size(), - ServerPdu::SurfaceToCache(pdu) => pdu.size(), - ServerPdu::CacheToSurface(pdu) => pdu.size(), - ServerPdu::CreateSurface(pdu) => pdu.size(), - ServerPdu::DeleteSurface(pdu) => pdu.size(), - ServerPdu::ResetGraphics(pdu) => pdu.size(), - ServerPdu::MapSurfaceToOutput(pdu) => pdu.size(), - ServerPdu::MapSurfaceToScaledOutput(pdu) => pdu.size(), - ServerPdu::MapSurfaceToScaledWindow(pdu) => pdu.size(), - ServerPdu::StartFrame(pdu) => pdu.size(), - ServerPdu::EndFrame(pdu) => pdu.size(), - ServerPdu::EvictCacheEntry(pdu) => pdu.size(), - ServerPdu::CapabilitiesConfirm(pdu) => pdu.size(), - ServerPdu::CacheImportReply(pdu) => pdu.size(), - } - } -} - -impl<'a> Decode<'a> for ServerPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let pdu_type = ServerPduType::from_u16(src.read_u16()) - .ok_or_else(|| invalid_field_err!("serverPduType", "invalid pdu type"))?; - let _flags = src.read_u16(); - let pdu_length = cast_length!("pduLen", src.read_u32())?; - - let (server_pdu, buffer_length) = { - let pdu = match pdu_type { - ServerPduType::DeleteEncodingContext => { - ServerPdu::DeleteEncodingContext(DeleteEncodingContextPdu::decode(src)?) - } - ServerPduType::WireToSurface1 => ServerPdu::WireToSurface1(WireToSurface1Pdu::decode(src)?), - ServerPduType::WireToSurface2 => ServerPdu::WireToSurface2(WireToSurface2Pdu::decode(src)?), - ServerPduType::SolidFill => ServerPdu::SolidFill(SolidFillPdu::decode(src)?), - ServerPduType::SurfaceToSurface => ServerPdu::SurfaceToSurface(SurfaceToSurfacePdu::decode(src)?), - ServerPduType::SurfaceToCache => ServerPdu::SurfaceToCache(SurfaceToCachePdu::decode(src)?), - ServerPduType::CacheToSurface => ServerPdu::CacheToSurface(CacheToSurfacePdu::decode(src)?), - ServerPduType::EvictCacheEntry => ServerPdu::EvictCacheEntry(EvictCacheEntryPdu::decode(src)?), - ServerPduType::CreateSurface => ServerPdu::CreateSurface(CreateSurfacePdu::decode(src)?), - ServerPduType::DeleteSurface => ServerPdu::DeleteSurface(DeleteSurfacePdu::decode(src)?), - ServerPduType::StartFrame => ServerPdu::StartFrame(StartFramePdu::decode(src)?), - ServerPduType::EndFrame => ServerPdu::EndFrame(EndFramePdu::decode(src)?), - ServerPduType::ResetGraphics => ServerPdu::ResetGraphics(ResetGraphicsPdu::decode(src)?), - ServerPduType::MapSurfaceToOutput => ServerPdu::MapSurfaceToOutput(MapSurfaceToOutputPdu::decode(src)?), - ServerPduType::CapabilitiesConfirm => { - ServerPdu::CapabilitiesConfirm(CapabilitiesConfirmPdu::decode(src)?) - } - ServerPduType::CacheImportReply => ServerPdu::CacheImportReply(CacheImportReplyPdu::decode(src)?), - ServerPduType::MapSurfaceToScaledOutput => { - ServerPdu::MapSurfaceToScaledOutput(MapSurfaceToScaledOutputPdu::decode(src)?) - } - ServerPduType::MapSurfaceToScaledWindow => { - ServerPdu::MapSurfaceToScaledWindow(MapSurfaceToScaledWindowPdu::decode(src)?) - } - _ => return Err(invalid_field_err!("pduType", "invalid pdu type")), - }; - let buffer_length = pdu.size(); - - (pdu, buffer_length) - }; - - if buffer_length != pdu_length { - Err(invalid_field_err!("len", "invalid pdu length")) - } else { - Ok(server_pdu) - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ClientPdu { - FrameAcknowledge(FrameAcknowledgePdu), - CapabilitiesAdvertise(CapabilitiesAdvertisePdu), -} - -impl ClientPdu { - const NAME: &'static str = "GfxClientPdu"; - - const FIXED_PART_SIZE: usize = RDP_GFX_HEADER_SIZE; -} - -impl Encode for ClientPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(ClientPduType::from(self).to_u16().unwrap()); - dst.write_u16(0); // flags - dst.write_u32(cast_length!("bufferLen", self.size())?); - - match self { - ClientPdu::FrameAcknowledge(pdu) => pdu.encode(dst), - ClientPdu::CapabilitiesAdvertise(pdu) => pdu.encode(dst), - } - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - + match self { - ClientPdu::FrameAcknowledge(pdu) => pdu.size(), - ClientPdu::CapabilitiesAdvertise(pdu) => pdu.size(), - } - } -} - -impl<'a> Decode<'a> for ClientPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - let pdu_type = ClientPduType::from_u16(src.read_u16()) - .ok_or_else(|| invalid_field_err!("clientPduType", "invalid pdu type"))?; - let _flags = src.read_u16(); - let pdu_length = cast_length!("bufferLen", src.read_u32())?; - - let client_pdu = match pdu_type { - ClientPduType::FrameAcknowledge => ClientPdu::FrameAcknowledge(FrameAcknowledgePdu::decode(src)?), - ClientPduType::CapabilitiesAdvertise => { - ClientPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::decode(src)?) - } - _ => return Err(invalid_field_err!("pduType", "invalid pdu type")), - }; - - if client_pdu.size() != pdu_length { - Err(invalid_field_err!("len", "invalid pdu length")) - } else { - Ok(client_pdu) - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] -pub enum ClientPduType { - FrameAcknowledge = 0x0d, - CacheImportOffer = 0x10, - CapabilitiesAdvertise = 0x12, - QoeFrameAcknowledge = 0x16, -} - -impl<'a> From<&'a ClientPdu> for ClientPduType { - fn from(c: &'a ClientPdu) -> Self { - match c { - ClientPdu::FrameAcknowledge(_) => Self::FrameAcknowledge, - ClientPdu::CapabilitiesAdvertise(_) => Self::CapabilitiesAdvertise, - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] -pub enum ServerPduType { - WireToSurface1 = 0x01, - WireToSurface2 = 0x02, - DeleteEncodingContext = 0x03, - SolidFill = 0x04, - SurfaceToSurface = 0x05, - SurfaceToCache = 0x06, - CacheToSurface = 0x07, - EvictCacheEntry = 0x08, - CreateSurface = 0x09, - DeleteSurface = 0x0a, - StartFrame = 0x0b, - EndFrame = 0x0c, - ResetGraphics = 0x0e, - MapSurfaceToOutput = 0x0f, - CacheImportReply = 0x11, - CapabilitiesConfirm = 0x13, - MapSurfaceToWindow = 0x15, - MapSurfaceToScaledOutput = 0x17, - MapSurfaceToScaledWindow = 0x18, -} - -impl<'a> From<&'a ServerPdu> for ServerPduType { - fn from(s: &'a ServerPdu) -> Self { - match s { - ServerPdu::WireToSurface1(_) => Self::WireToSurface1, - ServerPdu::WireToSurface2(_) => Self::WireToSurface2, - ServerPdu::DeleteEncodingContext(_) => Self::DeleteEncodingContext, - ServerPdu::SolidFill(_) => Self::SolidFill, - ServerPdu::SurfaceToSurface(_) => Self::SurfaceToSurface, - ServerPdu::SurfaceToCache(_) => Self::SurfaceToCache, - ServerPdu::CacheToSurface(_) => Self::CacheToSurface, - ServerPdu::EvictCacheEntry(_) => Self::EvictCacheEntry, - ServerPdu::CreateSurface(_) => Self::CreateSurface, - ServerPdu::DeleteSurface(_) => Self::DeleteSurface, - ServerPdu::StartFrame(_) => Self::StartFrame, - ServerPdu::EndFrame(_) => Self::EndFrame, - ServerPdu::ResetGraphics(_) => Self::ResetGraphics, - ServerPdu::MapSurfaceToOutput(_) => Self::MapSurfaceToOutput, - ServerPdu::MapSurfaceToScaledOutput(_) => Self::MapSurfaceToScaledOutput, - ServerPdu::MapSurfaceToScaledWindow(_) => Self::MapSurfaceToScaledWindow, - ServerPdu::CapabilitiesConfirm(_) => Self::CapabilitiesConfirm, - ServerPdu::CacheImportReply(_) => Self::CacheImportReply, - } - } -} diff --git a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages.rs b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages.rs deleted file mode 100644 index 6f341f77c1..0000000000 --- a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages.rs +++ /dev/null @@ -1,344 +0,0 @@ -mod client; -mod server; - -mod avc_messages; -use bitflags::bitflags; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; - -#[rustfmt::skip] // do not re-order this -pub use avc_messages::{Avc420BitmapStream, Avc444BitmapStream, Encoding, QuantQuality}; -pub use client::{CacheImportReplyPdu, CapabilitiesAdvertisePdu, FrameAcknowledgePdu, QueueDepth}; -use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, - ReadCursor, WriteCursor, -}; -pub use server::{ - CacheToSurfacePdu, CapabilitiesConfirmPdu, Codec1Type, Codec2Type, CreateSurfacePdu, DeleteEncodingContextPdu, - DeleteSurfacePdu, EndFramePdu, EvictCacheEntryPdu, MapSurfaceToOutputPdu, MapSurfaceToScaledOutputPdu, - MapSurfaceToScaledWindowPdu, PixelFormat, ResetGraphicsPdu, SolidFillPdu, StartFramePdu, SurfaceToCachePdu, - SurfaceToSurfacePdu, Timestamp, WireToSurface1Pdu, WireToSurface2Pdu, -}; - -use super::RDP_GFX_HEADER_SIZE; - -const CAPABILITY_SET_HEADER_SIZE: usize = 8; - -const V10_1_RESERVED: u128 = 0; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CapabilitySet { - V8 { flags: CapabilitiesV8Flags }, - V8_1 { flags: CapabilitiesV81Flags }, - V10 { flags: CapabilitiesV10Flags }, - V10_1, - V10_2 { flags: CapabilitiesV10Flags }, - V10_3 { flags: CapabilitiesV103Flags }, - V10_4 { flags: CapabilitiesV104Flags }, - V10_5 { flags: CapabilitiesV104Flags }, - V10_6 { flags: CapabilitiesV104Flags }, - V10_6Err { flags: CapabilitiesV104Flags }, - V10_7 { flags: CapabilitiesV107Flags }, - Unknown(Vec), -} - -impl CapabilitySet { - fn version(&self) -> CapabilityVersion { - match self { - CapabilitySet::V8 { .. } => CapabilityVersion::V8, - CapabilitySet::V8_1 { .. } => CapabilityVersion::V8_1, - CapabilitySet::V10 { .. } => CapabilityVersion::V10, - CapabilitySet::V10_1 => CapabilityVersion::V10_1, - CapabilitySet::V10_2 { .. } => CapabilityVersion::V10_2, - CapabilitySet::V10_3 { .. } => CapabilityVersion::V10_3, - CapabilitySet::V10_4 { .. } => CapabilityVersion::V10_4, - CapabilitySet::V10_5 { .. } => CapabilityVersion::V10_5, - CapabilitySet::V10_6 { .. } => CapabilityVersion::V10_6, - CapabilitySet::V10_6Err { .. } => CapabilityVersion::V10_6Err, - CapabilitySet::V10_7 { .. } => CapabilityVersion::V10_7, - CapabilitySet::Unknown { .. } => CapabilityVersion::Unknown, - } - } -} - -impl CapabilitySet { - const NAME: &'static str = "GfxCapabilitySet"; - - const FIXED_PART_SIZE: usize = CAPABILITY_SET_HEADER_SIZE; -} - -impl Encode for CapabilitySet { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u32(self.version().to_u32().unwrap()); - dst.write_u32(cast_length!("dataLength", self.size() - CAPABILITY_SET_HEADER_SIZE)?); - - match self { - CapabilitySet::V8 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V8_1 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10_1 => dst.write_u128(V10_1_RESERVED), - CapabilitySet::V10_2 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10_3 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10_4 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10_5 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10_6 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10_6Err { flags } => dst.write_u32(flags.bits()), - CapabilitySet::V10_7 { flags } => dst.write_u32(flags.bits()), - CapabilitySet::Unknown(data) => dst.write_slice(data), - } - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - CAPABILITY_SET_HEADER_SIZE - + match self { - CapabilitySet::V8 { .. } - | CapabilitySet::V8_1 { .. } - | CapabilitySet::V10 { .. } - | CapabilitySet::V10_2 { .. } - | CapabilitySet::V10_3 { .. } - | CapabilitySet::V10_4 { .. } - | CapabilitySet::V10_5 { .. } - | CapabilitySet::V10_6 { .. } - | CapabilitySet::V10_6Err { .. } - | CapabilitySet::V10_7 { .. } => 4, - CapabilitySet::V10_1 => 16, - CapabilitySet::Unknown(data) => data.len(), - } - } -} - -impl<'de> Decode<'de> for CapabilitySet { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let version = CapabilityVersion::from_u32(src.read_u32()) - .ok_or_else(|| invalid_field_err!("version", "unhandled version"))?; - let data_length: usize = cast_length!("dataLength", src.read_u32())?; - - ensure_size!(in: src, size: data_length); - let data = src.read_slice(data_length); - let mut cur = ReadCursor::new(data); - - let size = match version { - CapabilityVersion::V8 - | CapabilityVersion::V8_1 - | CapabilityVersion::V10 - | CapabilityVersion::V10_2 - | CapabilityVersion::V10_3 - | CapabilityVersion::V10_4 - | CapabilityVersion::V10_5 - | CapabilityVersion::V10_6 - | CapabilityVersion::V10_6Err - | CapabilityVersion::V10_7 => 4, - CapabilityVersion::V10_1 => 16, - CapabilityVersion::Unknown => 0, - }; - - ensure_size!(in: cur, size: size); - match version { - CapabilityVersion::V8 => Ok(CapabilitySet::V8 { - flags: CapabilitiesV8Flags::from_bits_truncate(cur.read_u32()), - }), - CapabilityVersion::V8_1 => Ok(CapabilitySet::V8_1 { - flags: CapabilitiesV81Flags::from_bits_truncate(cur.read_u32()), - }), - CapabilityVersion::V10 => Ok(CapabilitySet::V10 { - flags: CapabilitiesV10Flags::from_bits_truncate(cur.read_u32()), - }), - CapabilityVersion::V10_1 => { - cur.read_u128(); - - Ok(CapabilitySet::V10_1) - } - CapabilityVersion::V10_2 => Ok(CapabilitySet::V10_2 { - flags: CapabilitiesV10Flags::from_bits_truncate(cur.read_u32()), - }), - CapabilityVersion::V10_3 => Ok(CapabilitySet::V10_3 { - flags: CapabilitiesV103Flags::from_bits_truncate(cur.read_u32()), - }), - CapabilityVersion::V10_4 => Ok(CapabilitySet::V10_4 { - flags: CapabilitiesV104Flags::from_bits_truncate(cur.read_u32()), - }), - CapabilityVersion::V10_5 => Ok(CapabilitySet::V10_5 { - flags: CapabilitiesV104Flags::from_bits_truncate(cur.read_u32()), - }), - CapabilityVersion::V10_6 => Ok(CapabilitySet::V10_6 { - flags: CapabilitiesV104Flags::from_bits_truncate(cur.read_u32()), - }), - CapabilityVersion::V10_6Err => Ok(CapabilitySet::V10_6Err { - flags: CapabilitiesV104Flags::from_bits_truncate(cur.read_u32()), - }), - CapabilityVersion::V10_7 => Ok(CapabilitySet::V10_7 { - flags: CapabilitiesV107Flags::from_bits_truncate(cur.read_u32()), - }), - CapabilityVersion::Unknown => Ok(CapabilitySet::Unknown(data.to_vec())), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Color { - pub b: u8, - pub g: u8, - pub r: u8, - pub xa: u8, -} - -impl Color { - const NAME: &'static str = "GfxColor"; - - const FIXED_PART_SIZE: usize = 4 /* BGRA */; -} - -impl Encode for Color { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u8(self.b); - dst.write_u8(self.g); - dst.write_u8(self.r); - dst.write_u8(self.xa); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'de> Decode<'de> for Color { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let b = src.read_u8(); - let g = src.read_u8(); - let r = src.read_u8(); - let xa = src.read_u8(); - - Ok(Self { b, g, r, xa }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Point { - pub x: u16, - pub y: u16, -} - -impl Point { - const NAME: &'static str = "GfxPoint"; - - const FIXED_PART_SIZE: usize = 2 /* X */ + 2 /* Y */; -} - -impl Encode for Point { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.x); - dst.write_u16(self.y); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'de> Decode<'de> for Point { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let x = src.read_u16(); - let y = src.read_u16(); - - Ok(Self { x, y }) - } -} - -#[repr(u32)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] -pub(crate) enum CapabilityVersion { - V8 = 0x8_0004, - V8_1 = 0x8_0105, - V10 = 0xa_0002, - V10_1 = 0xa_0100, - V10_2 = 0xa_0200, - V10_3 = 0xa_0301, - V10_4 = 0xa_0400, - V10_5 = 0xa_0502, - V10_6 = 0xa_0600, // [MS-RDPEGFX-errata] - V10_6Err = 0xa_0601, // defined similar to FreeRDP to maintain best compatibility - V10_7 = 0xa_0701, - Unknown = 0xa_0702, -} - -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - pub struct CapabilitiesV8Flags: u32 { - const THIN_CLIENT = 0x1; - const SMALL_CACHE = 0x2; - } -} - -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - pub struct CapabilitiesV81Flags: u32 { - const THIN_CLIENT = 0x01; - const SMALL_CACHE = 0x02; - const AVC420_ENABLED = 0x10; - } -} - -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - pub struct CapabilitiesV10Flags: u32 { - const SMALL_CACHE = 0x02; - const AVC_DISABLED = 0x20; - } -} - -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - pub struct CapabilitiesV103Flags: u32 { - const AVC_DISABLED = 0x20; - const AVC_THIN_CLIENT = 0x40; - } -} - -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - pub struct CapabilitiesV104Flags: u32 { - const SMALL_CACHE = 0x02; - const AVC_DISABLED = 0x20; - const AVC_THIN_CLIENT = 0x40; - } -} - -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - pub struct CapabilitiesV107Flags: u32 { - const SMALL_CACHE = 0x02; - const AVC_DISABLED = 0x20; - const AVC_THIN_CLIENT = 0x40; - const SCALEDMAP_DISABLE = 0x80; - } -} diff --git a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/avc_messages.rs b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/avc_messages.rs deleted file mode 100644 index 501c44c379..0000000000 --- a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/avc_messages.rs +++ /dev/null @@ -1,219 +0,0 @@ -use core::fmt::Debug; - -use bit_field::BitField as _; -use bitflags::bitflags; -use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, - ReadCursor, WriteCursor, -}; - -use crate::geometry::InclusiveRectangle; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct QuantQuality { - pub quantization_parameter: u8, - pub progressive: bool, - pub quality: u8, -} - -impl QuantQuality { - const NAME: &'static str = "GfxQuantQuality"; - - const FIXED_PART_SIZE: usize = 1 /* data */ + 1 /* quality */; -} - -impl Encode for QuantQuality { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - let mut data = 0u8; - data.set_bits(0..6, self.quantization_parameter); - data.set_bit(7, self.progressive); - dst.write_u8(data); - dst.write_u8(self.quality); - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'de> Decode<'de> for QuantQuality { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let data = src.read_u8(); - let qp = data.get_bits(0..6); - let progressive = data.get_bit(7); - let quality = src.read_u8(); - Ok(QuantQuality { - quantization_parameter: qp, - progressive, - quality, - }) - } -} - -#[derive(Clone, PartialEq, Eq)] -pub struct Avc420BitmapStream<'a> { - pub rectangles: Vec, - pub quant_qual_vals: Vec, - pub data: &'a [u8], -} - -impl Debug for Avc420BitmapStream<'_> { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("Avc420BitmapStream") - .field("rectangles", &self.rectangles) - .field("quant_qual_vals", &self.quant_qual_vals) - .field("data_len", &self.data.len()) - .finish() - } -} - -impl Avc420BitmapStream<'_> { - const NAME: &'static str = "Avc420BitmapStream"; - - const FIXED_PART_SIZE: usize = 4 /* nRect */; -} - -impl Encode for Avc420BitmapStream<'_> { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u32(cast_length!("len", self.rectangles.len())?); - for rectangle in &self.rectangles { - rectangle.encode(dst)?; - } - for quant_qual_val in &self.quant_qual_vals { - quant_qual_val.encode(dst)?; - } - dst.write_slice(self.data); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - // Each rectangle is 8 bytes and 2 bytes for each quant val - Self::FIXED_PART_SIZE + self.rectangles.len() * 10 + self.data.len() - } -} - -impl<'de> Decode<'de> for Avc420BitmapStream<'de> { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let num_regions = src.read_u32(); - let mut rectangles = Vec::with_capacity(num_regions as usize); - let mut quant_qual_vals = Vec::with_capacity(num_regions as usize); - for _ in 0..num_regions { - rectangles.push(InclusiveRectangle::decode(src)?); - } - for _ in 0..num_regions { - quant_qual_vals.push(QuantQuality::decode(src)?); - } - let data = src.remaining(); - Ok(Avc420BitmapStream { - rectangles, - quant_qual_vals, - data, - }) - } -} - -bitflags! { - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] - pub struct Encoding: u8 { - const LUMA_AND_CHROMA = 0x00; - const LUMA = 0x01; - const CHROMA = 0x02; - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Avc444BitmapStream<'a> { - pub encoding: Encoding, - pub stream1: Avc420BitmapStream<'a>, - pub stream2: Option>, -} - -impl Avc444BitmapStream<'_> { - const NAME: &'static str = "Avc444BitmapStream"; - - const FIXED_PART_SIZE: usize = 4 /* streamInfo */; -} - -impl Encode for Avc444BitmapStream<'_> { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - let mut stream_info = 0u32; - stream_info.set_bits(0..30, cast_length!("stream1size", self.stream1.size())?); - stream_info.set_bits(30..32, self.encoding.bits() as u32); - dst.write_u32(stream_info); - self.stream1.encode(dst)?; - if let Some(stream) = self.stream2.as_ref() { - stream.encode(dst)?; - } - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - let stream2_size = if let Some(stream) = self.stream2.as_ref() { - stream.size() - } else { - 0 - }; - - Self::FIXED_PART_SIZE + self.stream1.size() + stream2_size - } -} - -impl<'de> Decode<'de> for Avc444BitmapStream<'de> { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let stream_info = src.read_u32(); - let stream_len = stream_info.get_bits(0..30); - let encoding = Encoding::from_bits_truncate(stream_info.get_bits(30..32) as u8); - - if stream_len == 0 { - if encoding == Encoding::LUMA_AND_CHROMA { - return Err(invalid_field_err!("encoding", "invalid encoding")); - } - - let stream1 = Avc420BitmapStream::decode(src)?; - Ok(Avc444BitmapStream { - encoding, - stream1, - stream2: None, - }) - } else { - let (mut stream1, mut stream2) = src.split_at(stream_len as usize); - let stream1 = Avc420BitmapStream::decode(&mut stream1)?; - let stream2 = if encoding == Encoding::LUMA_AND_CHROMA { - Some(Avc420BitmapStream::decode(&mut stream2)?) - } else { - None - }; - Ok(Avc444BitmapStream { - encoding, - stream1, - stream2, - }) - } - } -} diff --git a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/client.rs b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/client.rs deleted file mode 100644 index 45d1b8022c..0000000000 --- a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/client.rs +++ /dev/null @@ -1,173 +0,0 @@ -use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, - WriteCursor, -}; - -use super::CapabilitySet; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CapabilitiesAdvertisePdu(pub Vec); - -impl CapabilitiesAdvertisePdu { - const NAME: &'static str = "CapabilitiesAdvertisePdu"; - - const FIXED_PART_SIZE: usize = 2 /* Count */; -} - -impl Encode for CapabilitiesAdvertisePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(cast_length!("Count", self.0.len())?); - - for capability_set in self.0.iter() { - capability_set.encode(dst)?; - } - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE + self.0.iter().map(|c| c.size()).sum::() - } -} - -impl<'a> Decode<'a> for CapabilitiesAdvertisePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let capabilities_count = cast_length!("Count", src.read_u16())?; - - ensure_size!(in: src, size: capabilities_count * CapabilitySet::FIXED_PART_SIZE); - - let capabilities = (0..capabilities_count) - .map(|_| CapabilitySet::decode(src)) - .collect::>()?; - - Ok(Self(capabilities)) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FrameAcknowledgePdu { - pub queue_depth: QueueDepth, - pub frame_id: u32, - pub total_frames_decoded: u32, -} - -impl FrameAcknowledgePdu { - const NAME: &'static str = "FrameAcknowledgePdu"; - - const FIXED_PART_SIZE: usize = 4 /* QueueDepth */ + 4 /* FrameId */ + 4 /* TotalFramesDecoded */; -} - -impl Encode for FrameAcknowledgePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u32(self.queue_depth.to_u32()); - dst.write_u32(self.frame_id); - dst.write_u32(self.total_frames_decoded); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for FrameAcknowledgePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let queue_depth = QueueDepth::from_u32(src.read_u32()); - let frame_id = src.read_u32(); - let total_frames_decoded = src.read_u32(); - - Ok(Self { - queue_depth, - frame_id, - total_frames_decoded, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CacheImportReplyPdu { - pub cache_slots: Vec, -} - -impl CacheImportReplyPdu { - const NAME: &'static str = "CacheImportReplyPdu"; - - const FIXED_PART_SIZE: usize = 2 /* Count */; -} - -impl Encode for CacheImportReplyPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(cast_length!("Count", self.cache_slots.len())?); - - for cache_slot in self.cache_slots.iter() { - dst.write_u16(*cache_slot); - } - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE + self.cache_slots.iter().map(|_| 2).sum::() - } -} - -impl<'a> Decode<'a> for CacheImportReplyPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let entries_count = src.read_u16(); - - let cache_slots = (0..entries_count).map(|_| src.read_u16()).collect(); - - Ok(Self { cache_slots }) - } -} - -#[repr(u32)] -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub enum QueueDepth { - Unavailable, - AvailableBytes(u32), - Suspend, -} - -impl QueueDepth { - pub fn from_u32(v: u32) -> Self { - match v { - 0x0000_0000 => Self::Unavailable, - 0x0000_0001..=0xFFFF_FFFE => Self::AvailableBytes(v), - 0xFFFF_FFFF => Self::Suspend, - } - } - - pub fn to_u32(self) -> u32 { - match self { - Self::Unavailable => 0x0000_0000, - Self::AvailableBytes(v) => v, - Self::Suspend => 0xFFFF_FFFF, - } - } -} diff --git a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/server.rs b/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/server.rs deleted file mode 100644 index 8e366ff103..0000000000 --- a/crates/ironrdp-pdu/src/rdp/vc/dvc/gfx/graphics_messages/server.rs +++ /dev/null @@ -1,1016 +0,0 @@ -use std::fmt; - -use bit_field::BitField as _; -use ironrdp_core::{ - cast_length, decode_cursor, ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, write_padding, - Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, -}; -use num_derive::{FromPrimitive, ToPrimitive}; -use num_traits::{FromPrimitive as _, ToPrimitive as _}; - -use super::{CapabilitySet, Color, Point, RDP_GFX_HEADER_SIZE}; -use crate::gcc::Monitor; -use crate::geometry::InclusiveRectangle; - -pub(crate) const RESET_GRAPHICS_PDU_SIZE: usize = 340; - -const MAX_RESET_GRAPHICS_WIDTH_HEIGHT: u32 = 32_766; -const MONITOR_COUNT_MAX: u32 = 16; - -#[derive(Clone, PartialEq, Eq)] -pub struct WireToSurface1Pdu { - pub surface_id: u16, - pub codec_id: Codec1Type, - pub pixel_format: PixelFormat, - pub destination_rectangle: InclusiveRectangle, - pub bitmap_data: Vec, -} - -impl fmt::Debug for WireToSurface1Pdu { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("WireToSurface1Pdu") - .field("surface_id", &self.surface_id) - .field("codec_id", &self.codec_id) - .field("pixel_format", &self.pixel_format) - .field("destination_rectangle", &self.destination_rectangle) - .field("bitmap_data_length", &self.bitmap_data.len()) - .finish() - } -} - -impl WireToSurface1Pdu { - const NAME: &'static str = "WireToSurface1Pdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 2 /* CodecId */ + 1 /* PixelFormat */ + InclusiveRectangle::FIXED_PART_SIZE /* Dest */ + 4 /* BitmapDataLen */; -} - -impl Encode for WireToSurface1Pdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(self.surface_id); - dst.write_u16(self.codec_id.to_u16().unwrap()); - dst.write_u8(self.pixel_format.to_u8().unwrap()); - self.destination_rectangle.encode(dst)?; - dst.write_u32(cast_length!("BitmapDataLen", self.bitmap_data.len())?); - dst.write_slice(&self.bitmap_data); - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE + self.bitmap_data.len() - } -} - -impl<'a> Decode<'a> for WireToSurface1Pdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let codec_id = - Codec1Type::from_u16(src.read_u16()).ok_or_else(|| invalid_field_err!("CodecId", "invalid codec ID"))?; - let pixel_format = PixelFormat::from_u8(src.read_u8()) - .ok_or_else(|| invalid_field_err!("PixelFormat", "invalid pixel format"))?; - let destination_rectangle = InclusiveRectangle::decode(src)?; - let bitmap_data_length = cast_length!("BitmapDataLen", src.read_u32())?; - - ensure_size!(in: src, size: bitmap_data_length); - let bitmap_data = src.read_slice(bitmap_data_length).to_vec(); - - Ok(Self { - surface_id, - codec_id, - pixel_format, - destination_rectangle, - bitmap_data, - }) - } -} - -#[derive(Clone, PartialEq, Eq)] -pub struct WireToSurface2Pdu { - pub surface_id: u16, - pub codec_id: Codec2Type, - pub codec_context_id: u32, - pub pixel_format: PixelFormat, - pub bitmap_data: Vec, -} - -impl fmt::Debug for WireToSurface2Pdu { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("WireToSurface2Pdu") - .field("surface_id", &self.surface_id) - .field("codec_id", &self.codec_id) - .field("codec_context_id", &self.codec_context_id) - .field("pixel_format", &self.pixel_format) - .field("bitmap_data_length", &self.bitmap_data.len()) - .finish() - } -} - -impl WireToSurface2Pdu { - const NAME: &'static str = "WireToSurface2Pdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 2 /* CodecId */ + 4 /* ContextId */ + 1 /* PixelFormat */ + 4 /* BitmapDataLen */; -} - -impl Encode for WireToSurface2Pdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(self.surface_id); - dst.write_u16(self.codec_id.to_u16().unwrap()); - dst.write_u32(self.codec_context_id); - dst.write_u8(self.pixel_format.to_u8().unwrap()); - dst.write_u32(cast_length!("BitmapDataLen", self.bitmap_data.len())?); - dst.write_slice(&self.bitmap_data); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE + self.bitmap_data.len() - } -} - -impl<'a> Decode<'a> for WireToSurface2Pdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let codec_id = - Codec2Type::from_u16(src.read_u16()).ok_or_else(|| invalid_field_err!("CodecId", "invalid codec ID"))?; - let codec_context_id = src.read_u32(); - let pixel_format = PixelFormat::from_u8(src.read_u8()) - .ok_or_else(|| invalid_field_err!("PixelFormat", "invalid pixel format"))?; - let bitmap_data_length = cast_length!("BitmapDataLen", src.read_u32())?; - - ensure_size!(in: src, size: bitmap_data_length); - let bitmap_data = src.read_slice(bitmap_data_length).to_vec(); - - Ok(Self { - surface_id, - codec_id, - codec_context_id, - pixel_format, - bitmap_data, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DeleteEncodingContextPdu { - pub surface_id: u16, - pub codec_context_id: u32, -} - -impl DeleteEncodingContextPdu { - const NAME: &'static str = "DeleteEncodingContextPdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 4 /* CodecContextId */; -} - -impl Encode for DeleteEncodingContextPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.surface_id); - dst.write_u32(self.codec_context_id); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for DeleteEncodingContextPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let codec_context_id = src.read_u32(); - - Ok(Self { - surface_id, - codec_context_id, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SolidFillPdu { - pub surface_id: u16, - pub fill_pixel: Color, - pub rectangles: Vec, -} - -impl SolidFillPdu { - const NAME: &'static str = "CacheToSurfacePdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + Color::FIXED_PART_SIZE /* Color */ + 2 /* RectCount */; -} - -impl Encode for SolidFillPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(self.surface_id); - self.fill_pixel.encode(dst)?; - dst.write_u16(self.rectangles.len() as u16); - - for rectangle in self.rectangles.iter() { - rectangle.encode(dst)?; - } - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE + self.rectangles.iter().map(|r| r.size()).sum::() - } -} - -impl<'a> Decode<'a> for SolidFillPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let fill_pixel = Color::decode(src)?; - let rectangles_count = src.read_u16(); - - ensure_size!(in: src, size: usize::from(rectangles_count) * InclusiveRectangle::FIXED_PART_SIZE); - let rectangles = (0..rectangles_count) - .map(|_| InclusiveRectangle::decode(src)) - .collect::>()?; - - Ok(Self { - surface_id, - fill_pixel, - rectangles, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SurfaceToSurfacePdu { - pub source_surface_id: u16, - pub destination_surface_id: u16, - pub source_rectangle: InclusiveRectangle, - pub destination_points: Vec, -} - -impl SurfaceToSurfacePdu { - const NAME: &'static str = "SurfaceToSurfacePdu"; - - const FIXED_PART_SIZE: usize = 2 /* SourceId */ + 2 /* DestId */ + InclusiveRectangle::FIXED_PART_SIZE /* SourceRect */ + 2 /* DestPointsCount */; -} - -impl Encode for SurfaceToSurfacePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(self.source_surface_id); - dst.write_u16(self.destination_surface_id); - self.source_rectangle.encode(dst)?; - - dst.write_u16(cast_length!("DestinationPoints", self.destination_points.len())?); - for rectangle in self.destination_points.iter() { - rectangle.encode(dst)?; - } - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE + self.destination_points.iter().map(|r| r.size()).sum::() - } -} - -impl<'a> Decode<'a> for SurfaceToSurfacePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let source_surface_id = src.read_u16(); - let destination_surface_id = src.read_u16(); - let source_rectangle = InclusiveRectangle::decode(src)?; - let destination_points_count = src.read_u16(); - - let destination_points = (0..destination_points_count) - .map(|_| Point::decode(src)) - .collect::>()?; - - Ok(Self { - source_surface_id, - destination_surface_id, - source_rectangle, - destination_points, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SurfaceToCachePdu { - pub surface_id: u16, - pub cache_key: u64, - pub cache_slot: u16, - pub source_rectangle: InclusiveRectangle, -} - -impl SurfaceToCachePdu { - const NAME: &'static str = "SurfaceToCachePdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 8 /* CacheKey */ + 2 /* CacheSlot */ + InclusiveRectangle::FIXED_PART_SIZE /* SourceRect */; -} - -impl Encode for SurfaceToCachePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.surface_id); - dst.write_u64(self.cache_key); - dst.write_u16(self.cache_slot); - self.source_rectangle.encode(dst)?; - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for SurfaceToCachePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let cache_key = src.read_u64(); - let cache_slot = src.read_u16(); - let source_rectangle = InclusiveRectangle::decode(src)?; - - Ok(Self { - surface_id, - cache_key, - cache_slot, - source_rectangle, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CacheToSurfacePdu { - pub cache_slot: u16, - pub surface_id: u16, - pub destination_points: Vec, -} - -impl CacheToSurfacePdu { - const NAME: &'static str = "CacheToSurfacePdu"; - - const FIXED_PART_SIZE: usize = 2 /* cache_slot */ + 2 /* surface_id */ + 2 /* npoints */; -} - -impl Encode for CacheToSurfacePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u16(self.cache_slot); - dst.write_u16(self.surface_id); - dst.write_u16(cast_length!("npoints", self.destination_points.len())?); - for point in self.destination_points.iter() { - point.encode(dst)?; - } - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE + self.destination_points.iter().map(|p| p.size()).sum::() - } -} - -impl<'de> Decode<'de> for CacheToSurfacePdu { - fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let cache_slot = src.read_u16(); - let surface_id = src.read_u16(); - let destination_points_count = src.read_u16(); - - let destination_points = (0..destination_points_count) - .map(|_| decode_cursor(src)) - .collect::>()?; - - Ok(Self { - cache_slot, - surface_id, - destination_points, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CreateSurfacePdu { - pub surface_id: u16, - pub width: u16, - pub height: u16, - pub pixel_format: PixelFormat, -} - -impl CreateSurfacePdu { - const NAME: &'static str = "CreateSurfacePdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 2 /* Width */ + 2 /* Height */ + 1 /* PixelFormat */; -} - -impl Encode for CreateSurfacePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.surface_id); - dst.write_u16(self.width); - dst.write_u16(self.height); - dst.write_u8(self.pixel_format.to_u8().unwrap()); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for CreateSurfacePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let width = src.read_u16(); - let height = src.read_u16(); - let pixel_format = PixelFormat::from_u8(src.read_u8()) - .ok_or_else(|| invalid_field_err!("pixelFormat", "invalid pixel format"))?; - - Ok(Self { - surface_id, - width, - height, - pixel_format, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DeleteSurfacePdu { - pub surface_id: u16, -} - -impl DeleteSurfacePdu { - const NAME: &'static str = "DeleteSurfacePdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */; -} - -impl Encode for DeleteSurfacePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.surface_id); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for DeleteSurfacePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - - Ok(Self { surface_id }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResetGraphicsPdu { - pub width: u32, - pub height: u32, - pub monitors: Vec, -} - -impl ResetGraphicsPdu { - const NAME: &'static str = "ResetGraphicsPdu"; - - const FIXED_PART_SIZE: usize = 4 /* Width */ + 4 /* Height */; -} - -impl Encode for ResetGraphicsPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_size!(in: dst, size: self.size()); - - dst.write_u32(self.width); - dst.write_u32(self.height); - dst.write_u32(cast_length!("nMonitors", self.monitors.len())?); - - for monitor in self.monitors.iter() { - monitor.encode(dst)?; - } - - write_padding!(dst, self.padding_size()); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - RESET_GRAPHICS_PDU_SIZE - RDP_GFX_HEADER_SIZE - } -} - -impl<'a> Decode<'a> for ResetGraphicsPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let width = src.read_u32(); - if width > MAX_RESET_GRAPHICS_WIDTH_HEIGHT { - return Err(invalid_field_err!("width", "invalid reset graphics width")); - } - - let height = src.read_u32(); - if height > MAX_RESET_GRAPHICS_WIDTH_HEIGHT { - return Err(invalid_field_err!("height", "invalid reset graphics height")); - } - - let monitor_count = src.read_u32(); - if monitor_count > MONITOR_COUNT_MAX { - return Err(invalid_field_err!("height", "invalid reset graphics monitor count")); - } - - let monitors = (0..monitor_count) - .map(|_| Monitor::decode(src)) - .collect::, _>>()?; - - let pdu = Self { - width, - height, - monitors, - }; - - read_padding!(src, pdu.padding_size()); - - Ok(pdu) - } -} - -impl ResetGraphicsPdu { - fn padding_size(&self) -> usize { - RESET_GRAPHICS_PDU_SIZE - RDP_GFX_HEADER_SIZE - 12 - self.monitors.iter().map(|m| m.size()).sum::() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MapSurfaceToOutputPdu { - pub surface_id: u16, - pub output_origin_x: u32, - pub output_origin_y: u32, -} - -impl MapSurfaceToOutputPdu { - const NAME: &'static str = "MapSurfaceToOutputPdu"; - - const FIXED_PART_SIZE: usize = 2 /* surfaceId */ + 2 /* reserved */ + 4 /* OutOriginX */ + 4 /* OutOriginY */; -} - -impl Encode for MapSurfaceToOutputPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.surface_id); - dst.write_u16(0); // reserved - dst.write_u32(self.output_origin_x); - dst.write_u32(self.output_origin_y); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for MapSurfaceToOutputPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let _reserved = src.read_u16(); - let output_origin_x = src.read_u32(); - let output_origin_y = src.read_u32(); - - Ok(Self { - surface_id, - output_origin_x, - output_origin_y, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MapSurfaceToScaledOutputPdu { - pub surface_id: u16, - pub output_origin_x: u32, - pub output_origin_y: u32, - pub target_width: u32, - pub target_height: u32, -} - -impl MapSurfaceToScaledOutputPdu { - const NAME: &'static str = "MapSurfaceToScaledOutputPdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 2 /* reserved */ + 4 /* OutOriginX */ + 4 /* OutOriginY */ + 4 /* TargetWidth */ + 4 /* TargetHeight */; -} - -impl Encode for MapSurfaceToScaledOutputPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.surface_id); - dst.write_u16(0); // reserved - dst.write_u32(self.output_origin_x); - dst.write_u32(self.output_origin_y); - dst.write_u32(self.target_width); - dst.write_u32(self.target_height); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for MapSurfaceToScaledOutputPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let _reserved = src.read_u16(); - let output_origin_x = src.read_u32(); - let output_origin_y = src.read_u32(); - let target_width = src.read_u32(); - let target_height = src.read_u32(); - - Ok(Self { - surface_id, - output_origin_x, - output_origin_y, - target_width, - target_height, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MapSurfaceToScaledWindowPdu { - pub surface_id: u16, - pub window_id: u64, - pub mapped_width: u32, - pub mapped_height: u32, - pub target_width: u32, - pub target_height: u32, -} - -impl MapSurfaceToScaledWindowPdu { - const NAME: &'static str = "MapSurfaceToScaledWindowPdu"; - - const FIXED_PART_SIZE: usize = 2 /* SurfaceId */ + 8 /* WindowId */ + 4 /* MappedWidth */ + 4 /* MappedHeight */ + 4 /* TargetWidth */ + 4 /* TargetHeight */; -} - -impl Encode for MapSurfaceToScaledWindowPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - dst.write_u16(self.surface_id); - dst.write_u64(self.window_id); // reserved - dst.write_u32(self.mapped_width); - dst.write_u32(self.mapped_height); - dst.write_u32(self.target_width); - dst.write_u32(self.target_height); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for MapSurfaceToScaledWindowPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let surface_id = src.read_u16(); - let window_id = src.read_u64(); - let mapped_width = src.read_u32(); - let mapped_height = src.read_u32(); - let target_width = src.read_u32(); - let target_height = src.read_u32(); - - Ok(Self { - surface_id, - window_id, - mapped_width, - mapped_height, - target_width, - target_height, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EvictCacheEntryPdu { - pub cache_slot: u16, -} - -impl EvictCacheEntryPdu { - const NAME: &'static str = "EvictCacheEntryPdu"; - - const FIXED_PART_SIZE: usize = 2; -} - -impl Encode for EvictCacheEntryPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u16(self.cache_slot); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for EvictCacheEntryPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let cache_slot = src.read_u16(); - - Ok(Self { cache_slot }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct StartFramePdu { - pub timestamp: Timestamp, - pub frame_id: u32, -} - -impl StartFramePdu { - const NAME: &'static str = "StartFramePdu"; - - const FIXED_PART_SIZE: usize = Timestamp::FIXED_PART_SIZE + 4 /* FrameId */; -} - -impl Encode for StartFramePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - self.timestamp.encode(dst)?; - dst.write_u32(self.frame_id); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for StartFramePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let timestamp = Timestamp::decode(src)?; - let frame_id = src.read_u32(); - - Ok(Self { timestamp, frame_id }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EndFramePdu { - pub frame_id: u32, -} - -impl EndFramePdu { - const NAME: &'static str = "EndFramePdu"; - - const FIXED_PART_SIZE: usize = 4; -} - -impl Encode for EndFramePdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - dst.write_u32(self.frame_id); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for EndFramePdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let frame_id = src.read_u32(); - - Ok(Self { frame_id }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CapabilitiesConfirmPdu(pub CapabilitySet); - -impl CapabilitiesConfirmPdu { - const NAME: &'static str = "CapabilitiesConfirmPdu"; -} - -impl Encode for CapabilitiesConfirmPdu { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - self.0.encode(dst) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - self.0.size() - } -} - -impl<'a> Decode<'a> for CapabilitiesConfirmPdu { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - let capability_set = CapabilitySet::decode(src)?; - - Ok(Self(capability_set)) - } -} - -#[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] -pub enum Codec1Type { - Uncompressed = 0x0, - RemoteFx = 0x3, - ClearCodec = 0x8, - Planar = 0xa, - Avc420 = 0xb, - Alpha = 0xc, - Avc444 = 0xe, - Avc444v2 = 0xf, -} - -#[repr(u16)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] -pub enum Codec2Type { - RemoteFxProgressive = 0x9, -} - -#[repr(u8)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] -pub enum PixelFormat { - XRgb = 0x20, - ARgb = 0x21, -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub struct Timestamp { - pub milliseconds: u16, - pub seconds: u8, - pub minutes: u8, - pub hours: u16, -} - -impl Timestamp { - const NAME: &'static str = "Timestamp"; - - const FIXED_PART_SIZE: usize = 4; -} - -impl Encode for Timestamp { - fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { - ensure_fixed_part_size!(in: dst); - - let mut timestamp: u32 = 0; - - timestamp.set_bits(..10, u32::from(self.milliseconds)); - timestamp.set_bits(10..16, u32::from(self.seconds)); - timestamp.set_bits(16..22, u32::from(self.minutes)); - timestamp.set_bits(22.., u32::from(self.hours)); - - dst.write_u32(timestamp); - - Ok(()) - } - - fn name(&self) -> &'static str { - Self::NAME - } - - fn size(&self) -> usize { - Self::FIXED_PART_SIZE - } -} - -impl<'a> Decode<'a> for Timestamp { - fn decode(src: &mut ReadCursor<'a>) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let timestamp = src.read_u32(); - - let milliseconds = timestamp.get_bits(..10) as u16; - let seconds = timestamp.get_bits(10..16) as u8; - let minutes = timestamp.get_bits(16..22) as u8; - let hours = timestamp.get_bits(22..) as u16; - - Ok(Self { - milliseconds, - seconds, - minutes, - hours, - }) - } -} diff --git a/crates/ironrdp-pdu/src/rdp/vc.rs b/crates/ironrdp-pdu/src/rdp/vc/mod.rs similarity index 57% rename from crates/ironrdp-pdu/src/rdp/vc.rs rename to crates/ironrdp-pdu/src/rdp/vc/mod.rs index 7d1665404e..81c0e217ca 100644 --- a/crates/ironrdp-pdu/src/rdp/vc.rs +++ b/crates/ironrdp-pdu/src/rdp/vc/mod.rs @@ -1,20 +1,14 @@ -pub mod dvc; - #[cfg(test)] mod tests; -use std::{io, str}; - use bitflags::bitflags; -use ironrdp_core::{ensure_fixed_part_size, Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor}; -use thiserror::Error; - -use crate::PduError; +use ironrdp_core::{Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size}; const CHANNEL_PDU_HEADER_SIZE: usize = 8; /// Channel PDU Header (CHANNEL_PDU_HEADER) #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ChannelPduHeader { /// The total length in bytes of the uncompressed channel data, excluding this header /// @@ -53,7 +47,7 @@ impl<'de> Decode<'de> for ChannelPduHeader { ensure_fixed_part_size!(in: src); let total_length = src.read_u32(); - let flags = ChannelControlFlags::from_bits_truncate(src.read_u32()); + let flags = ChannelControlFlags::from_bits_retain(src.read_u32()); Ok(Self { length: total_length, flags, @@ -63,6 +57,7 @@ impl<'de> Decode<'de> for ChannelPduHeader { bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + #[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] pub struct ChannelControlFlags: u32 { const FLAG_FIRST = 0x0000_0001; const FLAG_LAST = 0x0000_0002; @@ -74,43 +69,7 @@ bitflags! { const PACKET_AT_FRONT = 0x0040_0000; const PACKET_FLUSHED = 0x0080_0000; const COMPRESSION_TYPE_MASK = 0x000F_0000; - } -} - -#[derive(Debug, Error)] -pub enum ChannelError { - #[error("IO error")] - IOError(#[from] io::Error), - #[error("from UTF-8 error")] - FromUtf8Error(#[from] std::string::FromUtf8Error), - #[error("invalid channel PDU header")] - InvalidChannelPduHeader, - #[error("invalid channel total data length")] - InvalidChannelTotalDataLength, - #[error("invalid DVC PDU type")] - InvalidDvcPduType, - #[error("invalid DVC id length value")] - InvalidDVChannelIdLength, - #[error("invalid DVC data length value")] - InvalidDvcDataLength, - #[error("invalid DVC capabilities version")] - InvalidDvcCapabilitiesVersion, - #[error("invalid DVC message size")] - InvalidDvcMessageSize, - #[error("invalid DVC total message size: actual ({actual}) > expected ({expected})")] - InvalidDvcTotalMessageSize { actual: usize, expected: usize }, - #[error("PDU error: {0}")] - Pdu(PduError), -} - -impl From for ChannelError { - fn from(e: PduError) -> Self { - Self::Pdu(e) - } -} -impl From for io::Error { - fn from(e: ChannelError) -> io::Error { - io::Error::other(format!("Virtual channel error: {e}")) + const _ = !0; } } diff --git a/crates/ironrdp-pdu/src/rdp/vc/tests.rs b/crates/ironrdp-pdu/src/rdp/vc/tests.rs index b781bc4e5b..ef31a096cf 100644 --- a/crates/ironrdp-pdu/src/rdp/vc/tests.rs +++ b/crates/ironrdp-pdu/src/rdp/vc/tests.rs @@ -1,17 +1,16 @@ +use std::sync::LazyLock; + use ironrdp_core::{decode, encode_vec}; -use lazy_static::lazy_static; use super::*; const CHANNEL_CHUNK_LENGTH_DEFAULT: u32 = 1600; const CHANNEL_PDU_HEADER_BUFFER: [u8; CHANNEL_PDU_HEADER_SIZE] = [0x40, 0x06, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00]; -lazy_static! { - static ref CHANNEL_PDU_HEADER: ChannelPduHeader = ChannelPduHeader { - length: CHANNEL_CHUNK_LENGTH_DEFAULT, - flags: ChannelControlFlags::FLAG_FIRST, - }; -} +static CHANNEL_PDU_HEADER: LazyLock = LazyLock::new(|| ChannelPduHeader { + length: CHANNEL_CHUNK_LENGTH_DEFAULT, + flags: ChannelControlFlags::FLAG_FIRST, +}); #[test] fn from_buffer_correct_parses_channel_header() { diff --git a/crates/ironrdp-pdu/src/tpdu.rs b/crates/ironrdp-pdu/src/tpdu.rs index f2fe040db8..d140ce7083 100644 --- a/crates/ironrdp-pdu/src/tpdu.rs +++ b/crates/ironrdp-pdu/src/tpdu.rs @@ -1,6 +1,6 @@ use ironrdp_core::{ - ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, unexpected_message_type_err, ReadCursor, - WriteCursor, + ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, invalid_field_err, read_padding, + unexpected_message_type_err, }; use crate::tpkt::TpktHeader; @@ -16,9 +16,7 @@ impl TpduCode { pub const DISCONNECT_REQUEST: Self = Self(0x80); pub const DATA: Self = Self(0xF0); pub const ERROR: Self = Self(0x70); -} -impl TpduCode { pub fn header_fixed_part_size(self) -> usize { if self == TpduCode::DATA { TpduHeader::DATA_FIXED_PART_SIZE diff --git a/crates/ironrdp-pdu/src/tpkt.rs b/crates/ironrdp-pdu/src/tpkt.rs index 95a96f4781..2665dc7767 100644 --- a/crates/ironrdp-pdu/src/tpkt.rs +++ b/crates/ironrdp-pdu/src/tpkt.rs @@ -1,5 +1,5 @@ use ironrdp_core::{ - ensure_fixed_part_size, read_padding, unsupported_version_err, write_padding, ReadCursor, WriteCursor, + ReadCursor, WriteCursor, ensure_fixed_part_size, read_padding, unsupported_version_err, write_padding, }; use crate::{DecodeResult, EncodeResult}; diff --git a/crates/ironrdp-pdu/src/utils.rs b/crates/ironrdp-pdu/src/utils.rs index fca9288e5b..397f2e085e 100644 --- a/crates/ironrdp-pdu/src/utils.rs +++ b/crates/ironrdp-pdu/src/utils.rs @@ -2,18 +2,20 @@ use core::fmt::Debug; use core::ops::Add; use byteorder::{LittleEndian, ReadBytesExt as _}; -use ironrdp_core::{ensure_size, invalid_field_err, other_err, ReadCursor, WriteCursor}; -use num_derive::{FromPrimitive, ToPrimitive}; +use ironrdp_core::{ReadCursor, WriteCursor, ensure_size, invalid_field_err, other_err}; +use num_derive::FromPrimitive; use crate::{DecodeResult, EncodeResult}; pub fn split_u64(value: u64) -> (u32, u32) { - let bytes = value.to_le_bytes(); - let (low, high) = bytes.split_at(size_of::()); - ( - u32::from_le_bytes(low.try_into().unwrap()), - u32::from_le_bytes(high.try_into().unwrap()), - ) + #[expect(clippy::missing_panics_doc, reason = "unreachable panic (checked integer downcast)")] + let low = + u32::try_from(value & 0xFFFF_FFFF).expect("masking with 0xFFFF_FFFF ensures that the value fits into u32"); + + #[expect(clippy::missing_panics_doc, reason = "unreachable panic (checked integer downcast)")] + let high = u32::try_from(value >> 32).expect("(u64 >> 32) fits into u32"); + + (low, high) } pub fn combine_u64(lo: u32, hi: u32) -> u64 { @@ -32,6 +34,8 @@ pub fn to_utf16_bytes(value: &str) -> Vec { pub fn from_utf16_bytes(mut value: &[u8]) -> String { let mut value_u16 = vec![0x00; value.len() / 2]; + + #[expect(clippy::missing_panics_doc, reason = "unreachable panic (prior constrain)")] value .read_u16_into::(value_u16.as_mut()) .expect("read_u16_into cannot fail at this point"); @@ -39,12 +43,23 @@ pub fn from_utf16_bytes(mut value: &[u8]) -> String { String::from_utf16_lossy(value_u16.as_ref()) } -#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive, ToPrimitive)] +#[repr(u16)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] pub enum CharacterSet { Ansi = 1, Unicode = 2, } +impl CharacterSet { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + pub fn as_u16(self) -> u16 { + self as u16 + } +} + // Read a string from the cursor, using the specified character set. // // If read_null_terminator is true, the string will be read until a null terminator is found. @@ -96,6 +111,7 @@ pub fn read_string_from_cursor( let str_buffer = &mut slice; let mut u16_buffer = vec![0u16; str_buffer.len() / 2]; + #[expect(clippy::missing_panics_doc, reason = "unreachable panic (prior constrain)")] str_buffer .read_u16_into::(u16_buffer.as_mut()) .expect("BUG: str_buffer is always even for UTF16"); @@ -285,7 +301,12 @@ where ) } -// Utility function that panics on overflow +/// Utility function that panics on overflow +/// +/// # Panics +/// +/// Panics if sum of values overflows. +// FIXME: Is it really something we want to expose from ironrdp-pdu? pub fn strict_sum(values: &[T]) -> T where T: CheckedAdd + Copy + Debug, diff --git a/crates/ironrdp-pdu/src/x224.rs b/crates/ironrdp-pdu/src/x224.rs index 1c84dcab36..6372ca6d49 100644 --- a/crates/ironrdp-pdu/src/x224.rs +++ b/crates/ironrdp-pdu/src/x224.rs @@ -1,12 +1,13 @@ use std::borrow::Cow; use ironrdp_core::{ - ensure_size, invalid_field_err, Decode, DecodeResult, Encode, EncodeResult, IntoOwned, ReadCursor, WriteCursor, + Decode, DecodeResult, Encode, EncodeResult, IntoOwned, ReadCursor, WriteCursor, cast_length, ensure_size, + invalid_field_err, }; use crate::tpdu::{TpduCode, TpduHeader}; use crate::tpkt::TpktHeader; -use crate::{impl_x224_pdu_borrowing, Pdu}; +use crate::{Pdu, impl_x224_pdu_borrowing}; pub trait X224Pdu<'de>: Sized { const X224_NAME: &'static str; @@ -42,16 +43,16 @@ where ensure_size!(in: dst, size: packet_length); TpktHeader { - packet_length: u16::try_from(packet_length).unwrap(), + packet_length: cast_length!("packet length", packet_length)?, } .write(dst)?; - TpduHeader { - li: u8::try_from(T::TPDU_CODE.header_fixed_part_size() + self.0.tpdu_header_variable_part_size() - 1) - .unwrap(), - code: T::TPDU_CODE, - } - .write(dst)?; + let li = cast_length!( + "length indicator", + (T::TPDU_CODE.header_fixed_part_size() + self.0.tpdu_header_variable_part_size() - 1) + )?; + + TpduHeader { li, code: T::TPDU_CODE }.write(dst)?; self.0.x224_body_encode(dst) } diff --git a/crates/ironrdp-propertyset/CHANGELOG.md b/crates/ironrdp-propertyset/CHANGELOG.md new file mode 100644 index 0000000000..7831314e13 --- /dev/null +++ b/crates/ironrdp-propertyset/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [[0.1.0](https://github.com/Devolutions/IronRDP/releases/tag/ironrdp-propertyset-v0.1.0)] - 2026-07-10 + +Initial release. diff --git a/crates/ironrdp-propertyset/Cargo.toml b/crates/ironrdp-propertyset/Cargo.toml index 4cb05bb65a..3d2a06a6bd 100644 --- a/crates/ironrdp-propertyset/Cargo.toml +++ b/crates/ironrdp-propertyset/Cargo.toml @@ -3,8 +3,8 @@ name = "ironrdp-propertyset" version = "0.1.0" readme = "README.md" description = "A key-value store for configuration options" -publish = false # TODO: publish edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -16,8 +16,5 @@ categories.workspace = true doctest = false test = false -[dependencies] -tracing = { version = "0.1", features = ["log"] } - [lints] workspace = true diff --git a/crates/ironrdp-propertyset/src/lib.rs b/crates/ironrdp-propertyset/src/lib.rs index faa60a538d..54f77b6399 100644 --- a/crates/ironrdp-propertyset/src/lib.rs +++ b/crates/ironrdp-propertyset/src/lib.rs @@ -9,8 +9,6 @@ use alloc::collections::BTreeMap; use alloc::string::String; use core::fmt::{self, Display}; -use tracing::debug; - pub type Key = Cow<'static, str>; /// Key-value store for configuration keys. @@ -26,35 +24,27 @@ impl PropertySet { pub fn insert(&mut self, key: impl Into, value: impl Into) -> Option { let (key, value) = (key.into(), value.into()); - debug!("PropertySet::insert({key}, {value})"); self.inner.insert(key, value) } pub fn remove(&mut self, key: &str) -> Option { - let value = self.inner.remove(key); - - match &value { - Some(value) => debug!("PropertySet::remove({key}) = {value}"), - None => debug!("PropertySet::remove({key}) = None"), - } - - value + self.inner.remove(key) } pub fn get<'a, V: ExtractFrom<&'a Value>>(&'a self, key: &str) -> Option { - let value = self.inner.get(key); - - match &value { - Some(value) => debug!("PropertySet::get({key}) = {value}"), - None => debug!("PropertySet::get({key}) = None"), - } - - value.and_then(|val| V::extract_from(val, private::Token)) + self.inner.get(key).and_then(|val| V::extract_from(val, private::Token)) } pub fn iter(&self) -> impl Iterator { self.inner.iter() } + + /// Merges all entries from `other` into this set, overwriting existing keys (last writer wins). + pub fn merge(&mut self, other: &PropertySet) { + for (key, value) in &other.inner { + self.inner.insert(key.clone(), value.clone()); + } + } } impl IntoIterator for PropertySet { diff --git a/crates/ironrdp-rdcleanpath/CHANGELOG.md b/crates/ironrdp-rdcleanpath/CHANGELOG.md index ef774cfbfd..57da670c5a 100644 --- a/crates/ironrdp-rdcleanpath/CHANGELOG.md +++ b/crates/ironrdp-rdcleanpath/CHANGELOG.md @@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.2.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdcleanpath-v0.2.1...ironrdp-rdcleanpath-v0.2.2)] - 2026-06-05 + +## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdcleanpath-v0.2.0...ironrdp-rdcleanpath-v0.2.1)] - 2025-10-02 + +### Features + +- Human-readable descriptions for RDCleanPath errors (#999) ([18c81ed5d8](https://github.com/Devolutions/IronRDP/commit/18c81ed5d8d3bf13b3d10fe15209233c0c10bb62)) + + More munging to give human-readable webclient-side errors for + RDCleanPath general/negotiation errors, including strings for WSA and + TLS and HTTP error conditions. + +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdcleanpath-v0.1.3...ironrdp-rdcleanpath-v0.2.0)] - 2025-08-29 + +### Features + +- [**breaking**] Extend helper API for handling negotiation errors (#930) ([ca11e338d7](https://github.com/Devolutions/IronRDP/commit/ca11e338d7231c86f60a110627a5d864377d8594)) + + - Helper for proxies creating an RDCleanPath error with server response. + - Helper for clients to handle these. + ## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdcleanpath-v0.1.2...ironrdp-rdcleanpath-v0.1.3)] - 2025-03-12 ### Build diff --git a/crates/ironrdp-rdcleanpath/Cargo.toml b/crates/ironrdp-rdcleanpath/Cargo.toml index 17f4e0fbd7..7c88376bc8 100644 --- a/crates/ironrdp-rdcleanpath/Cargo.toml +++ b/crates/ironrdp-rdcleanpath/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-rdcleanpath" -version = "0.1.3" +version = "0.2.2" readme = "README.md" description = "RDCleanPath PDU structure used by IronRDP web client and Devolutions Gateway" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true diff --git a/crates/ironrdp-rdcleanpath/src/lib.rs b/crates/ironrdp-rdcleanpath/src/lib.rs index daff4c1cda..b884ba8538 100644 --- a/crates/ironrdp-rdcleanpath/src/lib.rs +++ b/crates/ironrdp-rdcleanpath/src/lib.rs @@ -30,18 +30,128 @@ pub struct RDCleanPathErr { impl fmt::Display for RDCleanPathErr { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "RDCleanPath error (code {})", self.error_code)?; + let error_description = match self.error_code { + GENERAL_ERROR_CODE => "general error", + NEGOTIATION_ERROR_CODE => "negotiation error", + _ => "unknown error", + }; + write!(f, "{error_description} (code {})", self.error_code)?; if let Some(http_status_code) = self.http_status_code { - write!(f, " [HTTP status = {http_status_code}]")?; + let description = match http_status_code { + 200 => "OK", + 400 => "bad request", + 401 => "unauthorized", + 403 => "forbidden", + 404 => "not found", + 405 => "method not allowed", + 408 => "request timeout", + 409 => "conflict", + 410 => "gone", + 413 => "payload too large", + 414 => "URI too long", + 422 => "unprocessable entity", + 429 => "too many requests", + 500 => "internal server error", + 501 => "not implemented", + 502 => "bad gateway", + 503 => "service unavailable", + 504 => "gateway timeout", + 505 => "HTTP version not supported", + _ => "unknown HTTP status", + }; + write!(f, "; HTTP {http_status_code} {description}")?; } if let Some(wsa_last_error) = self.wsa_last_error { - write!(f, " [WSA last error = {wsa_last_error}]")?; + let description = match wsa_last_error { + 10004 => "interrupted system call", + 10009 => "bad file descriptor", + 10013 => "permission denied", + 10014 => "bad address", + 10022 => "invalid argument", + 10024 => "too many open files", + 10035 => "resource temporarily unavailable", + 10036 => "operation now in progress", + 10037 => "operation already in progress", + 10038 => "socket operation on nonsocket", + 10039 => "destination address required", + 10040 => "message too long", + 10041 => "protocol wrong type for socket", + 10042 => "bad protocol option", + 10043 => "protocol not supported", + 10044 => "socket type not supported", + 10045 => "operation not supported", + 10046 => "protocol family not supported", + 10047 => "address family not supported by protocol family", + 10048 => "address already in use", + 10049 => "cannot assign requested address", + 10050 => "network is down", + 10051 => "network is unreachable", + 10052 => "network dropped connection on reset", + 10053 => "software caused connection abort", + 10054 => "connection reset by peer", + 10055 => "no buffer space available", + 10056 => "socket is already connected", + 10057 => "socket is not connected", + 10058 => "cannot send after socket shutdown", + 10060 => "connection timed out", + 10061 => "connection refused", + 10064 => "host is down", + 10065 => "no route to host", + 10067 => "too many processes", + 10091 => "network subsystem is unavailable", + 10092 => "Winsock version not supported", + 10093 => "successful WSAStartup not yet performed", + 10101 => "graceful shutdown in progress", + 10109 => "class type not found", + 11001 => "host not found", + 11002 => "nonauthoritative host not found", + 11003 => "this is a nonrecoverable error", + 11004 => "valid name, no data record of requested type", + _ => "unknown WSA error", + }; + write!(f, "; WSA {wsa_last_error} {description}")?; } if let Some(tls_alert_code) = self.tls_alert_code { - write!(f, " [TLS alert = {tls_alert_code}]")?; + let description = match tls_alert_code { + 0 => "close notify", + 10 => "unexpected message", + 20 => "bad record MAC", + 21 => "decryption failed", + 22 => "record overflow", + 30 => "decompression failure", + 40 => "handshake failure", + 41 => "no certificate", + 42 => "bad certificate", + 43 => "unsupported certificate", + 44 => "certificate revoked", + 45 => "certificate expired", + 46 => "certificate unknown", + 47 => "illegal parameter", + 48 => "unknown CA", + 49 => "access denied", + 50 => "decode error", + 51 => "decrypt error", + 60 => "export restriction", + 70 => "protocol version", + 71 => "insufficient security", + 80 => "internal error", + 90 => "user canceled", + 100 => "no renegotiation", + 109 => "missing extension", + 110 => "unsupported extension", + 111 => "certificate unobtainable", + 112 => "unrecognized name", + 113 => "bad certificate status response", + 114 => "bad certificate hash value", + 115 => "unknown PSK identity", + 116 => "certificate required", + 120 => "no application protocol", + _ => "unknown TLS alert", + }; + write!(f, "; TLS alert {tls_alert_code} {description}")?; } Ok(()) @@ -412,7 +522,10 @@ impl From for RDCleanPathPdu { wsa_last_error: None, tls_alert_code: None, }), - x224_connection_pdu: Some(OctetString::new(x224_connection_response).unwrap()), + x224_connection_pdu: Some( + OctetString::new(x224_connection_response) + .expect("x224_connection_response smaller than u32::MAX (256 MiB)"), + ), ..Default::default() }, } diff --git a/crates/ironrdp-rdpdr-native/CHANGELOG.md b/crates/ironrdp-rdpdr-native/CHANGELOG.md index 148a88331a..bf89103bd6 100644 --- a/crates/ironrdp-rdpdr-native/CHANGELOG.md +++ b/crates/ironrdp-rdpdr-native/CHANGELOG.md @@ -6,13 +6,48 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-native-v0.1.2...ironrdp-rdpdr-native-v0.2.0)] - 2025-03-12 +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-native-v0.6.0...ironrdp-rdpdr-native-v0.7.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-rdpdr` public dependency to 0.7 + +- [**breaking**] Update `ironrdp-svc` public dependency to 0.8 + + + +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-native-v0.5.0...ironrdp-rdpdr-native-v0.6.0)] - 2026-05-27 + +### Bug Fixes + +- Model CreateDisposition as enum instead of bitflags ([#1145](https://github.com/Devolutions/IronRDP/issues/1145)) ([c4f87aa417](https://github.com/Devolutions/IronRDP/commit/c4f87aa417e83c9cf6d1550c877ea3facb2f9a59)) + + CreateDisposition values (FILE_SUPERSEDE through FILE_OVERWRITE_IF) are + mutually exclusive integers 0 through 5, not combinable bit flags. + Modeling them with the bitflags macro causes subtle correctness issues. ### Build +- Bump nix from 0.30.1 to 0.31.1 ([#1085](https://github.com/Devolutions/IronRDP/issues/1085)) ([e92135dc0d](https://github.com/Devolutions/IronRDP/commit/e92135dc0d46bb3217ad26fcb82651c29e9c43c4)) + + +## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-native-v0.4.0...ironrdp-rdpdr-native-v0.5.0)] - 2025-12-18 + + +## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-native-v0.3.0...ironrdp-rdpdr-native-v0.4.0)] - 2025-08-29 + +### Build + +- Bump nix to 0.30 ([971ad922a5](https://github.com/Devolutions/IronRDP/commit/971ad922a51f78511243aaa885acdd8b1ed94b27)) - Bump ironrdp-pdu +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-native-v0.1.2...ironrdp-rdpdr-native-v0.2.0)] - 2025-03-12 +### Build + +- Bump ironrdp-pdu ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-native-v0.1.1...ironrdp-rdpdr-native-v0.1.2)] - 2025-03-12 diff --git a/crates/ironrdp-rdpdr-native/Cargo.toml b/crates/ironrdp-rdpdr-native/Cargo.toml index 20f59eb46e..0a16fe5b63 100644 --- a/crates/ironrdp-rdpdr-native/Cargo.toml +++ b/crates/ironrdp-rdpdr-native/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-rdpdr-native" -version = "0.3.0" +version = "0.7.0" readme = "README.md" description = "Native RDPDR static channel backend implementations for IronRDP" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -16,9 +17,9 @@ doctest = false test = false [target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public -ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.3" } # public -nix = { version = "0.30", features = ["fs", "dir"] } +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public +ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.7" } # public +nix = { version = "0.31", features = ["fs", "dir"] } tracing = { version = "0.1", features = ["log"] } diff --git a/crates/ironrdp-rdpdr-native/src/nix/backend.rs b/crates/ironrdp-rdpdr-native/src/nix/backend.rs index 30721497cf..ee73825df2 100644 --- a/crates/ironrdp-rdpdr-native/src/nix/backend.rs +++ b/crates/ironrdp-rdpdr-native/src/nix/backend.rs @@ -4,11 +4,11 @@ use std::os::fd::{AsFd, AsRawFd}; use std::os::unix::fs::MetadataExt; use ironrdp_core::impl_as_any; -use ironrdp_pdu::{encode_err, PduResult}; +use ironrdp_pdu::{PduResult, encode_err}; +use ironrdp_rdpdr::RdpdrBackend; +use ironrdp_rdpdr::pdu::RdpdrPdu; use ironrdp_rdpdr::pdu::efs::*; use ironrdp_rdpdr::pdu::esc::{ScardCall, ScardIoCtlCode}; -use ironrdp_rdpdr::pdu::RdpdrPdu; -use ironrdp_rdpdr::RdpdrBackend; use ironrdp_svc::SvcMessage; use nix::dir::{Dir, OwningIter}; use tracing::{debug, warn}; @@ -631,14 +631,12 @@ fn make_create_drive_resp( file_id: u32, ) -> PduResult> { let io_response = DeviceIoResponse::new(device_io_request, NtStatus::SUCCESS); - let information = match create_disposation { - CreateDisposition::FILE_CREATE - | CreateDisposition::FILE_SUPERSEDE - | CreateDisposition::FILE_OPEN - | CreateDisposition::FILE_OVERWRITE => Information::FILE_SUPERSEDED, - CreateDisposition::FILE_OPEN_IF => Information::FILE_OPENED, - CreateDisposition::FILE_OVERWRITE_IF => Information::FILE_OVERWRITTEN, - _ => Information::empty(), + let information = if create_disposation == CreateDisposition::FILE_OPEN_IF { + Information::FILE_OPENED + } else if create_disposation == CreateDisposition::FILE_OVERWRITE_IF { + Information::FILE_OVERWRITTEN + } else { + Information::FILE_SUPERSEDED }; let res = RdpdrPdu::DeviceCreateResponse(DeviceCreateResponse { device_io_reply: io_response, diff --git a/crates/ironrdp-rdpdr/CHANGELOG.md b/crates/ironrdp-rdpdr/CHANGELOG.md index e5e5f8dad7..ec466f8009 100644 --- a/crates/ironrdp-rdpdr/CHANGELOG.md +++ b/crates/ironrdp-rdpdr/CHANGELOG.md @@ -6,35 +6,89 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-v0.6.0...ironrdp-rdpdr-v0.7.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + +- [**breaking**] Update `ironrdp-svc` public dependency to 0.8 + + + +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-v0.5.0...ironrdp-rdpdr-v0.6.0)] - 2026-05-27 + +### Features + +- Notify RdpdrBackend of 'User Logged On' Messages ([#1211](https://github.com/Devolutions/IronRDP/issues/1211)) ([1a09dbaca9](https://github.com/Devolutions/IronRDP/commit/1a09dbaca9dd5d35025ee50aaa645100222be189)) + +- Add Web RDPDR virtual printer support ([#1230](https://github.com/Devolutions/IronRDP/issues/1230)) ([14b1cef9cb](https://github.com/Devolutions/IronRDP/commit/14b1cef9cbbd0d8ef5e1fc8c73a3003a5e9f9bc2)) + + Adds RDPDR virtual printer redirection for web sessions, enabling the web client to announce a redirected printer, receive server print jobs over RDPDR, and deliver completed PostScript jobs to a browser callback. + +### Bug Fixes + +- Model CreateDisposition as enum instead of bitflags ([#1145](https://github.com/Devolutions/IronRDP/issues/1145)) ([c4f87aa417](https://github.com/Devolutions/IronRDP/commit/c4f87aa417e83c9cf6d1550c877ea3facb2f9a59)) + + CreateDisposition values (FILE_SUPERSEDE through FILE_OVERWRITE_IF) are + mutually exclusive integers 0 through 5, not combinable bit flags. + Modeling them with the bitflags macro causes subtle correctness issues. + +- Replace all from_bits_truncate with from_bits_retain ([#1144](https://github.com/Devolutions/IronRDP/issues/1144)) ([353e30ddfd](https://github.com/Devolutions/IronRDP/commit/353e30ddfdaafc897db10b8663e364ef7775a7fd)) + + from_bits_truncate silently discards unknown bits, which breaks the + encode/decode round-trip property. This matters for fuzzing because a + PDU that decodes and re-encodes should produce identical bytes. + from_bits_retain preserves all bits, including those not yet defined in + our bitflags types, so the round-trip property holds. + +### Build + +- Bump the patch group across 1 directory with 2 updates ([#1222](https://github.com/Devolutions/IronRDP/issues/1222)) ([3fe6d157e0](https://github.com/Devolutions/IronRDP/commit/3fe6d157e0b55bddfdac20af290a6cfa6e550576)) + + +## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-v0.4.1...ironrdp-rdpdr-v0.5.0)] - 2025-12-18 + +### Bug Fixes + +- Fix incorrect padding when parsing NDR strings ([#1015](https://github.com/Devolutions/IronRDP/issues/1015)) ([a0a3e750c9](https://github.com/Devolutions/IronRDP/commit/a0a3e750c9e4ee9c73b957fbcb26dbc59e57d07d)) + + When parsing Network Data Representation (NDR) messages, we're supposed + to account for padding at the end of strings to remain aligned on a + 4-byte boundary. The existing code doesn't seem to cover all cases, and + the resulting misalignment causes misleading errors when processing the + rest of the message. + +## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-v0.4.0...ironrdp-rdpdr-v0.4.1)] - 2025-09-04 + +### Features + +- Support device removal (#947) ([50574c570f](https://github.com/Devolutions/IronRDP/commit/50574c570f6e44d264153337e5f87a5313f190e6)) + ## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-v0.2.0...ironrdp-rdpdr-v0.3.0)] - 2025-05-27 ### Features - Add USER_LOGGEDON flag support ([5e78f91713](https://github.com/Devolutions/IronRDP/commit/5e78f917132a174bdd5d8711beb1744de1bd265a)) - ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-v0.1.3...ironrdp-rdpdr-v0.2.0)] - 2025-03-12 ### Build - Bump ironrdp-pdu - ## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-v0.1.2...ironrdp-rdpdr-v0.1.3)] - 2025-03-12 ### Build - Update dependencies (#695) ([c21fa44fd6](https://github.com/Devolutions/IronRDP/commit/c21fa44fd6f3c6a6b74788ff68e83133c1314caa)) - ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-v0.1.1...ironrdp-rdpdr-v0.1.2)] - 2025-01-28 ### Documentation - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpdr-v0.1.0...ironrdp-rdpdr-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-rdpdr/Cargo.toml b/crates/ironrdp-rdpdr/Cargo.toml index da29b2a754..b6dfa959da 100644 --- a/crates/ironrdp-rdpdr/Cargo.toml +++ b/crates/ironrdp-rdpdr/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-rdpdr" -version = "0.3.0" +version = "0.7.0" readme = "README.md" description = "RDPDR channel implementation." edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -16,12 +17,12 @@ doctest = false test = false [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-error = { path = "../ironrdp-error", version = "0.1" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public tracing = { version = "0.1", features = ["log"] } -bitflags = "2.9" +bitflags = "2.11" [lints] workspace = true diff --git a/crates/ironrdp-rdpdr/README.md b/crates/ironrdp-rdpdr/README.md index 7f18a040b4..1ff9c1bc5c 100644 --- a/crates/ironrdp-rdpdr/README.md +++ b/crates/ironrdp-rdpdr/README.md @@ -7,4 +7,18 @@ Implements the RDPDR static virtual channel as described in This crate is part of the [IronRDP] project. +## Virtual Printers + +`Rdpdr::with_printer` announces a PostScript virtual printer using +`MS Publisher Imagesetter` as the default server-side driver. This matches +FreeRDP's default CUPS printer driver for PostScript redirection and keeps the +client format-agnostic: printer IRPs deliver the raw job bytes to the backend. +Printer devices are advertised after the server sends `RDPDR_USER_LOGGEDON_PDU`; +pre-logon announces remain reserved for special devices such as smart cards. + +Use `Rdpdr::with_printer_driver` when the target host needs a different +installed printer driver. The selected driver controls the document format the +server writes to the redirected printer, so consumers are responsible for any +PostScript-to-PDF or other conversion step before presenting the job to a user. + [IronRDP]: https://github.com/Devolutions/IronRDP diff --git a/crates/ironrdp-rdpdr/src/backend/mod.rs b/crates/ironrdp-rdpdr/src/backend/mod.rs index 6c0f1519a0..c72999b05a 100644 --- a/crates/ironrdp-rdpdr/src/backend/mod.rs +++ b/crates/ironrdp-rdpdr/src/backend/mod.rs @@ -6,7 +6,12 @@ use ironrdp_core::AsAny; use ironrdp_pdu::PduResult; use ironrdp_svc::SvcMessage; -use crate::pdu::efs::{DeviceControlRequest, ServerDeviceAnnounceResponse, ServerDriveIoRequest}; +use crate::Rdpdr; +use crate::pdu::RdpdrPdu; +use crate::pdu::efs::{ + DeviceCloseResponse, DeviceControlRequest, DeviceIoResponse, NtStatus, PrinterIoRequest, + ServerDeviceAnnounceResponse, ServerDriveIoRequest, +}; use crate::pdu::esc::{ScardCall, ScardIoCtlCode}; /// OS-specific device redirection backend interface. @@ -14,4 +19,31 @@ pub trait RdpdrBackend: AsAny + fmt::Debug + Send { fn handle_server_device_announce_response(&mut self, pdu: ServerDeviceAnnounceResponse) -> PduResult<()>; fn handle_scard_call(&mut self, req: DeviceControlRequest, call: ScardCall) -> PduResult<()>; fn handle_drive_io_request(&mut self, req: ServerDriveIoRequest) -> PduResult>; + + fn handle_user_logged_on(&mut self, _rdpdr: &mut Rdpdr) -> PduResult> { + Ok(Vec::new()) + } + + /// Handle a server-initiated IRP addressed to a printer device. + /// + /// `req` carries the fully-decoded printer IRP. Printers only see + /// [`PrinterIoRequest::Create`] / [`PrinterIoRequest::Write`] / + /// [`PrinterIoRequest::Close`] on the backend path. Unsupported printer + /// major functions are completed by the SVC processor before the backend + /// is called. + /// + /// Return the PDUs to send back on the RDPDR channel — + /// typically a [`crate::pdu::efs::DeviceIoResponse`]-wrapped + /// `DeviceCreateResponse` / `DeviceWriteResponse` / + /// `DeviceCloseResponse`. Returning an empty `Vec` is allowed + /// when the backend has already queued a response out of band + /// and/or wants to defer. + fn handle_printer_io_request(&mut self, req: PrinterIoRequest) -> PduResult> { + let device_io_request = req.into_device_io_request(); + Ok(vec![SvcMessage::from(RdpdrPdu::DeviceCloseResponse( + DeviceCloseResponse { + device_io_response: DeviceIoResponse::new(device_io_request, NtStatus::NOT_SUPPORTED), + }, + ))]) + } } diff --git a/crates/ironrdp-rdpdr/src/lib.rs b/crates/ironrdp-rdpdr/src/lib.rs index b6cad92073..26fc9658cd 100644 --- a/crates/ironrdp-rdpdr/src/lib.rs +++ b/crates/ironrdp-rdpdr/src/lib.rs @@ -1,29 +1,27 @@ #![cfg_attr(doc, doc = include_str!("../README.md"))] #![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] #![allow(clippy::arithmetic_side_effects)] // FIXME: remove -#![allow(clippy::cast_lossless)] // FIXME: remove -#![allow(clippy::cast_possible_truncation)] // FIXME: remove -#![allow(clippy::cast_possible_wrap)] // FIXME: remove -#![allow(clippy::cast_sign_loss)] // FIXME: remove -use ironrdp_core::{decode_cursor, impl_as_any, ReadCursor}; +use ironrdp_core::{ReadCursor, impl_as_any}; use ironrdp_pdu::gcc::ChannelName; -use ironrdp_pdu::{decode_err, pdu_other_err, PduResult}; +use ironrdp_pdu::{PduResult, decode_err, pdu_other_err}; use ironrdp_svc::{CompressionCondition, SvcClientProcessor, SvcMessage, SvcProcessor}; use pdu::efs::{ - Capabilities, ClientDeviceListAnnounce, ClientNameRequest, ClientNameRequestUnicodeFlag, CoreCapability, - CoreCapabilityKind, DeviceControlRequest, DeviceIoRequest, DeviceType, Devices, ServerDeviceAnnounceResponse, - VersionAndIdPdu, VersionAndIdPduKind, + AnyIoCtlCode, Capabilities, ClientDeviceListAnnounce, ClientDeviceListRemove, ClientNameRequest, + ClientNameRequestUnicodeFlag, CoreCapability, CoreCapabilityKind, DEFAULT_PRINTER_DRIVER_NAME, + DeviceAnnounceHeader, DeviceCloseResponse, DeviceControlRequest, DeviceControlResponse, DeviceIoRequest, + DeviceIoResponse, DeviceType, Devices, MajorFunction, NtStatus, PrinterIoRequest, ServerDeviceAnnounceResponse, + VERSION_MINOR_RDP51, VersionAndIdPdu, VersionAndIdPduKind, }; use pdu::esc::{ScardCall, ScardIoCtlCode}; -use pdu::RdpdrPdu; +use pdu::{PacketId, RdpdrPdu, SharedHeader}; use tracing::{debug, trace, warn}; pub mod backend; pub mod pdu; -pub use self::backend::noop::NoopRdpdrBackend; pub use self::backend::RdpdrBackend; +pub use self::backend::noop::NoopRdpdrBackend; use crate::pdu::efs::ServerDriveIoRequest; /// The RDPDR channel as specified in [\[MS-RDPEFS\]]. @@ -46,7 +44,8 @@ pub struct Rdpdr { /// /// All devices not of the type [`DeviceType::Filesystem`] must be declared here. device_list: Devices, - backend: Box, + post_logon_devices_announced: bool, + backend: Option>, } impl_as_any!(Rdpdr); @@ -60,7 +59,8 @@ impl Rdpdr { computer_name, capabilities: Capabilities::new(), device_list: Devices::new(), - backend, + post_logon_devices_announced: false, + backend: Some(backend), } } @@ -87,6 +87,33 @@ impl Rdpdr { self } + /// Adds printer redirection capability and announces a single + /// virtual printer under `device_id` with the user-visible name + /// `print_name`. + /// + /// Uses [`DEFAULT_PRINTER_DRIVER_NAME`] as the PostScript driver and + /// marks the device as the session's default printer — see + /// [`pdu::efs::Devices::add_printer`] for the rationale. IRPs + /// targeting this device are dispatched to + /// [`RdpdrBackend::handle_printer_io_request`]. + #[must_use] + pub fn with_printer(self, device_id: u32, print_name: String) -> Self { + self.with_printer_driver(device_id, print_name, DEFAULT_PRINTER_DRIVER_NAME.to_owned()) + } + + /// Adds printer redirection capability with an explicit server-side + /// printer driver name. + /// + /// Use this when the target host needs a driver other than + /// [`DEFAULT_PRINTER_DRIVER_NAME`] for the redirected printer queue. + #[must_use] + pub fn with_printer_driver(mut self, device_id: u32, print_name: String, driver_name: String) -> Self { + self.capabilities.add_printer(); + self.device_list + .add_printer_with_driver(device_id, print_name, driver_name); + self + } + /// Users should call this method to announce a new drive to the server. It's the caller's responsibility /// to take the returned [`ClientDeviceListAnnounce`] and send it to the server. pub fn add_drive(&mut self, device_id: u32, name: String) -> ClientDeviceListAnnounce { @@ -94,12 +121,18 @@ impl Rdpdr { ClientDeviceListAnnounce::new_drive(device_id, name) } + pub fn remove_device(&mut self, device_id: u32) -> Option { + Some(ClientDeviceListRemove::remove_device( + self.device_list.remove_device(device_id)?, + )) + } + pub fn downcast_backend(&self) -> Option<&T> { - self.backend.as_any().downcast_ref::() + self.backend.as_ref()?.as_any().downcast_ref::() } pub fn downcast_backend_mut(&mut self) -> Option<&mut T> { - self.backend.as_any_mut().downcast_mut::() + self.backend.as_mut()?.as_any_mut().downcast_mut::() } fn handle_server_announce(&mut self, req: VersionAndIdPdu) -> PduResult> { @@ -119,16 +152,60 @@ impl Rdpdr { ]) } - fn handle_server_capability(&mut self, _req: CoreCapability) -> PduResult> { - let res = RdpdrPdu::CoreCapability(CoreCapability::new_response(self.capabilities.clone_inner())); - trace!("sending {:?}", res); - Ok(vec![SvcMessage::from(res)]) + fn handle_server_capability(&mut self, req: CoreCapability) -> PduResult> { + let client_capability_response = + RdpdrPdu::CoreCapability(CoreCapability::new_response(self.capabilities.clone_supported_by(&req))); + trace!("sending {:?}", client_capability_response); + Ok(vec![SvcMessage::from(client_capability_response)]) + } + + fn handle_client_id_confirm(&mut self, req: VersionAndIdPdu) -> PduResult> { + let announce_all_devices = req.version_minor == VERSION_MINOR_RDP51; + if announce_all_devices { + self.post_logon_devices_announced = true; + } + + let device_list = self + .device_list + .clone_inner() + .into_iter() + .filter(|device| announce_all_devices || Self::is_pre_logon_device(device)) + .collect::>(); + + if device_list.is_empty() { + return Ok(Vec::new()); + } + + Self::announce_devices(device_list) + } + + fn announce_post_logon_devices(&mut self) -> PduResult> { + if self.post_logon_devices_announced { + return Ok(Vec::new()); + } + + self.post_logon_devices_announced = true; + + let device_list = self + .device_list + .clone_inner() + .into_iter() + .filter(|device| !Self::is_pre_logon_device(device)) + .collect::>(); + + if device_list.is_empty() { + return Ok(Vec::new()); + } + + Self::announce_devices(device_list) + } + + fn is_pre_logon_device(device: &DeviceAnnounceHeader) -> bool { + matches!(device.device_type(), DeviceType::Smartcard) } - fn handle_client_id_confirm(&mut self) -> PduResult> { - let res = RdpdrPdu::ClientDeviceListAnnounce(ClientDeviceListAnnounce { - device_list: self.device_list.clone_inner(), - }); + fn announce_devices(device_list: Vec) -> PduResult> { + let res = RdpdrPdu::ClientDeviceListAnnounce(ClientDeviceListAnnounce { device_list }); trace!("sending {:?}", res); Ok(vec![SvcMessage::from(res)]) } @@ -137,20 +214,36 @@ impl Rdpdr { &mut self, pdu: ServerDeviceAnnounceResponse, ) -> PduResult> { - self.backend.handle_server_device_announce_response(pdu)?; + self.backend + .as_mut() + .ok_or_else(|| pdu_other_err!("missing rdpdr backend"))? + .handle_server_device_announce_response(pdu)?; Ok(Vec::new()) } + fn handle_user_logged_on(&mut self) -> PduResult> { + let mut backend = self.backend.take().expect("missing rdpdr backend"); + let res = backend.handle_user_logged_on(self); + self.backend = Some(backend); + let mut messages = res?; + messages.extend(self.announce_post_logon_devices()?); + trace!("sending {:?}", messages); + Ok(messages) + } + fn handle_device_io_request( &mut self, dev_io_req: DeviceIoRequest, src: &mut ReadCursor<'_>, ) -> PduResult> { - match self - .device_list - .for_device_type(dev_io_req.device_id) - .map_err(|e| decode_err!(e))? - { + let Ok(device_type) = self.device_list.for_device_type(dev_io_req.device_id) else { + // > If a request is received that contains a DeviceId field that was not announced by the client or has + // > been removed, the request SHOULD be ignored by the implementation. + // source: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpefs/9925f2e4-8d5a-4777-a41a-7ba6ef6e8bff + return Ok(vec![]); + }; + + match device_type { DeviceType::Smartcard => { let req = DeviceControlRequest::::decode(dev_io_req, src).map_err(|e| decode_err!(e))?; @@ -159,7 +252,10 @@ impl Rdpdr { debug!(?req); debug!(?req.io_control_code, ?call); - self.backend.handle_scard_call(req, call)?; + self.backend + .as_mut() + .ok_or_else(|| pdu_other_err!("missing rdpdr backend"))? + .handle_scard_call(req, call)?; Ok(Vec::new()) } @@ -168,8 +264,42 @@ impl Rdpdr { debug!(?req); - Ok(self.backend.handle_drive_io_request(req)?) + Ok(self + .backend + .as_mut() + .ok_or_else(|| pdu_other_err!("missing rdpdr backend"))? + .handle_drive_io_request(req)?) } + DeviceType::Print => match dev_io_req.major_function { + MajorFunction::DeviceControl => { + let req = + DeviceControlRequest::::decode(dev_io_req, src).map_err(|e| decode_err!(e))?; + debug!(?req, "Completing printer device-control IRP"); + + Ok(vec![SvcMessage::from(RdpdrPdu::DeviceControlResponse( + DeviceControlResponse::new(req, NtStatus::SUCCESS, None), + ))]) + } + MajorFunction::Create | MajorFunction::Write | MajorFunction::Close => { + let req = PrinterIoRequest::decode(dev_io_req, src).map_err(|e| decode_err!(e))?; + debug!(?req, "Dispatching printer IRP to backend"); + self.backend + .as_mut() + .ok_or_else(|| pdu_other_err!("missing rdpdr backend"))? + .handle_printer_io_request(req) + } + _ => { + debug!( + major = ?dev_io_req.major_function, + minor = ?dev_io_req.minor_function, + file_id = dev_io_req.file_id, + completion_id = dev_io_req.completion_id, + "Completing unsupported printer IRP" + ); + + Ok(vec![Self::unsupported_printer_io_response(dev_io_req)]) + } + }, _ => { // This should never happen, as we only announce devices that we support. warn!(?dev_io_req, "received packet for unsupported device type"); @@ -177,6 +307,12 @@ impl Rdpdr { } } } + + fn unsupported_printer_io_response(device_io_request: DeviceIoRequest) -> SvcMessage { + SvcMessage::from(RdpdrPdu::DeviceCloseResponse(DeviceCloseResponse { + device_io_response: DeviceIoResponse::new(device_io_request, NtStatus::NOT_SUPPORTED), + })) + } } impl SvcProcessor for Rdpdr { @@ -190,7 +326,16 @@ impl SvcProcessor for Rdpdr { fn process(&mut self, payload: &[u8]) -> PduResult> { let mut src = ReadCursor::new(payload); - let pdu = decode_cursor::(&mut src).map_err(|e| decode_err!(e))?; + let header = SharedHeader::decode(&mut src).map_err(|e| decode_err!(e))?; + if matches!(header.packet_id, PacketId::PrnCacheData | PacketId::PrnUsingXps) { + warn!( + packet_id = ?header.packet_id, + "Ignoring unhandled RDPDR printer-cache PDU" + ); + return Ok(vec![]); + } + + let pdu = RdpdrPdu::decode_body(header, &mut src).map_err(|e| decode_err!(e))?; debug!("Received {:?}", pdu); match pdu { @@ -201,15 +346,16 @@ impl SvcProcessor for Rdpdr { self.handle_server_capability(pdu) } RdpdrPdu::VersionAndIdPdu(pdu) if pdu.kind == VersionAndIdPduKind::ServerClientIdConfirm => { - self.handle_client_id_confirm() + self.handle_client_id_confirm(pdu) } RdpdrPdu::ServerDeviceAnnounceResponse(pdu) => self.handle_server_device_announce_response(pdu), RdpdrPdu::DeviceIoRequest(pdu) => self.handle_device_io_request(pdu, &mut src), - RdpdrPdu::UserLoggedon => Ok(vec![]), + RdpdrPdu::UserLoggedon => self.handle_user_logged_on(), // TODO: This can eventually become a `_ => {}` block, but being explicit for now // to make sure we don't miss handling new RdpdrPdu variants here during active development. RdpdrPdu::ClientNameRequest(_) | RdpdrPdu::ClientDeviceListAnnounce(_) + | RdpdrPdu::ClientDeviceListRemove(_) | RdpdrPdu::VersionAndIdPdu(_) | RdpdrPdu::CoreCapability(_) | RdpdrPdu::DeviceControlResponse(_) diff --git a/crates/ironrdp-rdpdr/src/pdu/efs.rs b/crates/ironrdp-rdpdr/src/pdu/efs.rs index bfde792bc1..5d6094f51d 100644 --- a/crates/ironrdp-rdpdr/src/pdu/efs.rs +++ b/crates/ironrdp-rdpdr/src/pdu/efs.rs @@ -7,11 +7,11 @@ use core::fmt::{Debug, Display}; use bitflags::bitflags; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, invalid_field_err_with_source, - unsupported_value_err, DecodeError, DecodeResult, EncodeResult, ReadCursor, WriteCursor, + DecodeError, DecodeResult, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_fixed_part_size, ensure_size, + invalid_field_err, invalid_field_err_with_source, unsupported_value_err, }; -use ironrdp_pdu::utils::{decode_string, encoded_str_len, from_utf16_bytes, write_string_to_cursor, CharacterSet}; -use ironrdp_pdu::{read_padding, write_padding, PduError}; +use ironrdp_pdu::utils::{CharacterSet, decode_string, encoded_str_len, from_utf16_bytes, write_string_to_cursor}; +use ironrdp_pdu::{PduError, read_padding, write_padding}; use tracing::error; use super::esc::rpce; @@ -92,7 +92,7 @@ impl VersionAndIdPdu { "VersionAndIdPdu::decode", "PacketId", "invalid value" - )) + )); } }; @@ -154,9 +154,15 @@ impl ClientNameRequest { pub fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); + + let encoded_computer_name_length = cast_length!( + "encoded computer name length", + encoded_str_len(self.computer_name(), self.unicode_flag().into(), true) + )?; + dst.write_u32(self.unicode_flag().into()); dst.write_u32(0); // // CodePage (4 bytes): it MUST be set to 0 - dst.write_u32(encoded_str_len(self.computer_name(), self.unicode_flag().into(), true) as u32); + dst.write_u32(encoded_computer_name_length); write_string_to_cursor(dst, self.computer_name(), self.unicode_flag().into(), true) } @@ -186,6 +192,10 @@ impl From for CharacterSet { } impl From for u32 { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] fn from(val: ClientNameRequestUnicodeFlag) -> Self { val as u32 } @@ -238,7 +248,7 @@ impl CoreCapability { "CoreCapability::decode", "PacketId", "invalid value" - )) + )); } }; @@ -294,10 +304,39 @@ impl Capabilities { this } - pub fn clone_inner(&mut self) -> Vec { + pub fn clone_inner(&self) -> Vec { self.0.clone() } + pub fn clone_supported_by(&self, server_capability: &CoreCapability) -> Vec { + let mut capabilities = self + .0 + .iter() + .copied() + .filter(|capability| { + capability.header.cap_type == CapabilityType::General + || server_capability + .capabilities + .iter() + .any(|server_capability| server_capability.header.cap_type == capability.header.cap_type) + }) + .collect::>(); + + let special_type_device_cap = capabilities + .iter() + .filter(|capability| capability.header.cap_type == CapabilityType::Smartcard) + .fold(0u32, |count, _| count.saturating_add(1)); + + for capability in capabilities.iter_mut() { + if let CapabilityData::General(general_capability) = &mut capability.capability_data { + general_capability.special_type_device_cap = special_type_device_cap; + break; + } + } + + capabilities + } + pub fn add_smartcard(&mut self) { self.push(CapabilityMessage::new_smartcard()); self.increment_special_devices(); @@ -307,6 +346,10 @@ impl Capabilities { self.push(CapabilityMessage::new_drive()); } + pub fn add_printer(&mut self) { + self.push(CapabilityMessage::new_printer()); + } + fn add_general(&mut self, special_type_device_cap: u32) { self.push(CapabilityMessage::new_general(special_type_device_cap)); } @@ -394,6 +437,16 @@ impl CapabilityMessage { } } + /// Creates a new [`PRINTER_CAPS_SET`]. + /// + /// [`PRINTER_CAPS_SET`]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpefs/28d2d0f8-f7c8-4c8a-94c2-cdf04ff60b7a + pub fn new_printer() -> Self { + Self { + header: CapabilityHeader::new_printer(), + capability_data: CapabilityData::Printer, + } + } + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size()); self.header.encode(dst)?; @@ -431,7 +484,7 @@ impl CapabilityHeader { fn new_general() -> Self { Self { cap_type: CapabilityType::General, - length: (Self::SIZE + GeneralCapabilitySet::SIZE) as u16, + length: u16::try_from(Self::SIZE + GeneralCapabilitySet::SIZE).expect("value fits into u16"), version: GENERAL_CAPABILITY_VERSION_02, } } @@ -439,7 +492,7 @@ impl CapabilityHeader { fn new_smartcard() -> Self { Self { cap_type: CapabilityType::Smartcard, - length: Self::SIZE as u16, + length: u16::try_from(Self::SIZE).expect("value fits into u16"), version: SMARTCARD_CAPABILITY_VERSION_01, } } @@ -447,11 +500,19 @@ impl CapabilityHeader { fn new_drive() -> Self { Self { cap_type: CapabilityType::Drive, - length: Self::SIZE as u16, + length: u16::try_from(Self::SIZE).expect("value fits into u16"), version: DRIVE_CAPABILITY_VERSION_02, } } + fn new_printer() -> Self { + Self { + cap_type: CapabilityType::Printer, + length: u16::try_from(Self::SIZE).expect("value fits into u16"), + version: PRINTER_CAPABILITY_VERSION_01, + } + } + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { ensure_size!(in: src, size: Self::SIZE); let cap_type: CapabilityType = src.read_u16().try_into()?; @@ -490,6 +551,10 @@ enum CapabilityType { } impl From for u16 { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] fn from(cap_type: CapabilityType) -> Self { cap_type as u16 } @@ -501,6 +566,36 @@ pub const GENERAL_CAPABILITY_VERSION_02: u32 = 0x0000_0002; pub const SMARTCARD_CAPABILITY_VERSION_01: u32 = 0x0000_0001; /// DRIVE_CAPABILITY_VERSION_02 pub const DRIVE_CAPABILITY_VERSION_02: u32 = 0x0000_0002; +/// PRINTER_CAPABILITY_VERSION_01 +/// +/// Windows hosts accept v1 and v2 here; v1 is the lowest-common-denominator +/// and has no additional body, which is the usual choice for virtual printers. +pub const PRINTER_CAPABILITY_VERSION_01: u32 = 0x0000_0001; + +/// [MS-RDPEPC 2.2.2.3] RDPDR_PRINTER_ANNOUNCE flag: ASCII encoding for names. +/// +/// [2.2.2.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpepc/2057a02f-57d5-47db-9a32-e337ac3f50e9 +pub const RDPDR_PRINTER_ANNOUNCE_FLAG_ASCII: u32 = 0x0000_0001; +/// [MS-RDPEPC 2.2.2.3] RDPDR_PRINTER_ANNOUNCE flag: this printer is the default. +pub const RDPDR_PRINTER_ANNOUNCE_FLAG_DEFAULTPRINTER: u32 = 0x0000_0002; +/// [MS-RDPEPC 2.2.2.3] RDPDR_PRINTER_ANNOUNCE flag: the printer is a network printer. +pub const RDPDR_PRINTER_ANNOUNCE_FLAG_NETWORKPRINTER: u32 = 0x0000_0004; +/// [MS-RDPEPC 2.2.2.3] RDPDR_PRINTER_ANNOUNCE flag: the printer is a Terminal Services printer. +pub const RDPDR_PRINTER_ANNOUNCE_FLAG_TSPRINTER: u32 = 0x0000_0008; +/// [MS-RDPEPC 2.2.2.3] RDPDR_PRINTER_ANNOUNCE flag: the server should expect XPS output. +pub const RDPDR_PRINTER_ANNOUNCE_FLAG_XPSFORMAT: u32 = 0x0000_0010; + +/// Default server-side printer driver announced for PostScript virtual printers. +/// +/// `MS Publisher Imagesetter` matches FreeRDP's default CUPS printer driver +/// for PostScript redirection. If a target host does not have this driver +/// installed, callers can use the explicit-driver helpers to advertise a +/// different server-side driver. +pub const DEFAULT_PRINTER_DRIVER_NAME: &str = "MS Publisher Imagesetter"; +/// Server-side PDF printer driver used by the macOS 14+ printer default. +/// +/// The target Windows host still needs this driver installed. +pub const MICROSOFT_PRINT_TO_PDF_DRIVER_NAME: &str = "Microsoft Print to PDF"; impl TryFrom for CapabilityType { type Error = DecodeError; @@ -699,6 +794,8 @@ bitflags! { | Self::RDPDR_IRP_MJ_DIRECTORY_CONTROL.bits() | Self::RDPDR_IRP_MJ_LOCK_CONTROL.bits(); + + const _ = !0; } } @@ -713,6 +810,8 @@ bitflags! { const RDPDR_CLIENT_DISPLAY_NAME_PDU = 0x0000_0002; /// Allow the server to send a Server User Logged On packet. const RDPDR_USER_LOGGEDON_PDU = 0x0000_0004; + + const _ = !0; } } @@ -725,6 +824,8 @@ bitflags! { /// Allows the server to send multiple simultaneous read or write requests /// on the same file from a redirected file system. const ENABLE_ASYNCIO = 0x0000_0001; + + const _ = !0; } } @@ -736,6 +837,7 @@ bitflags! { /// /// [Server Client ID Confirm (section 2.2.2.6)]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpefs/bbbb9666-6994-4cf6-8e65-0d46eb319c6e /// [2.2.2.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpefs/d6fe6d1b-c145-4a6f-99aa-4fe3cdcea398 +pub const VERSION_MINOR_RDP51: u16 = 0x0005; pub const VERSION_MINOR_12: u16 = 0x000C; pub const VERSION_MAJOR: u16 = 0x0001; @@ -782,6 +884,46 @@ impl ClientDeviceListAnnounce { } } +/// [2.2.3.2] Client Device List Remove (DR_DEVICELIST_REMOVE) +/// +/// [2.2.3.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpefs/13bd4c0a-e674-47a5-b317-50a835defb55 +#[derive(Debug, PartialEq, Clone)] +pub struct ClientDeviceListRemove { + pub device_list: Vec, +} + +impl ClientDeviceListRemove { + const FIXED_PART_SIZE: usize = size_of::(); // DeviceCount + + pub(crate) fn remove_device(device_id: u32) -> Self { + Self { + device_list: vec![device_id], + } + } + + pub fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + dst.write_u32(cast_length!( + "ClientDeviceListRemove", + "DeviceCount", + self.device_list.len() + )?); + + for dev in self.device_list.iter() { + dst.write_u32(*dev) + } + + Ok(()) + } + + pub fn name(&self) -> &'static str { + "DR_DEVICELIST_REMOVE" + } + + pub fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.device_list.len() * size_of::() + } +} + #[derive(Debug, PartialEq, Clone)] pub struct Devices(Vec); @@ -798,6 +940,32 @@ impl Devices { self.push(DeviceAnnounceHeader::new_drive(device_id, name)); } + /// Announce a virtual printer device to the server. + /// + /// Uses sensible defaults for web-client / virtual-printer scenarios: + /// flagged as the session's default printer, empty PnP name (so the + /// server resolves the driver from `DriverName`), and + /// [`DEFAULT_PRINTER_DRIVER_NAME`] as the PostScript driver. + /// See [`DeviceAnnounceHeader::new_printer`] for the rationale. + /// Callers needing a different driver should use + /// [`DeviceAnnounceHeader::new_printer_with_driver`]. + pub fn add_printer(&mut self, device_id: u32, print_name: String) { + self.add_printer_with_driver(device_id, print_name, DEFAULT_PRINTER_DRIVER_NAME.to_owned()); + } + + /// Announce a virtual printer device with an explicit server-side driver. + pub fn add_printer_with_driver(&mut self, device_id: u32, print_name: String, driver_name: String) { + self.push(DeviceAnnounceHeader::new_printer_with_driver( + device_id, + print_name, + driver_name, + )); + } + + pub fn remove_device(&mut self, device_id: u32) -> Option { + self.remove(device_id) + } + /// Returns the [`DeviceType`] for the given device ID. pub fn for_device_type(&self, device_id: u32) -> DecodeResult { if let Some(device_type) = self.0.iter().find(|d| d.device_id == device_id).map(|d| d.device_type) { @@ -815,7 +983,19 @@ impl Devices { self.0.push(device); } - pub fn clone_inner(&mut self) -> Vec { + fn remove(&mut self, device: u32) -> Option { + Some( + self.0 + .remove( + self.0 + .iter() + .position(|d: &DeviceAnnounceHeader| d.device_id == device)?, + ) + .device_id, + ) + } + + pub fn clone_inner(&self) -> Vec { self.0.clone() } } @@ -869,6 +1049,132 @@ impl DeviceAnnounceHeader { } } + /// Construct a printer announce with sensible defaults; see + /// [`Devices::add_printer`] for the policy. Callers with custom + /// driver requirements should use + /// [`Self::new_printer_with_driver`]. + pub fn new_printer(device_id: u32, print_name: String) -> Self { + // `MS Publisher Imagesetter` matches FreeRDP's default CUPS + // PostScript printer driver, so the announce resolves without + // needing `UseUniversalPrinterDriverFirst` or the XPS Services + // feature on hosts where that driver is installed. + // + // Trade-off: the server's spooler emits PostScript (not XPS) for + // jobs on this queue, so consumers of + // [`RdpdrBackend::handle_printer_io_request`] need a PostScript-to-PDF + // pipeline (e.g. Ghostscript). Print bytes are passed through + // verbatim; IronRDP itself is format-agnostic. + Self::new_printer_with_driver(device_id, print_name, DEFAULT_PRINTER_DRIVER_NAME.to_owned()) + } + + /// Construct a printer announce with an explicit driver name. + /// + /// `driver_name` is used by the server to locate a print-driver + /// package. [`DEFAULT_PRINTER_DRIVER_NAME`] is the default used by + /// [`Self::new_printer`]. Other commonly-shipping drivers: + /// `"Microsoft XPS Document Writer"` (XPS; may need + /// `UseUniversalPrinterDriverFirst` to fall back to Easy Print when + /// the v3 variant isn't installed), `"Microsoft Print to PDF"` + /// (Windows 10+), and `"Generic / Text Only"` (all versions). + /// + /// Do not pass `"Remote Desktop Easy Print"`; it's a server-side-only + /// substitute driver and some Windows builds silently drop announces + /// that name it directly. + /// + /// # Panics + /// + /// Panics if the encoded UTF-16LE of any name field exceeds + /// `u32::MAX` bytes. Real printer names are well under 200 bytes, + /// so this is unreachable in practice. + pub fn new_printer_with_driver(device_id: u32, print_name: String, driver_name: String) -> Self { + // [MS-RDPEPC 2.2.2.3] RDPDR_PRINTER_ANNOUNCE device_data layout: + // Flags u32 LE + // CodePage u32 LE (reserved; MUST be ignored) + // PnPNameLen u32 LE (bytes, includes trailing UTF-16 NUL) + // DriverNameLen u32 LE (bytes, includes trailing UTF-16 NUL) + // PrintNameLen u32 LE (bytes, includes trailing UTF-16 NUL) + // CachedFieldsLen u32 LE + // PnPName UTF-16LE, NUL-terminated + // DriverName UTF-16LE, NUL-terminated + // PrintName UTF-16LE, NUL-terminated + // CachedFields opaque bytes + // + // FreeRDP leaves PnPName empty for this PostScript path and lets the + // server resolve the queue from DriverName + PrintName. Matching that + // behavior avoids exercising server-side PnP-name edge cases. + // + // [2.2.2.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpepc/2057a02f-57d5-47db-9a32-e337ac3f50e9 + + let driver_name_bytes = utf16le_with_nul(&driver_name); + let print_name_bytes = utf16le_with_nul(&print_name); + + // [MS-RDPEPC 2.2.2.3] Flags. We match FreeRDP's PostScript + // redirection behavior: mark the queue as the session default and as + // a network printer. We intentionally leave the others off: + // - XPSFORMAT (0x10): advertises *client* XPS-consumption support; + // our driver is PostScript so this is irrelevant and could nudge + // mixed-driver hosts toward the XPS path. + // - TSPRINTER (0x08): "printer is from a previous terminal server + // session" (i.e. nested-hop re-redirection). We're a first-hop + // client, so setting it would be a lie. + let flags: u32 = RDPDR_PRINTER_ANNOUNCE_FLAG_DEFAULTPRINTER | RDPDR_PRINTER_ANNOUNCE_FLAG_NETWORKPRINTER; + let code_page: u32 = 0; + let pnp_name_len: u32 = 0; + let cached_fields_len: u32 = 0; + + let mut device_data = Vec::with_capacity( + 4 /* Flags */ + + 4 /* CodePage */ + + 4 /* PnPNameLen */ + + 4 /* DriverNameLen */ + + 4 /* PrintNameLen */ + + 4 /* CachedFieldsLen */ + + driver_name_bytes.len() + + print_name_bytes.len(), + ); + device_data.extend_from_slice(&flags.to_le_bytes()); + device_data.extend_from_slice(&code_page.to_le_bytes()); + device_data.extend_from_slice(&pnp_name_len.to_le_bytes()); + device_data.extend_from_slice( + &u32::try_from(driver_name_bytes.len()) + .expect("DriverName length fits in u32") + .to_le_bytes(), + ); + device_data.extend_from_slice( + &u32::try_from(print_name_bytes.len()) + .expect("PrintName length fits in u32") + .to_le_bytes(), + ); + device_data.extend_from_slice(&cached_fields_len.to_le_bytes()); + device_data.extend_from_slice(&driver_name_bytes); + device_data.extend_from_slice(&print_name_bytes); + + return Self { + device_type: DeviceType::Print, + device_id, + // Per spec: when DeviceDataLength is non-zero PreferredDosName + // is ignored, but it still needs a value that wouldn't trip + // the character validator (forbidden: < > " / \ |; colon only + // at end). "PRN1" is the conventional choice for the first + // redirected printer. + preferred_dos_name: PreferredDosName("PRN1".to_owned()), + device_data, + }; + + fn utf16le_with_nul(s: &str) -> Vec { + let mut out = Vec::with_capacity((s.len() + 1) * 2 /* 2 bytes per UTF-16 unit */); + for unit in s.encode_utf16() { + out.extend_from_slice(&unit.to_le_bytes()); + } + out.extend_from_slice(&[0, 0] /* UTF-16 NUL terminator */); + out + } + } + + pub(crate) fn device_type(&self) -> DeviceType { + self.device_type + } + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { dst.write_u32(self.device_type.into()); dst.write_u32(self.device_id); @@ -910,7 +1216,9 @@ impl PreferredDosName { fn format(&self) -> String { let mut name: &str = &self.0; if name.len() > 7 { - name = &name[..7]; + name = name + .get(..7) + .expect("index is guaranteed to be on a UTF-8 boundary for a string of ASCII characters"); } format!("{name:\x00<8}") } @@ -932,6 +1240,10 @@ pub enum DeviceType { } impl From for u32 { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] fn from(device_type: DeviceType) -> Self { device_type as u32 } @@ -1153,6 +1465,10 @@ impl TryFrom for MajorFunction { } impl From for u32 { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] fn from(major_function: MajorFunction) -> Self { major_function as u32 } @@ -1195,12 +1511,6 @@ impl From for u32 { } } -impl From for u8 { - fn from(minor_function: MinorFunction) -> Self { - minor_function.0 as u8 - } -} - /// [2.2.1.4.5] Device Control Request (DR_CONTROL_REQ) /// /// [2.2.1.4.5]: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpefs/30662c80-ec6e-4ed1-9004-2e6e367bb59f @@ -1273,47 +1583,6 @@ pub struct DeviceControlResponse { pub output_buffer: Option>, } -impl PartialEq for DeviceControlResponse { - fn eq(&self, other: &Self) -> bool { - if (self.device_io_reply != other.device_io_reply) - || (self.output_buffer.is_some() != other.output_buffer.is_some()) - { - return false; - } - - // If both are `None`, they are equal. - if self.output_buffer.is_none() && other.output_buffer.is_none() { - return true; - } - - // device_io_reply is equal and both output_buffers are Some - - // If the sizes are different, the buffers are not equal. - let self_size = self.output_buffer.as_ref().unwrap().size(); - let other_size = other.output_buffer.as_ref().unwrap().size(); - if self_size != other_size { - return false; - } - - // Sizes are the same. Last check is to encode the output buffers and compare the encoded bytes directly. - let mut self_buf = vec![0u8; self_size]; - let mut other_buf = vec![0u8; other_size]; - self.output_buffer - .as_ref() - .unwrap() - .encode(&mut WriteCursor::new(self_buf.as_mut_slice())) - .unwrap(); - other - .output_buffer - .as_ref() - .unwrap() - .encode(&mut WriteCursor::new(other_buf.as_mut_slice())) - .unwrap(); - - self_buf == other_buf - } -} - impl DeviceControlResponse { const NAME: &'static str = "DR_CONTROL_RSP"; @@ -1536,6 +1805,49 @@ impl From for ServerDriveIoRequest { } } +/// Printer-targeted IRP (subset of [MS-RDPEFS] 2.2.1.4 that virtual printers care about). +/// +/// A printer device sees open/write/close on the print-job path: the server +/// opens a file handle against the virtual device (Create), streams print-job +/// bytes into it (Write, possibly many times), and then closes the handle when +/// the job is finished (Close). Device-control requests are handled directly by +/// the RDPDR SVC processor before a backend is called. +#[derive(Debug, PartialEq, Clone)] +pub enum PrinterIoRequest { + /// Server opened the virtual printer; answer with a [`DeviceCreateResponse`] + /// that stamps a backend-assigned `file_id` and `FILE_OPENED`. + Create(DeviceCreateRequest), + /// Server pushed print-job bytes into the opened handle; answer with a + /// [`DeviceWriteResponse`] echoing the request length. + Write(DeviceWriteRequest), + /// Server finalized the print job; answer with a [`DeviceCloseResponse`] + /// and finalize whatever document buffer the backend accumulated. + Close(DeviceCloseRequest), +} + +impl PrinterIoRequest { + pub fn decode(dev_io_req: DeviceIoRequest, src: &mut ReadCursor<'_>) -> DecodeResult { + match dev_io_req.major_function { + MajorFunction::Create => Ok(Self::Create(DeviceCreateRequest::decode(dev_io_req, src)?)), + MajorFunction::Write => Ok(Self::Write(DeviceWriteRequest::decode(dev_io_req, src)?)), + MajorFunction::Close => Ok(Self::Close(DeviceCloseRequest::decode(dev_io_req))), + _ => Err(invalid_field_err!( + "PrinterIoRequest::decode", + "MajorFunction", + "unsupported value" + )), + } + } + + pub fn into_device_io_request(self) -> DeviceIoRequest { + match self { + Self::Create(req) => req.device_io_request, + Self::Write(req) => req.device_io_request, + Self::Close(req) => req.device_io_request, + } + } +} + /// [2.2.3.3.1] Server Create Drive Request (DR_DRIVE_CREATE_REQ) /// and [2.2.1.4.1] Device Create Request (DR_CREATE_REQ) /// @@ -1563,13 +1875,13 @@ impl DeviceCreateRequest { + 4 // CreateOptions + 4; // PathLength - fn decode(dev_io_req: DeviceIoRequest, src: &mut ReadCursor<'_>) -> DecodeResult { + pub fn decode(dev_io_req: DeviceIoRequest, src: &mut ReadCursor<'_>) -> DecodeResult { ensure_size!(ctx: "DeviceCreateRequest", in: src, size: Self::FIXED_PART_SIZE); let desired_access = DesiredAccess::from_bits_retain(src.read_u32()); let allocation_size = src.read_u64(); let file_attributes = FileAttributes::from_bits_retain(src.read_u32()); let shared_access = SharedAccess::from_bits_retain(src.read_u32()); - let create_disposition = CreateDisposition::from_bits_retain(src.read_u32()); + let create_disposition = CreateDisposition::from(src.read_u32()); let create_options = CreateOptions::from_bits_retain(src.read_u32()); let path_length: usize = cast_length!("DeviceCreateRequest", "path_length", src.read_u32())?; @@ -1651,6 +1963,8 @@ bitflags! { const GENERIC_WRITE = 0x40000000; /// This value indicates a request for the following combination of access flags listed above: FILE_READ_DATA| FILE_READ_ATTRIBUTES| FILE_READ_EA| SYNCHRONIZE| READ_CONTROL. const GENERIC_READ = 0x80000000; + + const _ = !0; } } @@ -1693,24 +2007,41 @@ bitflags! { const FILE_SHARE_READ = 0x00000001; const FILE_SHARE_WRITE = 0x00000002; const FILE_SHARE_DELETE = 0x00000004; + + const _ = !0; } } -bitflags! { - /// Defined in [2.2.13] SMB2 CREATE Request - /// - /// See FreeRDP's [drive_file.c] for context about how these should be interpreted. - /// - /// [2.2.13]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/e8fb45c1-a03d-44ca-b7ae-47385cfd7997 - /// [drive_file.c]: https://github.com/FreeRDP/FreeRDP/blob/511444a65e7aa2f537c5e531fa68157a50c1bd4d/channels/drive/client/drive_file.c#L207 - #[derive(PartialEq, Eq, Debug, Clone)] - pub struct CreateDisposition: u32 { - const FILE_SUPERSEDE = 0x00000000; - const FILE_OPEN = 0x00000001; - const FILE_CREATE = 0x00000002; - const FILE_OPEN_IF = 0x00000003; - const FILE_OVERWRITE = 0x00000004; - const FILE_OVERWRITE_IF = 0x00000005; +/// Defined in [2.2.13] SMB2 CREATE Request +/// +/// Mutually exclusive disposition values (0 through 5), not combinable bit flags. +/// Modeled as a newtype for infallible parsing and round-trip correctness. +/// +/// See FreeRDP's [drive_file.c] for context about how these should be interpreted. +/// +/// [2.2.13]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/e8fb45c1-a03d-44ca-b7ae-47385cfd7997 +/// [drive_file.c]: https://github.com/FreeRDP/FreeRDP/blob/511444a65e7aa2f537c5e531fa68157a50c1bd4d/channels/drive/client/drive_file.c#L207 +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct CreateDisposition(u32); + +impl CreateDisposition { + pub const FILE_SUPERSEDE: Self = Self(0x00000000); + pub const FILE_OPEN: Self = Self(0x00000001); + pub const FILE_CREATE: Self = Self(0x00000002); + pub const FILE_OPEN_IF: Self = Self(0x00000003); + pub const FILE_OVERWRITE: Self = Self(0x00000004); + pub const FILE_OVERWRITE_IF: Self = Self(0x00000005); +} + +impl From for CreateDisposition { + fn from(value: u32) -> Self { + Self(value) + } +} + +impl From for u32 { + fn from(value: CreateDisposition) -> Self { + value.0 } } @@ -1741,6 +2072,8 @@ bitflags! { const FILE_OPEN_REPARSE_POINT = 0x00200000; const FILE_OPEN_NO_RECALL = 0x00400000; const FILE_OPEN_FOR_FREE_SPACE_QUERY = 0x00800000; + + const _ = !0; } } @@ -1788,6 +2121,8 @@ bitflags! { const FILE_OPENED = 0x00000001; /// An existing file was overwritten. const FILE_OVERWRITTEN = 0x00000003; + + const _ = !0; } } @@ -2609,7 +2944,7 @@ impl ServerDriveQueryDirectoryRequest { "ServerDriveQueryDirectoryRequest::decode", "file_info_class_lvl", "received invalid level" - )) + )); } } @@ -2680,11 +3015,7 @@ impl ClientDriveQueryDirectoryResponse { dst.write_u32(cast_length!( "ClientDriveQueryDirectoryResponse", "length", - if self.buffer.is_some() { - self.buffer.as_ref().unwrap().size() - } else { - 0 - } + self.buffer.as_ref().map_or(0, |buf| buf.size()) )?); if let Some(buffer) = &self.buffer { buffer.encode(dst)?; @@ -2737,7 +3068,7 @@ impl ServerDriveQueryVolumeInformationRequest { "ServerDriveQueryVolumeInformationRequest::decode", "fs_info_class_lvl", "received invalid level" - )) + )); } } @@ -3040,6 +3371,8 @@ bitflags! { const FILE_SUPPORT_INTEGRITY_STREAMS = 0x04000000; const FILE_SUPPORTS_BLOCK_REFCOUNTING = 0x08000000; const FILE_SUPPORTS_SPARSE_VDL = 0x10000000; + + const _ = !0; } } @@ -3061,6 +3394,8 @@ bitflags! { const FILE_CHARACTERISTIC_WEBDAV_DEVICE = 0x00002000; const FILE_DEVICE_ALLOW_APPCONTAINER_TRAVERSAL = 0x00020000; const FILE_PORTABLE_DEVICE = 0x0004000; + + const _ = !0; } } @@ -3097,11 +3432,7 @@ impl ClientDriveQueryVolumeInformationResponse { dst.write_u32(cast_length!( "ClientDriveQueryVolumeInformationResponse", "length", - if self.buffer.is_some() { - self.buffer.as_ref().unwrap().size() - } else { - 0 - } + self.buffer.as_ref().map_or(0, |buf| buf.size()) )?); if let Some(buffer) = &self.buffer { buffer.encode(dst)?; @@ -3288,7 +3619,7 @@ impl ServerDriveSetInformationRequest { "ServerDriveSetInformationRequest::decode", "file_information_class_level", "received invalid level" - )) + )); } }; diff --git a/crates/ironrdp-rdpdr/src/pdu/esc.rs b/crates/ironrdp-rdpdr/src/pdu/esc/mod.rs similarity index 98% rename from crates/ironrdp-rdpdr/src/pdu/esc.rs rename to crates/ironrdp-rdpdr/src/pdu/esc/mod.rs index 82c584bdf2..bfe19fa51d 100644 --- a/crates/ironrdp-rdpdr/src/pdu/esc.rs +++ b/crates/ironrdp-rdpdr/src/pdu/esc/mod.rs @@ -7,11 +7,11 @@ pub mod rpce; use bitflags::bitflags; use ironrdp_core::{ - cast_length, ensure_size, invalid_field_err, other_err, DecodeError, DecodeResult, EncodeResult, ReadCursor, - WriteCursor, + DecodeError, DecodeResult, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_size, invalid_field_err, + other_err, }; use ironrdp_pdu::utils::{ - encoded_multistring_len, read_multistring_from_cursor, write_multistring_to_cursor, CharacterSet, + CharacterSet, encoded_multistring_len, read_multistring_from_cursor, write_multistring_to_cursor, }; use tracing::{error, warn}; @@ -574,6 +574,10 @@ impl ReturnCode { } impl From for u32 { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] fn from(val: ReturnCode) -> Self { val as u32 } @@ -901,6 +905,8 @@ bitflags! { const SCARD_STATE_INUSE = 0x0000_0100; const SCARD_STATE_MUTE = 0x0000_0200; const SCARD_STATE_UNPOWERED = 0x0000_0400; + + const _ = !0; } } @@ -1022,6 +1028,8 @@ bitflags! { const SCARD_PROTOCOL_RAW = 0x0001_0000; const SCARD_PROTOCOL_DEFAULT = 0x8000_0000; const SCARD_PROTOCOL_OPTIMAL = 0x0000_0000; + + const _ = !0; } } @@ -1244,7 +1252,7 @@ impl rpce::HeaderlessDecode for TransmitCall { #[derive(Debug, PartialEq, Clone)] pub struct SCardIORequest { pub protocol: CardProtocol, - pub extra_bytes_length: u32, + pub extra_bytes_length: usize, pub extra_bytes: Vec, } @@ -1255,7 +1263,7 @@ impl ndr::Decode for SCardIORequest { { ensure_size!(in: src, size: size_of::() * 2); let protocol = CardProtocol::from_bits_retain(src.read_u32()); - let extra_bytes_length = src.read_u32(); + let extra_bytes_length = cast_length!("SCardIORequest", "extra_bytes_length", src.read_u32())?; let _extra_bytes_ptr = ndr::decode_ptr(src, index)?; let extra_bytes = Vec::new(); Ok(Self { @@ -1267,9 +1275,8 @@ impl ndr::Decode for SCardIORequest { fn decode_value(&mut self, src: &mut ReadCursor<'_>, charset: Option) -> DecodeResult<()> { expect_no_charset(charset)?; - let extra_bytes_length: usize = cast_length!("TransmitCall", "extra_bytes_length", self.extra_bytes_length)?; - ensure_size!(in: src, size: extra_bytes_length); - self.extra_bytes = src.read_slice(extra_bytes_length).to_vec(); + ensure_size!(in: src, size: self.extra_bytes_length); + self.extra_bytes = src.read_slice(self.extra_bytes_length).to_vec(); Ok(()) } } @@ -1277,8 +1284,11 @@ impl ndr::Decode for SCardIORequest { impl ndr::Encode for SCardIORequest { fn encode_ptr(&self, index: &mut u32, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { ensure_size!(in: dst, size: self.size_ptr()); + + let extra_bytes_length = cast_length!("SCardIORequest", "extra_bytes_length", self.extra_bytes_length)?; + dst.write_u32(self.protocol.bits()); - ndr::encode_ptr(Some(self.extra_bytes_length), index, dst) + ndr::encode_ptr(Some(extra_bytes_length), index, dst) } fn encode_value(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { @@ -1292,7 +1302,7 @@ impl ndr::Encode for SCardIORequest { } fn size_value(&self) -> usize { - self.extra_bytes_length as usize + self.extra_bytes_length } } @@ -1485,6 +1495,10 @@ pub enum CardState { } impl From for u32 { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] fn from(val: CardState) -> Self { val as u32 } @@ -1836,10 +1850,7 @@ impl rpce::HeaderlessEncode for GetReaderIconReturn { } fn expect_charset(charset: Option) -> DecodeResult { - if charset.is_none() { - return Err(other_err!("internal error: missing character set")); - } - Ok(charset.unwrap()) + charset.ok_or_else(|| other_err!("internal error: missing character set")) } fn expect_no_charset(charset: Option) -> DecodeResult<()> { diff --git a/crates/ironrdp-rdpdr/src/pdu/esc/ndr.rs b/crates/ironrdp-rdpdr/src/pdu/esc/ndr.rs index 65afde51a0..14bda22ea6 100644 --- a/crates/ironrdp-rdpdr/src/pdu/esc/ndr.rs +++ b/crates/ironrdp-rdpdr/src/pdu/esc/ndr.rs @@ -21,7 +21,7 @@ //! //! [smartcard_pack.c]: https://github.com/FreeRDP/FreeRDP/blob/ff303a9bda911c54ffc1b9f2471acd79c897b075/libfreerdp/utils/smartcard_pack.c -use ironrdp_core::{ensure_size, invalid_field_err, DecodeResult, EncodeResult, ReadCursor, WriteCursor}; +use ironrdp_core::{DecodeResult, EncodeResult, ReadCursor, WriteCursor, ensure_size, invalid_field_err}; use ironrdp_pdu::utils::{self, CharacterSet}; pub trait Decode { @@ -80,17 +80,21 @@ pub fn ptr_size(with_length: bool) -> usize { /// offset fields prefixing the string, as well as any extra padding for a 4-byte aligned /// NULL-terminated string. pub fn read_string_from_cursor(cursor: &mut ReadCursor<'_>, charset: CharacterSet) -> DecodeResult { + const ALIGNMENT: usize = 4; ensure_size!(ctx: "ndr::read_string_from_cursor", in: cursor, size: size_of::() * 3); - let length = cursor.read_u32(); + let _length = cursor.read_u32(); let _offset = cursor.read_u32(); let _length2 = cursor.read_u32(); let string = utils::read_string_from_cursor(cursor, charset, true)?; // Skip padding for 4-byte aligned NULL-terminated string. - if length % 2 != 0 { - ensure_size!(ctx: "ndr::read_string_from_cursor", in: cursor, size: size_of::()); - let _padding = cursor.read_u16(); + let mut pad = cursor.pos(); + let size = (pad + ALIGNMENT - 1) & !(ALIGNMENT - 1); + pad = size - pad; + if pad > 0 { + ensure_size!(ctx: "ndr::read_string_from_cursor", in: cursor, size: pad); + cursor.advance(pad); } Ok(string) diff --git a/crates/ironrdp-rdpdr/src/pdu/esc/rpce.rs b/crates/ironrdp-rdpdr/src/pdu/esc/rpce.rs index 39ec76c25f..2b9c3b728e 100644 --- a/crates/ironrdp-rdpdr/src/pdu/esc/rpce.rs +++ b/crates/ironrdp-rdpdr/src/pdu/esc/rpce.rs @@ -3,7 +3,7 @@ //! [\[MS-RPCE\]: Remote Procedure Call Protocol Extensions]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rpce/290c38b1-92fe-4229-91e6-4fc376610c15 use ironrdp_core::{ - cast_length, ensure_size, invalid_field_err, DecodeError, DecodeResult, EncodeResult, ReadCursor, WriteCursor, + DecodeError, DecodeResult, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_size, invalid_field_err, }; use ironrdp_pdu::utils::CharacterSet; @@ -244,6 +244,10 @@ impl TryFrom for Endianness { } impl From for u8 { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] fn from(endianness: Endianness) -> Self { endianness as u8 } @@ -283,9 +287,7 @@ impl TypeHeader { filler, }) } -} -impl TypeHeader { fn size() -> usize { size_of::() * 2 } @@ -295,9 +297,5 @@ impl TypeHeader { /// to be 8-byte aligned. fn padding_size(pdu: &impl HeaderlessEncode) -> usize { let tail = pdu.size() % 8; - if tail > 0 { - 8 - tail - } else { - 0 - } + if tail > 0 { 8 - tail } else { 0 } } diff --git a/crates/ironrdp-rdpdr/src/pdu/mod.rs b/crates/ironrdp-rdpdr/src/pdu/mod.rs index aa7446fcb6..9ab261b937 100644 --- a/crates/ironrdp-rdpdr/src/pdu/mod.rs +++ b/crates/ironrdp-rdpdr/src/pdu/mod.rs @@ -1,16 +1,17 @@ use core::fmt::{self, Display}; use ironrdp_core::{ - ensure_size, invalid_field_err, unsupported_value_err, Decode, DecodeError, DecodeResult, Encode, EncodeResult, - ReadCursor, WriteCursor, + Decode, DecodeError, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_size, invalid_field_err, + unsupported_value_err, }; use ironrdp_svc::SvcEncode; use self::efs::{ - ClientDeviceListAnnounce, ClientDriveQueryDirectoryResponse, ClientDriveQueryInformationResponse, - ClientDriveQueryVolumeInformationResponse, ClientDriveSetInformationResponse, ClientNameRequest, CoreCapability, - CoreCapabilityKind, DeviceCloseResponse, DeviceControlResponse, DeviceCreateResponse, DeviceIoRequest, - DeviceReadResponse, DeviceWriteResponse, ServerDeviceAnnounceResponse, VersionAndIdPdu, VersionAndIdPduKind, + ClientDeviceListAnnounce, ClientDeviceListRemove, ClientDriveQueryDirectoryResponse, + ClientDriveQueryInformationResponse, ClientDriveQueryVolumeInformationResponse, ClientDriveSetInformationResponse, + ClientNameRequest, CoreCapability, CoreCapabilityKind, DeviceCloseResponse, DeviceControlResponse, + DeviceCreateResponse, DeviceIoRequest, DeviceReadResponse, DeviceWriteResponse, ServerDeviceAnnounceResponse, + VersionAndIdPdu, VersionAndIdPduKind, }; pub mod efs; @@ -22,6 +23,7 @@ pub enum RdpdrPdu { ClientNameRequest(ClientNameRequest), CoreCapability(CoreCapability), ClientDeviceListAnnounce(ClientDeviceListAnnounce), + ClientDeviceListRemove(ClientDeviceListRemove), ServerDeviceAnnounceResponse(ServerDeviceAnnounceResponse), DeviceIoRequest(DeviceIoRequest), DeviceControlResponse(DeviceControlResponse), @@ -73,6 +75,10 @@ impl RdpdrPdu { component: Component::RdpdrCtypCore, packet_id: PacketId::CoreDevicelistAnnounce, }, + RdpdrPdu::ClientDeviceListRemove(_) => SharedHeader { + component: Component::RdpdrCtypCore, + packet_id: PacketId::CoreDevicelistRemove, + }, RdpdrPdu::ServerDeviceAnnounceResponse(_) => SharedHeader { component: Component::RdpdrCtypCore, packet_id: PacketId::CoreDeviceReply, @@ -100,11 +106,8 @@ impl RdpdrPdu { }, } } -} -impl Decode<'_> for RdpdrPdu { - fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { - let header = SharedHeader::decode(src)?; + pub(crate) fn decode_body(header: SharedHeader, src: &mut ReadCursor<'_>) -> DecodeResult { match header.packet_id { PacketId::CoreServerAnnounce => Ok(RdpdrPdu::VersionAndIdPdu(VersionAndIdPdu::decode(header, src)?)), PacketId::CoreServerCapability => Ok(RdpdrPdu::CoreCapability(CoreCapability::decode(header, src)?)), @@ -114,15 +117,22 @@ impl Decode<'_> for RdpdrPdu { )), PacketId::CoreDeviceIoRequest => Ok(RdpdrPdu::DeviceIoRequest(DeviceIoRequest::decode(src)?)), PacketId::CoreUserLoggedon => Ok(RdpdrPdu::UserLoggedon), - _ => Err(unsupported_value_err!( - "RdpdrPdu", + packet_id => Err(unsupported_value_err!( + "RdpdrPdu::decode_body", "PacketId", - header.packet_id.to_string() + format!("{packet_id} ({:#06X})", u16::from(packet_id)) )), } } } +impl Decode<'_> for RdpdrPdu { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let header = SharedHeader::decode(src)?; + Self::decode_body(header, src) + } +} + impl Encode for RdpdrPdu { fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { self.header().encode(dst)?; @@ -132,6 +142,7 @@ impl Encode for RdpdrPdu { RdpdrPdu::ClientNameRequest(pdu) => pdu.encode(dst), RdpdrPdu::CoreCapability(pdu) => pdu.encode(dst), RdpdrPdu::ClientDeviceListAnnounce(pdu) => pdu.encode(dst), + RdpdrPdu::ClientDeviceListRemove(pdu) => pdu.encode(dst), RdpdrPdu::ServerDeviceAnnounceResponse(pdu) => pdu.encode(dst), RdpdrPdu::DeviceIoRequest(pdu) => pdu.encode(dst), RdpdrPdu::DeviceControlResponse(pdu) => pdu.encode(dst), @@ -158,6 +169,7 @@ impl Encode for RdpdrPdu { RdpdrPdu::ClientNameRequest(pdu) => pdu.name(), RdpdrPdu::CoreCapability(pdu) => pdu.name(), RdpdrPdu::ClientDeviceListAnnounce(pdu) => pdu.name(), + RdpdrPdu::ClientDeviceListRemove(pdu) => pdu.name(), RdpdrPdu::ServerDeviceAnnounceResponse(pdu) => pdu.name(), RdpdrPdu::DeviceIoRequest(pdu) => pdu.name(), RdpdrPdu::DeviceControlResponse(pdu) => pdu.name(), @@ -181,6 +193,7 @@ impl Encode for RdpdrPdu { RdpdrPdu::ClientNameRequest(pdu) => pdu.size(), RdpdrPdu::CoreCapability(pdu) => pdu.size(), RdpdrPdu::ClientDeviceListAnnounce(pdu) => pdu.size(), + RdpdrPdu::ClientDeviceListRemove(pdu) => pdu.size(), RdpdrPdu::ServerDeviceAnnounceResponse(pdu) => pdu.size(), RdpdrPdu::DeviceIoRequest(pdu) => pdu.size(), RdpdrPdu::DeviceControlResponse(pdu) => pdu.size(), @@ -215,6 +228,9 @@ impl fmt::Debug for RdpdrPdu { Self::ClientDeviceListAnnounce(it) => { write!(f, "RdpdrPdu({it:?})") } + Self::ClientDeviceListRemove(it) => { + write!(f, "RdpdrPdu({it:?})") + } Self::ServerDeviceAnnounceResponse(it) => { write!(f, "RdpdrPdu({it:?})") } @@ -362,12 +378,16 @@ impl TryFrom for Component { } impl From for u16 { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] fn from(component: Component) -> Self { component as u16 } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u16)] pub enum PacketId { /// PAKID_CORE_SERVER_ANNOUNCE @@ -442,6 +462,10 @@ impl Display for PacketId { } impl From for u16 { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] fn from(packet_id: PacketId) -> Self { packet_id as u16 } diff --git a/crates/ironrdp-rdpeusb/Cargo.toml b/crates/ironrdp-rdpeusb/Cargo.toml new file mode 100644 index 0000000000..dbc7c54778 --- /dev/null +++ b/crates/ironrdp-rdpeusb/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "ironrdp-rdpeusb" +version = "0.1.0" +description = "Remote Desktop Protocol: USB Devices Virtual Channel Extension implementation" +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +publish = false + +[lib] +doctest = false +test = false + +[features] +default = [] +std = [] + +[dependencies] +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["alloc"] } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public +ironrdp-str = { path = "../ironrdp-str", version = "0.1" } + +[lints] +workspace = true diff --git a/crates/ironrdp-rdpeusb/LICENSE-APACHE b/crates/ironrdp-rdpeusb/LICENSE-APACHE new file mode 120000 index 0000000000..1cd601d0a3 --- /dev/null +++ b/crates/ironrdp-rdpeusb/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/ironrdp-rdpeusb/LICENSE-MIT b/crates/ironrdp-rdpeusb/LICENSE-MIT new file mode 120000 index 0000000000..b2cfbdc7b0 --- /dev/null +++ b/crates/ironrdp-rdpeusb/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/ironrdp-rdpeusb/README.md b/crates/ironrdp-rdpeusb/README.md new file mode 100644 index 0000000000..583fb24295 --- /dev/null +++ b/crates/ironrdp-rdpeusb/README.md @@ -0,0 +1,10 @@ +# IronRDP RDPEUSB + +Implements [Remote Desktop Protocol: USB Devices Virtual Channel Extension][spec] +used to redirect USB devices from a terminal client to a terminal server. + +[spec]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a1004d0e-99e9-4968-894b-0b924ef2f125 + +This crate is part of the [IronRDP] project. + +[IronRDP]: https://github.com/Devolutions/IronRDP diff --git a/crates/ironrdp-rdpeusb/src/client.rs b/crates/ironrdp-rdpeusb/src/client.rs new file mode 100644 index 0000000000..586156d297 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/client.rs @@ -0,0 +1,736 @@ +use alloc::collections::btree_map::{BTreeMap, Entry}; +use alloc::vec; +use alloc::{boxed::Box, vec::Vec}; +use ironrdp_core::{Decode as _, ReadCursor, impl_as_any}; +use ironrdp_dvc::{DvcChannelListener, DvcClientProcessor, DvcMessage, DvcProcessor}; +use ironrdp_pdu::{PduResult, decode_err, pdu_other_err}; + +use crate::io::device::add_device_from_info; +use crate::io::{ + DeviceText, InternalIoControlPacket, IoControlCompletionResult, IoControlPacket, TransferInCompletionResult, + TransferInPacket, TransferOutCompletionResult, TransferOutPacket, device::DeviceInfo, +}; +use crate::pdu::UrbdrcServerDevicePdu; +use crate::pdu::completion::{IoControlCompletion, UrbCompletion, UrbCompletionNoData}; +use crate::pdu::header::{InterfaceId, Mask, MessageId}; +use crate::pdu::iface_manipulation::{InterfaceRelease, QueryInterfaceFailureResponse}; +use crate::pdu::sink::AddVirtualChannel; +use crate::pdu::usb_dev::QueryDeviceTextRsp; +use crate::pdu::utils::{RequestId, RequestIdTransferInOut}; +use crate::pdu::{ + UrbdrcServerControlPdu, + caps::{Capability, RimExchangeCapabilityResponse}, + notify::ChannelCreated, +}; +use crate::{CHANNEL_NAME, InvalidDeviceInterfaceId}; + +const ADD_VIRTUAL_CHANNEL_MSG_ID: u32 = 0; + +pub trait DeviceManagerBackend: Send { + /// Called when the first URBDRC DVC is assigned as the control DVC. + /// + /// This happens from listener.create(channel_id), before the DVC is fully open. + fn control_channel_assigned(&mut self, channel_id: u32); + + /// Called for each later URBDRC DVC create request. + /// + /// The manager should pop the pending device that caused ADD_VIRTUAL_CHANNEL + fn take_device_for_channel(&mut self, channel_id: u32) -> Option>; +} + +pub struct UrbdrcListener { + on_capability_exchanged: Option, + device_man: Box, + iface_man: InterfaceAlloc, +} + +impl UrbdrcListener { + pub fn new(callback: OnCapabilityExchanged, device_man: Box) -> Self { + Self { + on_capability_exchanged: Some(callback), + device_man, + iface_man: InterfaceAlloc::new(), + } + } +} + +struct InterfaceAlloc { + id: u32, +} + +impl InterfaceAlloc { + #[inline] + const fn new() -> Self { + Self { id: 3 } + } + + #[inline] + const fn alloc(&mut self) -> Option { + self.id += 1; + if self.id > 0x3F_FF_FF_FF { + None + } else { + Some(InterfaceId::from_raw(self.id)) + } + } +} + +impl DvcChannelListener for UrbdrcListener { + fn channel_name(&self) -> &str { + CHANNEL_NAME + } + + fn create(&mut self, channel_id: u32) -> Option> { + if let Some(callback) = self.on_capability_exchanged.take() { + self.device_man.control_channel_assigned(channel_id); + Some(Box::new(UrbdrcControlClient::new(callback))) + } else { + let udev_iface = self.iface_man.alloc()?; + #[expect(clippy::as_conversions)] + self.device_man.take_device_for_channel(channel_id).map(|backend| { + Box::new(UrbdrcDeviceClient::new(udev_iface, backend).expect("invalid interface id")) + as Box + }) + } + } +} + +/// A client for the URBDRC Control Virtual Channel. +pub struct UrbdrcControlClient { + /// Spec [3.1]: + /// Exchange-completed event: Signifies that the capability exchange is completed, that is, + /// the client has sent a Channel Created message. + /// + /// [3.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/511b4cd7-1940-4631-90ac-bf2189ba6735 + on_capability_exchanged: Option, +} + +type OnCapabilityExchanged = Box PduResult> + Send>; + +impl UrbdrcControlClient { + /// Create a new [UrbdrcControlClient] with the given callback. + /// + /// The `callback` will be called when the capability exchange is completed and the channel is + /// ready to redirect new devices. + pub fn new(callback: OnCapabilityExchanged) -> Self { + Self { + on_capability_exchanged: Some(callback), + } + } + + /// Whether the channel is ready for add virtual channel. + pub const fn ready(&self) -> bool { + self.on_capability_exchanged.is_none() + } + + /// Spec [3.3.5.1.1]: + /// + /// The client sends the ADD_VIRTUAL_CHANNEL message to server to request the server to create a + /// new instance of dynamic virtual channel for USB redirection. The client sends this message + /// for every USB device to be redirected. This isolates messages for each USB device in its own + /// instance of a dynamic virtual channel. + /// + /// [3.3.5.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/c7b1920a-d632-46d2-b62a-5c7e53570628 + pub fn add_virtual_channel(&self) -> PduResult { + if !self.ready() { + return Err(pdu_other_err!("is not ready for ADD_VIRTUAL_CHANNEL")); + } + Ok(Box::new(AddVirtualChannel { + msg_id: ADD_VIRTUAL_CHANNEL_MSG_ID, + })) + } +} + +impl DvcProcessor for UrbdrcControlClient { + fn channel_name(&self) -> &str { + CHANNEL_NAME + } + + fn start(&mut self, _channel_id: u32) -> PduResult> { + Ok(Vec::new()) + } + + fn process(&mut self, _channel_id: u32, payload: &[u8]) -> PduResult> { + let pdu = UrbdrcServerControlPdu::decode(&mut ReadCursor::new(payload)).map_err(|e| decode_err!(e))?; + use UrbdrcServerControlPdu::*; + match pdu { + Caps(caps_req_pdu) => Ok(vec![Box::new(RimExchangeCapabilityResponse { + msg_id: caps_req_pdu.msg_id, + capability: Capability::RimCapabilityVersion01, + result: 0, + })]), + ChanCreated(chan_created_pdu) => Ok(vec![Box::new(ChannelCreated { + msg_id: chan_created_pdu.msg_id, + direction: crate::pdu::notify::Direction::ToServer, + })]), + QueryIfaceReq(query_face_pdu) => Ok(vec![Box::new(QueryInterfaceFailureResponse { + iface_id: query_face_pdu.iface_id, + msg_id: query_face_pdu.msg_id, + })]), + IfaceRelease(InterfaceRelease { + iface_id, + msg_id: _msg_id, + }) => { + if iface_id == InterfaceId::NOTIFY_CLIENT.with_mask(Mask::Proxy) + && let Some(callback) = self.on_capability_exchanged.take() + { + // NOTE: MS-RDPEUSB does not normatively define RIMCALL_RELEASE as a + // server-ready-proceed barrier; the semantic comes from observed Windows + // urbdrc-server behavior. Pattern matches FreeRDP urbdrc_main.c since 2012 + // (commit fa4d8fca1be, Atrust contribution). Two sync points: control DVC + // (server -> client ADD_VIRTUAL_CHANNEL); device DVC (server -> client + // ADD_DEVICE). + callback() + } else { + Ok(Vec::new()) + } + } + } + } +} + +impl_as_any!(UrbdrcControlClient); + +impl DvcClientProcessor for UrbdrcControlClient {} + +pub trait UrbdrcDeviceBackend: Send { + /// Get the USB device information. + fn device_info(&mut self, channel_id: u32) -> PduResult; + + /// [Processing a Cancel Request Message][3.3.5.3.1]: + /// + /// The client MUST attempt to stop processing the request identified by the RequestId field in + /// the CANCEL_REQUEST message. If the current request has not been completed it MUST be + /// canceled. If the request has been completed, the client MUST ignore this CANCEL_REQUEST + /// message. + /// + /// [3.3.5.3.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/d5315234-d9ba-42dc-bc1b-b421c57a21ae + fn cancel_request(&mut self, request_id: RequestId, channel_id: u32); + + /// [Processing a Query Device Text Message][3.3.5.3.5]: + /// + /// After receiving the QUERY_DEVICE_TEXT message, the client forwards the request to the + /// physical device. When the physical device completes the request, the client sends the result + /// of the request to the server via QUERY_DEVICE_TEXT_RSP message and the RequestId field in + /// the message MUST match the RequestId in the QUERY_DEVICE_TEXT message. + /// + /// [3.3.5.3.5]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/834f56cc-cfed-4649-8952-0b6486638c28 + fn query_device_text(&mut self, channel_id: u32, text_type: u32, locale_id: u32) -> PduResult; + + /// Process an `IoControl` request. + /// + /// Returning [`None`] means the request remains pending and no immediate completion is sent. + fn io_control( + &mut self, + channel_id: u32, + request_id: RequestId, + request: IoControlPacket, + ) -> PduResult>; + + /// Process an `InternalIoControl` request. + /// + /// Returning [`None`] means the request remains pending and no immediate completion is sent. + fn internal_io_control( + &mut self, + channel_id: u32, + request_id: RequestId, + request: InternalIoControlPacket, + ) -> PduResult>; + + /// Process a `TransferInRequest`. + /// + /// Returning [`None`] means the request remains pending and no immediate completion is sent. + fn transfer_in( + &mut self, + channel_id: u32, + request_id: RequestId, + request: TransferInPacket, + ) -> PduResult>; + + /// Process a `TransferOutRequest`. + /// + /// Returning [`None`] means the request remains pending and no immediate completion is sent. + fn transfer_out( + &mut self, + channel_id: u32, + request_id: RequestId, + request: TransferOutPacket, + ) -> PduResult>; + + /// Process a no_ack `TransferOutRequest`. + fn transfer_out_no_ack( + &mut self, + channel_id: u32, + request_id: RequestId, + request: TransferOutPacket, + ) -> PduResult<()>; + + /// [Processing a Retract Device Message][3.3.5.3.8]: + /// + /// After receiving the RETRACT_DEVICE message, the client SHOULD terminate the dynamic channel + /// and stop redirecting the physical USB device. + /// + /// [3.3.5.3.8]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/77dc8e12-ddd6-4cb8-a3cc-247aacea7d6f + fn retract(&mut self, channel_id: u32) -> PduResult<()>; +} + +/// A client for the URBDRC Device Virtual Channel. +pub struct UrbdrcDeviceClient { + /// Indicates whether the channel is ready for handling IO request. + ready_for_io: bool, + /// Per-device USB interface ID allocated by the DVC layer. This is intentionally kept out of + /// `DeviceInfo`, which only describes backend USB facts. + udev_iface: InterfaceId, + request_completion: Option, + backend: Box, + pending_io: BTreeMap, +} + +impl UrbdrcDeviceClient { + pub fn new( + udev_iface: InterfaceId, + backend: Box, + ) -> Result>> { + if u32::from(udev_iface) <= u32::from(InterfaceId::NOTIFY_SERVER) { + return Err(InvalidDeviceInterfaceId::new(backend)); + } + Ok(Self { + ready_for_io: false, + udev_iface, + request_completion: None, + backend, + pending_io: BTreeMap::new(), + }) + } + + pub const fn ready_for_io(&self) -> bool { + self.ready_for_io + } + + pub const fn udev_iface(&self) -> InterfaceId { + self.udev_iface + } + + fn accepts_io_request(&self, udev_iface: InterfaceId, request_id: RequestId) -> bool { + self.ready_for_io && udev_iface == self.udev_iface && !self.pending_io.contains_key(&request_id) + } + + fn pending_completion( + &mut self, + request_id: RequestId, + expected_kind: PendingKind, + ) -> PduResult<( + InterfaceId, + alloc::collections::btree_map::OccupiedEntry<'_, u32, Pending>, + )> { + let Some(completion_iface) = self.request_completion else { + return Err(pdu_other_err!("request completion uninitialized")); + }; + let Entry::Occupied(entry) = self.pending_io.entry(request_id) else { + return Err(pdu_other_err!("completion mismatch")); + }; + if entry.get().kind != expected_kind { + return Err(pdu_other_err!("completion mismatch")); + } + + Ok((completion_iface, entry)) + } + + pub fn io_ctl_completion( + &mut self, + request_id: RequestId, + response: IoControlCompletionResult, + ) -> PduResult { + self.complete_io_control(PendingKind::IoCtl, request_id, response) + } + + pub fn internal_io_ctl_completion( + &mut self, + request_id: RequestId, + response: IoControlCompletionResult, + ) -> PduResult { + self.complete_io_control(PendingKind::InternalIoCtl, request_id, response) + } + + fn complete_io_control( + &mut self, + expected_kind: PendingKind, + request_id: RequestId, + response: IoControlCompletionResult, + ) -> PduResult { + let (completion_iface, entry) = self.pending_completion(request_id, expected_kind)?; + let pending = *entry.get(); + let completion = build_io_control_completion(pending, completion_iface, request_id, response)?; + entry.remove(); + + Ok(completion) + } + + fn finish_io_control_request( + &mut self, + request_id: RequestId, + completion_iface: InterfaceId, + pending: Pending, + response: Option, + ) -> PduResult> { + if let Some(response) = response { + Ok(vec![build_io_control_completion( + pending, + completion_iface, + request_id, + response, + )?]) + } else { + self.pending_io.insert(request_id, pending); + Ok(Vec::new()) + } + } + + pub fn transfer_in_completion( + &mut self, + request_id: RequestId, + response: TransferInCompletionResult, + ) -> PduResult { + let (completion_iface, entry) = self.pending_completion(request_id, PendingKind::TransferIn)?; + let pending = *entry.get(); + + #[expect( + clippy::missing_panics_doc, + reason = "panic is unreachable unless the pending transfer-key invariant is broken" + )] + let req_id = RequestIdTransferInOut::try_from(request_id) + .expect("pending TransferIn request id must be a TS_URB request id"); + let completion = build_transfer_in_completion(pending, completion_iface, req_id, response)?; + entry.remove(); + + Ok(completion) + } + + pub fn transfer_out_completion( + &mut self, + request_id: RequestId, + response: TransferOutCompletionResult, + ) -> PduResult { + let (completion_iface, entry) = self.pending_completion(request_id, PendingKind::TransferOut)?; + let pending = *entry.get(); + + #[expect( + clippy::missing_panics_doc, + reason = "panic is unreachable unless the pending transfer-key invariant is broken" + )] + let req_id = RequestIdTransferInOut::try_from(request_id) + .expect("pending TransferOut request id must be a TS_URB request id"); + let completion = build_transfer_out_completion(pending, completion_iface, req_id, response)?; + entry.remove(); + + Ok(completion) + } +} + +fn build_io_control_completion( + pending: Pending, + completion_iface: InterfaceId, + request_id: RequestId, + response: IoControlCompletionResult, +) -> PduResult { + let output_buffer_size = check_output_buffer_size(response.output_buffer.len(), pending.max_output_buf_size)?; + + Ok(Box::new(IoControlCompletion { + msg_id: pending.msg_id, + completion_iface, + hresult: response.hresult, + request_id, + information: response.information, + output_buffer_size, + output_buffer: response.output_buffer, + })) +} + +fn build_transfer_in_completion( + pending: Pending, + completion_iface: InterfaceId, + req_id: RequestIdTransferInOut, + response: TransferInCompletionResult, +) -> PduResult { + let output_buffer_size = check_output_buffer_size(response.output_buffer.len(), pending.max_output_buf_size)?; + + if response.output_buffer.is_empty() { + Ok(Box::new(UrbCompletionNoData { + msg_id: pending.msg_id, + completion_iface, + req_id, + ts_urb_result: response.ts_urb_result, + hresult: response.hresult, + output_buffer_size, + })) + } else { + Ok(Box::new(UrbCompletion { + msg_id: pending.msg_id, + completion_iface, + req_id, + ts_urb_result: response.ts_urb_result, + hresult: response.hresult, + output_buffer: response.output_buffer, + })) + } +} + +fn build_transfer_out_completion( + pending: Pending, + completion_iface: InterfaceId, + req_id: RequestIdTransferInOut, + response: TransferOutCompletionResult, +) -> PduResult { + if response.output_buffer_size > pending.max_output_buf_size { + return Err(pdu_other_err!("output buffer exceeds maximum amount")); + } + + Ok(Box::new(UrbCompletionNoData { + msg_id: pending.msg_id, + completion_iface, + req_id, + ts_urb_result: response.ts_urb_result, + hresult: response.hresult, + output_buffer_size: response.output_buffer_size, + })) +} + +fn check_output_buffer_size(output_buffer_size: usize, max_output_buf_size: u32) -> PduResult { + let output_buffer_size = + u32::try_from(output_buffer_size).map_err(|_| pdu_other_err!("convert usize to u32 failed"))?; + if output_buffer_size > max_output_buf_size { + return Err(pdu_other_err!("output buffer exceeds maximum amount")); + } + Ok(output_buffer_size) +} + +impl DvcProcessor for UrbdrcDeviceClient { + fn channel_name(&self) -> &str { + CHANNEL_NAME + } + + fn start(&mut self, _channel_id: u32) -> PduResult> { + Ok(Vec::new()) + } + + fn process(&mut self, channel_id: u32, payload: &[u8]) -> PduResult> { + let pdu = UrbdrcServerDevicePdu::decode(&mut ReadCursor::new(payload)).map_err(|e| decode_err!(e))?; + + use UrbdrcServerDevicePdu::*; + match pdu { + ChanCreated(chan_created_pdu) => Ok(vec![Box::new(ChannelCreated { + msg_id: chan_created_pdu.msg_id, + direction: crate::pdu::notify::Direction::ToServer, + })]), + QueryIfaceReq(query_face_pdu) => Ok(vec![Box::new(QueryInterfaceFailureResponse { + iface_id: query_face_pdu.iface_id, + msg_id: query_face_pdu.msg_id, + })]), + IfaceRelease(iface_release_pdu) => { + if iface_release_pdu.iface_id == InterfaceId::NOTIFY_CLIENT.with_mask(Mask::Proxy) && !self.ready_for_io + { + // NOTE: MS-RDPEUSB does not normatively define RIMCALL_RELEASE as a + // server-ready-proceed barrier; the semantic comes from observed Windows + // urbdrc-server behavior. Pattern matches FreeRDP urbdrc_main.c since 2012 + // (commit fa4d8fca1be, Atrust contribution). Two sync points: control DVC + // (server -> client ADD_VIRTUAL_CHANNEL); device DVC (server -> client + // ADD_DEVICE). + let device_info = self.backend.device_info(channel_id)?; + let add_device = add_device_from_info(self.udev_iface, &device_info)?; + self.ready_for_io = true; + + Ok(vec![Box::new(add_device)]) + } else { + Ok(Vec::new()) + } + } + // SPEC [3.1.5]: Out-of-sequence packets are packets that do not adhere to the rules in + // sections 3.2.5 and 3.3.5. Malformed and out-of-sequence packets MUST be ignored by + // the server and the client. + // + // [3.1.5]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/f31cc9ef-a8c3-4a4d-b64d-f027ed0752b0 + CancelReq(cancel_req_pdu) => { + if !self.ready_for_io || cancel_req_pdu.udev_iface != self.udev_iface { + return Ok(Vec::new()); + } + if self.pending_io.remove(&cancel_req_pdu.req_id).is_some() { + self.backend.cancel_request(cancel_req_pdu.req_id, channel_id); + } + Ok(Vec::new()) + } + RegReqCb(register_request_callback_pdu) => { + if !self.ready_for_io || register_request_callback_pdu.udev_iface != self.udev_iface { + return Ok(Vec::new()); + } + self.request_completion = register_request_callback_pdu.request_completion; + Ok(Vec::new()) + } + Retract(retract_pdu) => { + if !self.ready_for_io || retract_pdu.udev_iface != self.udev_iface { + return Ok(Vec::new()); + } + self.backend.retract(channel_id)?; + self.ready_for_io = false; + self.request_completion = None; + self.pending_io.clear(); + Ok(Vec::new()) + } + DevText(dev_text_pdu) => { + if !self.ready_for_io || dev_text_pdu.udev_iface != self.udev_iface { + return Ok(Vec::new()); + } + let device_text = + self.backend + .query_device_text(channel_id, dev_text_pdu.text_type, dev_text_pdu.locale_id)?; + Ok(vec![Box::new(QueryDeviceTextRsp { + msg_id: dev_text_pdu.msg_id, + udev_iface: dev_text_pdu.udev_iface, + hresult: device_text.hresult, + device_description: device_text.description.into(), + })]) + } + IoCtl(io_ctl_pdu) => { + let msg_id = io_ctl_pdu.msg_id; + let request_id = io_ctl_pdu.req_id; + let max_output_buf_size = io_ctl_pdu.output_buffer_size; + if !self.accepts_io_request(io_ctl_pdu.udev_iface, request_id) { + return Ok(Vec::new()); + } + let Some(completion_iface) = self.request_completion else { + return Ok(Vec::new()); + }; + + let io_ctl_packet = io_ctl_pdu.into(); + let response = self.backend.io_control(channel_id, request_id, io_ctl_packet)?; + self.finish_io_control_request( + request_id, + completion_iface, + Pending { + kind: PendingKind::IoCtl, + msg_id, + max_output_buf_size, + }, + response, + ) + } + InternalIoCtl(internal_io_ctl_pdu) => { + let msg_id = internal_io_ctl_pdu.msg_id; + let request_id = internal_io_ctl_pdu.req_id; + let max_output_buf_size = internal_io_ctl_pdu.output_buffer_size; + if !self.accepts_io_request(internal_io_ctl_pdu.udev_iface, request_id) { + return Ok(Vec::new()); + } + let Some(completion_iface) = self.request_completion else { + return Ok(Vec::new()); + }; + + let internal_io_ctl_packet = internal_io_ctl_pdu.try_into()?; + let response = self + .backend + .internal_io_control(channel_id, request_id, internal_io_ctl_packet)?; + self.finish_io_control_request( + request_id, + completion_iface, + Pending { + kind: PendingKind::InternalIoCtl, + msg_id, + max_output_buf_size, + }, + response, + ) + } + TransferIn(transfer_in_pdu) => { + let msg_id = transfer_in_pdu.msg_id; + let max_output_buf_size = transfer_in_pdu.output_buffer_size; + let request_id = transfer_in_pdu.request_id(); + if !self.accepts_io_request(transfer_in_pdu.udev_iface, request_id.into()) { + return Ok(Vec::new()); + } + let Some(completion_iface) = self.request_completion else { + return Ok(Vec::new()); + }; + + let transfer_in = TransferInPacket { + ts_urb: transfer_in_pdu.ts_urb.into(), + output_buffer_size: transfer_in_pdu.output_buffer_size, + }; + + let pending = Pending { + kind: PendingKind::TransferIn, + msg_id, + max_output_buf_size, + }; + if let Some(response) = self.backend.transfer_in(channel_id, request_id.into(), transfer_in)? { + Ok(vec![build_transfer_in_completion( + pending, + completion_iface, + request_id, + response, + )?]) + } else { + self.pending_io.insert(request_id.into(), pending); + Ok(Vec::new()) + } + } + TransferOut(transfer_out_pdu) => { + let msg_id = transfer_out_pdu.msg_id; + let request_id = transfer_out_pdu.ts_urb.header.req_id; + let no_ack = transfer_out_pdu.ts_urb.header.no_ack; + if !self.accepts_io_request(transfer_out_pdu.udev_iface, request_id.into()) { + return Ok(Vec::new()); + } + let output_buffer_size = u32::try_from(transfer_out_pdu.output_buffer.len()) + .map_err(|_| pdu_other_err!("convert usize to u32 failed"))?; + + let transfer_out = TransferOutPacket { + ts_urb: transfer_out_pdu.ts_urb.into(), + output_buffer: transfer_out_pdu.output_buffer, + }; + if no_ack { + self.backend + .transfer_out_no_ack(channel_id, request_id.into(), transfer_out)?; + Ok(Vec::new()) + } else { + let Some(completion_iface) = self.request_completion else { + return Ok(Vec::new()); + }; + let pending = Pending { + kind: PendingKind::TransferOut, + msg_id, + max_output_buf_size: output_buffer_size, + }; + if let Some(response) = self.backend.transfer_out(channel_id, request_id.into(), transfer_out)? { + Ok(vec![build_transfer_out_completion( + pending, + completion_iface, + request_id, + response, + )?]) + } else { + self.pending_io.insert(request_id.into(), pending); + Ok(Vec::new()) + } + } + } + } + } +} + +impl_as_any!(UrbdrcDeviceClient); + +impl DvcClientProcessor for UrbdrcDeviceClient {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingKind { + IoCtl, + InternalIoCtl, + TransferIn, + TransferOut, +} + +#[derive(Debug, Clone, Copy)] +struct Pending { + kind: PendingKind, + msg_id: MessageId, + max_output_buf_size: u32, +} diff --git a/crates/ironrdp-rdpeusb/src/io/device.rs b/crates/ironrdp-rdpeusb/src/io/device.rs new file mode 100644 index 0000000000..ef986ed346 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/io/device.rs @@ -0,0 +1,382 @@ +//! Backend-neutral USB device facts and the RDPEUSB-specific ADD_DEVICE conversion. +//! +//! Backends should fill [`DeviceInfo`] with raw USB topology/descriptor data. This module is +//! responsible for turning those facts into Windows PnP-style strings and RDPEUSB wire wrappers. +//! +//! The split is intentional: +//! - RDPEUSB defines the ADD_DEVICE fields and their wire types, but not every generation detail. +//! - Windows PnP/USB defines the usual hardware ID and compatibility ID formats used for driver +//! matching. +//! - Device instance ID and container ID are enumerator policy. They must be stable identifiers +//! with the RDPEUSB-required shape, so this implementation follows FreeRDP's observed strategy. +//! +//! References: +//! - [MS-RDPEUSB ADD_DEVICE] +//! - [MS-RDPEUSB USB_DEVICE_CAPABILITIES] +//! - [Windows device identification strings] +//! - [Standard USB identifiers] +//! - [USB composite device enumeration] +//! - [USB container ID assignment] +//! - [FreeRDP urbdrc_main.c] +//! - [FreeRDP libusb_udevice.c] +//! +//! [MS-RDPEUSB ADD_DEVICE]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a26bcb6d-d45d-48a9-b9bd-22e0107d8393 +//! [MS-RDPEUSB USB_DEVICE_CAPABILITIES]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/98d4650e-b6d8-47e5-b71b-4d320ab542ee +//! [Windows device identification strings]: https://learn.microsoft.com/en-us/windows-hardware/drivers/install/device-identification-strings +//! [Standard USB identifiers]: https://learn.microsoft.com/en-us/windows-hardware/drivers/install/standard-usb-identifiers +//! [USB composite device enumeration]: https://learn.microsoft.com/en-us/windows-hardware/drivers/usbcon/enumeration-of-the-composite-parent-device +//! [USB container ID assignment]: https://learn.microsoft.com/en-us/windows-hardware/drivers/install/how-usb-devices-are-assigned-container-ids +//! [FreeRDP urbdrc_main.c]: https://github.com/FreeRDP/FreeRDP/blob/master/channels/urbdrc/client/urbdrc_main.c +//! [FreeRDP libusb_udevice.c]: https://github.com/FreeRDP/FreeRDP/blob/master/channels/urbdrc/client/libusb/libusb_udevice.c + +use alloc::{format, string::String, vec, vec::Vec}; + +use ironrdp_pdu::{PduResult, pdu_other_err}; +use ironrdp_str::multi_sz::MultiSzString; +use ironrdp_str::prefixed::Cch32String; + +use crate::pdu::header::{InterfaceId, MessageId}; +use crate::pdu::sink::{ + AddDevice, DeviceSpeed, NoAckIsochWriteJitterBufSizeInMs, SupportedUsbVer, UsbBusIfaceVer, UsbDeviceCaps, UsbdiVer, +}; + +const ADD_DEVICE_MESSAGE_ID: MessageId = 0; +const DEFAULT_NO_ACK_ISOCH_WRITE_JITTER_MS: u32 = 0x50; + +const USB_CLASS_PER_INTERFACE: u8 = 0x00; +const USB_CLASS_MISCELLANEOUS: u8 = 0xef; +const USB_SUBCLASS_COMMON: u8 = 0x02; +const USB_PROTOCOL_INTERFACE_ASSOCIATION: u8 = 0x01; + +/// USB device facts supplied by a client backend. +/// +/// [`UrbdrcDeviceBackend::device_info`] returns this backend-neutral description. The RDPEUSB +/// client uses it to construct the Windows Plug and Play identifiers and capabilities carried by +/// `ADD_DEVICE`. +/// +/// [`UrbdrcDeviceBackend::device_info`]: crate::client::UrbdrcDeviceBackend::device_info +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceInfo { + /// Physical/topological location. Used to derive stable Windows PnP instance/container IDs. + pub location: UsbDeviceLocation, + /// Raw fields from the USB device descriptor. + pub descriptor: UsbDeviceDescriptorInfo, + /// Active configuration, if the backend can read it. Used for composite detection and + /// first-interface class codes. + pub active_config: Option, + /// Backend-observed connection speed. RDPEUSB only carries a high-speed boolean. + pub speed: UsbConnectionSpeed, +} + +impl DeviceInfo { + fn is_composite(&self) -> bool { + let descriptor_class = self.descriptor.class_codes; + // Match FreeRDP/libusb composite detection: either a per-interface class device with + // multiple interfaces, or an Interface Association Descriptor style device class. + // + // Refs: [USB composite device enumeration]; [FreeRDP libusb_udevice.c] + // `interface_create()`. + let has_single_config_multiple_interfaces = self.descriptor.num_configurations == 1 + && descriptor_class.class_code == USB_CLASS_PER_INTERFACE + && self + .active_config + .as_ref() + .is_some_and(|config| config.interfaces.len() > 1); + + let has_interface_association_descriptor = descriptor_class.class_code == USB_CLASS_MISCELLANEOUS + && descriptor_class.sub_class_code == USB_SUBCLASS_COMMON + && descriptor_class.protocol_code == USB_PROTOCOL_INTERFACE_ASSOCIATION; + + has_single_config_multiple_interfaces || has_interface_association_descriptor + } + + fn pnp_class_codes(&self) -> UsbClassCodes { + // FreeRDP uses the first active interface class for compatibility IDs after checking + // whether the whole device is composite. + // + // Ref: [FreeRDP libusb_udevice.c] `interface_create()`. + self.active_config + .as_ref() + .and_then(|config| config.interfaces.first()) + .map(|interface| interface.class_codes) + .unwrap_or(self.descriptor.class_codes) + } +} + +/// Physical location of a USB device in the backend's USB topology. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsbDeviceLocation { + pub bus_number: u8, + pub address: u8, + pub port_numbers: Vec, +} + +impl UsbDeviceLocation { + fn path(&self) -> String { + // FreeRDP uses "bus-last_port" as the device path. Keep the full port chain in + // DeviceInfo for backend fidelity, but only the last port participates in ADD_DEVICE IDs. + // + // Ref: [FreeRDP libusb_udevice.c] `udev_get_device_handle()`. + let last_port_or_address = self.port_numbers.last().copied().unwrap_or(self.address); + + format!("{}-{last_port_or_address}", self.bus_number) + } +} + +/// Fields from a standard USB device descriptor needed for device announcement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UsbDeviceDescriptorInfo { + pub vendor_id: u16, + pub product_id: u16, + pub device_version: u16, + pub usb_version: UsbBcdVersion, + pub class_codes: UsbClassCodes, + pub num_configurations: u8, +} + +/// Information from the device's active USB configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsbConfigInfo { + pub interfaces: Vec, +} + +/// Information from a USB interface descriptor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UsbInterfaceInfo { + pub class_codes: UsbClassCodes, +} + +/// USB class, subclass, and protocol code triplet. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UsbClassCodes { + pub class_code: u8, + pub sub_class_code: u8, + pub protocol_code: u8, +} + +impl UsbClassCodes { + pub const PER_INTERFACE: Self = Self { + class_code: 0x00, + sub_class_code: 0x00, + protocol_code: 0x00, + }; +} + +/// Raw `bcdUSB` value from the USB device descriptor. +/// +/// The value is preserved without BCD validation. RDPEUSB supports only +/// USB 1.0, 1.1, and 2.0, so newer values are advertised as USB 2.0. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UsbBcdVersion(u16); +impl UsbBcdVersion { + /// Wraps a raw `bcdUSB` value without validating its BCD digits. + pub const fn from_bcd(value: u16) -> Self { + Self(value) + } + + fn to_supported_usb_version(self) -> SupportedUsbVer { + if self.0 >= 0x0200 { + SupportedUsbVer::Usb20 + } else if self.0 >= 0x0110 { + SupportedUsbVer::Usb11 + } else { + SupportedUsbVer::Usb10 + } + } + + fn is_at_least_usb20(self) -> bool { + self.0 >= 0x0200 + } +} + +/// Connection speed reported by the USB backend. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsbConnectionSpeed { + Unknown, + Low, + Full, + High, + Super, + SuperPlus, +} + +/// Convert backend USB facts into the RDPEUSB ADD_DEVICE PDU. +/// +/// `usb_device` is deliberately passed separately: it is the per-device USB interface ID allocated +/// by the DVC processor, not a property of the USB backend device. +/// +/// The output strings are Windows PnP identifiers. They are opaque to this crate once generated; +/// the important part is using the standard USB forms and keeping instance/container values stable. +pub fn add_device_from_info(usb_device: InterfaceId, info: &DeviceInfo) -> PduResult { + let device_version = info.descriptor.device_version; + let location_path = info.location.path(); + + Ok(AddDevice { + msg_id: ADD_DEVICE_MESSAGE_ID, + usb_device, + // Cch32String and MultiSzString are wire-format concerns. Keep DeviceInfo plain and build + // these counted UTF-16 wrappers only at the RDPEUSB boundary. + // + // Ref: [MS-RDPEUSB ADD_DEVICE] field definitions for cchDeviceInstanceId, cchHwIds, + // cchCompatIds, and cchContainerId. + device_instance_id: Cch32String::new(device_instance_id(&location_path)), + hw_ids: Some( + MultiSzString::new(hardware_ids( + info.descriptor.vendor_id, + info.descriptor.product_id, + device_version, + )) + .map_err(|e| pdu_other_err!("generated ADD_DEVICE hardware IDs contain an embedded nul", source: e))?, + ), + compat_ids: Some(MultiSzString::new(compatibility_ids(info)).map_err( + |e| pdu_other_err!("generated ADD_DEVICE compatibility IDs contain an embedded nul", source: e), + )?), + container_id: Cch32String::new(container_id( + info.descriptor.vendor_id, + info.descriptor.product_id, + &location_path, + )), + usb_device_caps: usb_device_caps(info)?, + }) +} + +fn hardware_ids(vendor_id: u16, product_id: u16, device_version: u16) -> Vec { + // Windows PnP hardware IDs, ordered from most specific to less specific. + // + // Refs: [Standard USB identifiers]; [FreeRDP urbdrc_main.c] + // `urdbrc_send_usb_device_add()`. + vec![ + format!("USB\\VID_{vendor_id:04X}&PID_{product_id:04X}&REV_{device_version:04X}"), + format!("USB\\VID_{vendor_id:04X}&PID_{product_id:04X}"), + ] +} + +fn compatibility_ids(info: &DeviceInfo) -> Vec { + if info.is_composite() { + // Composite devices advertise DevClass_00 plus USB\COMPOSITE, matching FreeRDP. + // + // Refs: [USB composite device enumeration]; [FreeRDP urbdrc_main.c] + // `urdbrc_send_usb_device_add()`. + vec![ + String::from("USB\\DevClass_00&SubClass_00&Prot_00"), + String::from("USB\\DevClass_00&SubClass_00"), + String::from("USB\\DevClass_00"), + String::from("USB\\COMPOSITE"), + ] + } else { + let codes = info.pnp_class_codes(); + + // Non-composite devices advertise class/subclass/protocol in decreasing specificity. + // + // Refs: [Standard USB identifiers]; [FreeRDP urbdrc_main.c] + // `urdbrc_send_usb_device_add()`. + vec![ + format!( + "USB\\Class_{:02X}&SubClass_{:02X}&Prot_{:02X}", + codes.class_code, codes.sub_class_code, codes.protocol_code + ), + format!( + "USB\\Class_{:02X}&SubClass_{:02X}", + codes.class_code, codes.sub_class_code + ), + format!("USB\\Class_{:02X}", codes.class_code), + ] + } +} + +fn usb_device_caps(info: &DeviceInfo) -> PduResult { + // These constants mirror FreeRDP's ADD_DEVICE capabilities. The current PDU enum only models + // USB 1.0/1.1/2.0, so USB 3.x backend versions are reported as Usb20 for this field. + // + // Refs: [MS-RDPEUSB USB_DEVICE_CAPABILITIES]; [FreeRDP urbdrc_main.c] + // `urbdrc_send_add_device()`. + Ok(UsbDeviceCaps { + usb_bus_iface_ver: UsbBusIfaceVer::V2, + usbdi_ver: UsbdiVer::V0x600, + supported_usb_ver: info.descriptor.usb_version.to_supported_usb_version(), + device_speed: device_speed(info)?, + no_ack_isoch_write_jitter_buf_size: NoAckIsochWriteJitterBufSizeInMs::try_from( + DEFAULT_NO_ACK_ISOCH_WRITE_JITTER_MS, + ) + .map_err(|_| pdu_other_err!("default isochronous jitter buffer size is invalid"))?, + }) +} + +fn device_speed(info: &DeviceInfo) -> PduResult { + match info.speed { + UsbConnectionSpeed::Low | UsbConnectionSpeed::Full => Ok(DeviceSpeed::FullSpeed), + UsbConnectionSpeed::High | UsbConnectionSpeed::Super | UsbConnectionSpeed::SuperPlus => { + Ok(DeviceSpeed::HighSpeed) + } + UsbConnectionSpeed::Unknown => { + if info.descriptor.usb_version.is_at_least_usb20() { + Ok(DeviceSpeed::HighSpeed) + } else { + Ok(DeviceSpeed::FullSpeed) + } + } + } +} + +fn device_instance_id(location_path: &str) -> String { + // FreeRDP formats a zero-padded 16-byte ASCII seed as a GUID-looking instance ID. + // + // RDPEUSB only requires a null-terminated Unicode string identifying the USB device instance. + // Windows device identification strings are opaque string-comparison keys, so this is an + // enumerator policy choice rather than a USB descriptor field. + // + // Refs: [MS-RDPEUSB ADD_DEVICE] DeviceInstanceId; [Windows device identification strings]; + // [FreeRDP urbdrc_main.c] `func_instance_id_generate()`. + let raw = format!("\\{location_path}"); + + guid_from_bytes(bytes16_from_ascii(raw.as_bytes()), false) +} + +fn container_id(vendor_id: u16, product_id: u16, location_path: &str) -> String { + // Container ID uses VID/PID plus the last 8 bytes of the location path, with braces. + // + // RDPEUSB requires a non-zero GUID string. Windows uses container IDs to group devnodes that + // represent the same physical device; without the full Windows USB/ACPI/container descriptor + // heuristic available on the client side, follow FreeRDP's stable VID/PID/path-derived value. + // + // Refs: [MS-RDPEUSB ADD_DEVICE] ContainerId; [USB container ID assignment]; + // [FreeRDP urbdrc_main.c] `func_container_id_generate()`. + let path_suffix = location_path + .get(location_path.len().saturating_sub(8)..) + .expect("location path is ASCII"); + let raw = format!("{vendor_id:04X}{product_id:04X}{path_suffix}"); + guid_from_bytes(bytes16_from_ascii(raw.as_bytes()), true) +} + +fn bytes16_from_ascii(value: &[u8]) -> [u8; 16] { + let mut bytes = [0; 16]; + let copy_len = value.len().min(bytes.len()); + + bytes[..copy_len].copy_from_slice(&value[..copy_len]); + + bytes +} + +fn guid_from_bytes(bytes: [u8; 16], braces: bool) -> String { + let guid = format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], + bytes[1], + bytes[2], + bytes[3], + bytes[4], + bytes[5], + bytes[6], + bytes[7], + bytes[8], + bytes[9], + bytes[10], + bytes[11], + bytes[12], + bytes[13], + bytes[14], + bytes[15], + ); + + if braces { format!("{{{guid}}}") } else { guid } +} diff --git a/crates/ironrdp-rdpeusb/src/io/mod.rs b/crates/ironrdp-rdpeusb/src/io/mod.rs new file mode 100644 index 0000000000..7d6b38a54e --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/io/mod.rs @@ -0,0 +1,364 @@ +//! Backend-facing USB I/O types. +//! +//! This module is the data-model boundary between the RDPEUSB state machines and USB backend +//! implementations. The types intentionally omit RDPEUSB routing fields such as message, +//! interface, and request IDs when the state machine can manage those fields itself. +//! +//! On the client side, [`UrbdrcDeviceBackend`] receives the `*Packet` request types and returns +//! the corresponding `*CompletionResult`. Returning `None` from an I/O method leaves the request +//! pending; the backend can later pass a completion and its `RequestId` to the matching +//! completion method on [`UrbdrcDeviceClient`]. +//! +//! On the server side, methods on [`UrbdrcDeviceServer`] accept the `*Packet` request types and +//! return a [`ServerIoRequest`] ready for the DVC transport. Completion results received from the +//! client are delivered to [`UrbdrcDeviceServerBackend`]. +//! +//! `TransferIn` and `TransferOut` are named from the USB device's perspective: an IN transfer +//! reads data from the device, while an OUT transfer writes data to it. +//! +//! [`UrbdrcDeviceBackend`]: crate::client::UrbdrcDeviceBackend +//! [`UrbdrcDeviceClient`]: crate::client::UrbdrcDeviceClient +//! [`UrbdrcDeviceServer`]: crate::server::UrbdrcDeviceServer +//! [`UrbdrcDeviceServerBackend`]: crate::server::UrbdrcDeviceServerBackend + +use alloc::{string::String, vec::Vec}; +use ironrdp_dvc::DvcMessage; +use ironrdp_pdu::{PduError, PduResult, pdu_other_err}; + +pub use crate::pdu::{ + completion::ts_urb_result::TsUrbResult, + sink::UsbDeviceCaps, + usb_dev::{ + InternalIoControl, IoControl, IoctlInternalUsb, UsbInternalIoctlCode, UsbRetractReason, + ts_urb::{TsUrbInKind, TsUrbOutKind, utils::UrbFunction}, + }, + utils::{HResult, RequestId}, +}; +use crate::pdu::{ + header::{InterfaceId, MessageId}, + sink::{AddDevice, NoAckIsochWriteJitterBufSizeInMs}, + usb_dev::ts_urb::{TsUrbIn, TsUrbOut, utils::TsUrbHeader}, +}; + +pub mod device; +pub use device::DeviceInfo; + +/// Result of a device-text query. +/// +/// A client backend returns this from [`UrbdrcDeviceBackend::query_device_text`]. A server backend +/// receives the decoded response through [`UrbdrcDeviceServerBackend::device_text`]. +/// +/// [`UrbdrcDeviceBackend::query_device_text`]: crate::client::UrbdrcDeviceBackend::query_device_text +/// [`UrbdrcDeviceServerBackend::device_text`]: crate::server::UrbdrcDeviceServerBackend::device_text +#[derive(Debug, Clone)] +pub struct DeviceText { + pub hresult: u32, + pub description: String, +} + +/// Completion of an I/O control request. +/// +/// This completes either an [`IoControlPacket`] or an [`InternalIoControlPacket`]. The request ID +/// is carried separately by the backend and completion APIs. +#[derive(Debug, Clone)] +pub struct IoControlCompletionResult { + pub hresult: HResult, + /// Number of bytes transferred, or the required buffer size for an insufficient-buffer result. + /// + /// On success, this must equal `output_buffer.len()`. For other failures, except an + /// insufficient-buffer result, this value is ignored by the peer. + pub information: u32, + /// Data produced by the request. + /// + /// Its length must not exceed the request's output buffer size. For failures other than an + /// insufficient-buffer result, this must be empty. + pub output_buffer: Vec, +} + +/// Completion of a USB IN transfer. +#[derive(Debug, Clone)] +pub struct TransferInCompletionResult { + /// USB request-block result, including the USBD status and any operation-specific result. + pub ts_urb_result: TsUrbResult, + /// HRESULT returned by the transfer operation. + pub hresult: HResult, + /// Data read from the USB device. + /// + /// Its length must not exceed [`TransferInPacket::output_buffer_size`]. An empty buffer is + /// encoded as `URB_COMPLETION_NO_DATA`. + pub output_buffer: Vec, +} + +/// Completion of a USB OUT transfer. +/// +/// This is used only when [`TsUrbOutPacket::no_ack`] is `false`. +#[derive(Debug, Clone)] +pub struct TransferOutCompletionResult { + /// USB request-block result, including the USBD status and any operation-specific result. + pub ts_urb_result: TsUrbResult, + /// HRESULT returned by the transfer operation. + pub hresult: HResult, + /// Number of bytes written to the USB device. + /// + /// This must not exceed the length of [`TransferOutPacket::output_buffer`]. + pub output_buffer_size: u32, +} + +/// Backend-facing form of an RDPEUSB `IO_CONTROL` request. +#[derive(Debug, Clone)] +pub struct IoControlPacket { + /// Operation to perform on the USB device or its upstream port. + pub ioctl_code: IoctlInternalUsb, + /// Raw input supplied to the operation. + pub input_buffer: Vec, + /// Maximum number of bytes that may be returned in the completion's output buffer. + pub output_buffer_size: u32, +} + +impl From for IoControlPacket { + fn from(value: IoControl) -> Self { + Self { + ioctl_code: value.ioctl_code, + input_buffer: value.input_buffer, + output_buffer_size: value.output_buffer_size, + } + } +} + +impl IoControlPacket { + pub(crate) fn into_pdu(self, msg_id: MessageId, req_id: RequestId, udev_iface: InterfaceId) -> IoControl { + IoControl { + msg_id, + udev_iface, + ioctl_code: self.ioctl_code, + input_buffer: self.input_buffer, + output_buffer_size: self.output_buffer_size, + req_id, + } + } +} + +/// Backend-facing form of an RDPEUSB `INTERNAL_IO_CONTROL` request. +#[derive(Debug, Clone)] +pub enum InternalIoControlPacket { + QueryBusTime, +} + +impl InternalIoControlPacket { + pub(crate) fn into_pdu(self, msg_id: MessageId, req_id: RequestId, udev_iface: InterfaceId) -> InternalIoControl { + match self { + Self::QueryBusTime => InternalIoControl { + msg_id, + udev_iface, + ioctl_code: UsbInternalIoctlCode::QUERY_BUS_TIME, + input_buffer: Vec::new(), + output_buffer_size: 4, + req_id, + }, + } + } +} + +impl TryFrom for InternalIoControlPacket { + type Error = PduError; + fn try_from(value: InternalIoControl) -> PduResult { + match value.ioctl_code { + UsbInternalIoctlCode::QUERY_BUS_TIME => { + if !value.input_buffer.is_empty() { + return Err(pdu_other_err!("internal io control input buffer must be empty")); + } + if value.output_buffer_size != 4 { + return Err(pdu_other_err!("internal io control output buffer size must be 4")); + } + Ok(Self::QueryBusTime) + } + _ => Err(pdu_other_err!("unsupported InternalIoControl ioctl code")), + } + } +} + +/// Backend-facing form of a USB IN transfer request. +/// +/// An IN transfer requests data from the USB device. +#[derive(Debug, Clone)] +pub struct TransferInPacket { + /// USB request block describing the operation. + pub ts_urb: TsUrbInPacket, + /// Maximum number of bytes requested from the USB device. + pub output_buffer_size: u32, +} + +/// USB request block carried by a [`TransferInPacket`]. +#[derive(Debug, Clone)] +pub struct TsUrbInPacket { + /// Operation-specific TS_URB payload. + pub kind: TsUrbInKind, + /// URB function code identifying `kind`. + /// + /// The function and payload variant must match; conversion to a wire PDU rejects a mismatch. + pub func: UrbFunction, +} + +impl TsUrbInPacket { + pub(crate) fn into_ts_urb(self, request_id: u32) -> PduResult { + if !self.kind.matches_func(self.func) { + return Err(pdu_other_err!("URB function does not match TS_URB payload")); + } + + let ts_urb_size = self.kind.ts_urb_size()?; + Ok(TsUrbIn { + kind: self.kind, + header: TsUrbHeader { + ts_urb_size, + func: self.func, + req_id: request_id + .try_into() + .map_err(|_| pdu_other_err!("invalid transfer request id"))?, + no_ack: false, + }, + }) + } +} + +impl From for TsUrbInPacket { + fn from(value: TsUrbIn) -> Self { + Self { + kind: value.kind, + func: value.header.func, + } + } +} + +/// Backend-facing form of a USB OUT transfer request. +/// +/// An OUT transfer submits `output_buffer` to the USB device. +#[derive(Debug, Clone)] +pub struct TransferOutPacket { + /// USB request block describing the operation. + pub ts_urb: TsUrbOutPacket, + /// Raw data to write to the USB device. + pub output_buffer: Vec, +} + +/// USB request block carried by a [`TransferOutPacket`]. +#[derive(Debug, Clone)] +pub struct TsUrbOutPacket { + /// Operation-specific TS_URB payload. + pub kind: TsUrbOutKind, + /// Whether the client must omit the completion for this request. + /// + /// RDPEUSB permits this only for isochronous OUT transfers when the device advertised a + /// nonzero no-ack isochronous jitter buffer size. + pub no_ack: bool, + /// URB function code identifying `kind`. + /// + /// The function and payload variant must match; conversion to a wire PDU rejects a mismatch. + pub func: UrbFunction, +} + +impl From for TsUrbOutPacket { + fn from(value: TsUrbOut) -> Self { + Self { + kind: value.kind, + no_ack: value.header.no_ack, + func: value.header.func, + } + } +} + +/// Server-side request ready to be sent over the device DVC. +/// +/// Returned by the I/O request methods on [`UrbdrcDeviceServer`]. The server state machine has +/// already allocated and registered `request_id` before returning this value. +/// +/// [`UrbdrcDeviceServer`]: crate::server::UrbdrcDeviceServer +pub struct ServerIoRequest { + /// Request Identifier. + pub request_id: RequestId, + /// Whether the peer is expected to send a completion. + /// + /// This is `false` for a valid no-ack isochronous OUT transfer and `true` otherwise. + pub expects_completion: bool, + /// Request message to pass to the DVC transport. + pub message: DvcMessage, +} + +impl TsUrbOutPacket { + pub(crate) fn into_ts_urb( + self, + request_id: u32, + no_ack_isoch_write_jitter_buf_size: NoAckIsochWriteJitterBufSizeInMs, + ) -> PduResult { + if !self.kind.matches_func(self.func) { + return Err(pdu_other_err!("URB function does not match TS_URB payload")); + } + if self.no_ack + && !matches!( + self.func, + UrbFunction::URB_FUNCTION_ISOCH_TRANSFER | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL + ) + { + return Err(pdu_other_err!("NoAck can only be set for TS_URB_ISOCH_TRANSFER")); + } + if self.no_ack && no_ack_isoch_write_jitter_buf_size.outstanding_isoch_data().is_none() { + return Err(pdu_other_err!("NoAck is unsupported by USB device")); + } + + let ts_urb_size = self.kind.ts_urb_size()?; + Ok(TsUrbOut { + kind: self.kind, + header: TsUrbHeader { + ts_urb_size, + func: self.func, + req_id: request_id + .try_into() + .map_err(|_| pdu_other_err!("invalid transfer request id"))?, + no_ack: self.no_ack, + }, + }) + } +} + +/// Description of a redirected USB device announced by the client. +/// +/// A server backend receives this through [`UrbdrcDeviceServerBackend::add_device`] after an +/// `ADD_DEVICE` message has been decoded and its UTF-16 fields converted to Rust strings. +/// +/// [`UrbdrcDeviceServerBackend::add_device`]: crate::server::UrbdrcDeviceServerBackend::add_device +#[derive(Debug)] +pub struct DeviceAnnounce { + pub device_instance_id: String, + pub hw_ids: Vec, + pub compat_ids: Vec, + pub container_id: String, + pub usb_device_caps: UsbDeviceCaps, +} + +impl TryFrom for DeviceAnnounce { + type Error = PduError; + fn try_from(value: AddDevice) -> Result { + Ok(Self { + device_instance_id: value + .device_instance_id + .into_native() + .map_err(|e| pdu_other_err!("invalid device instance id").with_source(e))?, + hw_ids: match value.hw_ids { + Some(ids) => ids + .into_native() + .map_err(|e| pdu_other_err!("invalid hardware ids").with_source(e))?, + None => Vec::new(), + }, + compat_ids: match value.compat_ids { + Some(ids) => ids + .into_native() + .map_err(|e| pdu_other_err!("invalid compatibility id").with_source(e))?, + None => Vec::new(), + }, + container_id: value + .container_id + .into_native() + .map_err(|e| pdu_other_err!("invalid container id").with_source(e))?, + usb_device_caps: value.usb_device_caps, + }) + } +} diff --git a/crates/ironrdp-rdpeusb/src/lib.rs b/crates/ironrdp-rdpeusb/src/lib.rs new file mode 100644 index 0000000000..3855acb0d6 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/lib.rs @@ -0,0 +1,48 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +pub const CHANNEL_NAME: &str = "URBDRC"; + +pub mod client; +pub mod io; +pub mod pdu; +pub mod server; + +/// Error returned when a per-device USB interface ID conflicts with an RDPEUSB default interface. +/// +/// RDPEUSB reserves interface IDs `0x0..=0x3` for the built-in Capabilities, Device Sink, and +/// Channel Notification interfaces. A USB Device interface advertised in `ADD_DEVICE` must use a +/// dynamically allocated ID outside that range. +/// +/// The inner value is retained so callers can recover ownership and retry with a different ID. +pub struct InvalidDeviceInterfaceId { + inner: T, +} + +impl InvalidDeviceInterfaceId { + pub fn new(inner: T) -> Self { + Self { inner } + } + + pub fn into_inner(self) -> T { + self.inner + } +} + +impl core::fmt::Debug for InvalidDeviceInterfaceId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("InvalidDeviceInterfaceId").finish_non_exhaustive() + } +} + +impl core::error::Error for InvalidDeviceInterfaceId {} + +impl core::fmt::Display for InvalidDeviceInterfaceId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str( + "invalid USB device interface id: conflicts with RDPEUSB default interfaces (expected id >= 0x00000004)", + ) + } +} diff --git a/crates/ironrdp-rdpeusb/src/pdu/caps.rs b/crates/ironrdp-rdpeusb/src/pdu/caps.rs new file mode 100644 index 0000000000..5cc00a15ec --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/pdu/caps.rs @@ -0,0 +1,157 @@ +//! Messages specific to the [Exchange Capabilities][1] interface. +//! +//! Used to exchange the client's and the server's capabilities for interface manipulation. +//! +//! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/6aee4e70-9d3b-49d7-a9b9-3c437cb27c8e + +use ironrdp_core::{ + DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, invalid_field_err, +}; +use ironrdp_dvc::DvcEncode; + +use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader}; +use crate::pdu::utils::HResult; + +/// Identifies the interface manipulation capabilities of server/client. +#[repr(u32)] +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Capability { + #[doc(alias = "RIM_CAPABILITY_VERSION_01")] + RimCapabilityVersion01 = 0x1, +} + +impl Capability { + pub const FIXED_PART_SIZE: usize = size_of::(); +} + +/// [\[MS-RDPEUSB\] 2.2.3.1 Interface Manipulation Exchange Capabilities Request +/// (RIM_EXCHANGE_CAPABILITY_REQUEST)][1] packet. +/// +/// Used by the server to request interface manipulation capabilities from the client. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/13494979-ccdf-4c7c-99f0-f56e05cb259e +#[doc(alias = "RIM_EXCHANGE_CAPABILITY_REQUEST")] +#[derive(Debug, PartialEq)] +pub struct RimExchangeCapabilityRequest { + pub msg_id: MessageId, + pub capability: Capability, +} + +impl RimExchangeCapabilityRequest { + const PAYLOAD_SIZE: usize = Capability::FIXED_PART_SIZE; + + pub const FIXED_PART_SIZE: usize = Self::PAYLOAD_SIZE + SharedMsgHeader::SIZE_REQ; + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: InterfaceId::CAPABILITIES.with_mask(Mask::None), + msg_id: self.msg_id, + function_id: Some(FunctionId::RIM_EXCHANGE_CAPABILITY_REQUEST), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: Self::PAYLOAD_SIZE); + if src.read_u32() != 1 { + return Err(invalid_field_err!( + "RIM_EXCHANGE_CAPABILITY_REQUEST::CapabilityValue", + "is not 0x1 (RIM_CAPABILITY_VERSION_01)" + )); + } + Ok(Self { + msg_id: header.msg_id, + capability: Capability::RimCapabilityVersion01, + }) + } +} + +impl Encode for RimExchangeCapabilityRequest { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + self.header().encode(dst)?; + + #[expect(clippy::as_conversions)] + dst.write_u32(self.capability as u32); + + Ok(()) + } + + fn name(&self) -> &'static str { + "RIM_EXCHANGE_CAPABILITY_REQUEST" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEUSB\] 2.2.3.2 Interface Manipulation Exchange Capabilities Response +/// (RIM_EXCHANGE_CAPABILITY_RESPONSE)][1] packet. +/// +/// Sent by the client in response to [`RimExchangeCapabilityRequest`] +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/668b8ab2-7a78-4d94-bc78-04645b404cc7 +#[doc(alias = "RIM_EXCHANGE_CAPABILITY_RESPONSE")] +#[derive(Debug, PartialEq)] +pub struct RimExchangeCapabilityResponse { + pub msg_id: MessageId, + pub capability: Capability, + pub result: HResult, +} + +impl RimExchangeCapabilityResponse { + const PAYLOAD_SIZE: usize = Capability::FIXED_PART_SIZE + size_of::(); + + pub const FIXED_PART_SIZE: usize = Self::PAYLOAD_SIZE + SharedMsgHeader::SIZE_RSP; + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: InterfaceId::CAPABILITIES.with_mask(Mask::None), + msg_id: self.msg_id, + function_id: None, + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: Self::PAYLOAD_SIZE); + if src.read_u32() != 1 { + return Err(invalid_field_err!( + "RIM_EXCHANGE_CAPABILITY_RESPONSE::CapabilityValue", + "is not 0x1 (RIM_CAPABILITY_VERSION_01)" + )); + }; + let result = src.read_u32(); + + Ok(Self { + msg_id: header.msg_id, + capability: Capability::RimCapabilityVersion01, + result, + }) + } +} + +impl Encode for RimExchangeCapabilityResponse { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + self.header().encode(dst)?; + + #[expect(clippy::as_conversions)] + dst.write_u32(self.capability as u32); + + dst.write_u32(self.result); + + Ok(()) + } + + fn name(&self) -> &'static str { + "RIM_EXCHANGE_CAPABILITY_RESPONSE" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl DvcEncode for RimExchangeCapabilityRequest {} +impl DvcEncode for RimExchangeCapabilityResponse {} diff --git a/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs new file mode 100644 index 0000000000..eda4c878f0 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/pdu/completion/mod.rs @@ -0,0 +1,350 @@ +//! Messages specific to the [Request Completion][1] interface. +//! +//! Used by the client to send the final result for a request previously sent from the server. +//! The unique interface ID for this interface is provided by the server using the +//! [`RegisterRequestCallback`] message, during the lifecycle of a USB redirection channel. +//! +//! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/c0a146fc-20cf-4897-af27-a3c5474151ac + +use alloc::vec::Vec; + +use ironrdp_core::{ + Decode as _, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_size, invalid_field_err, other_err, +}; +use ironrdp_dvc::DvcEncode; +use ironrdp_pdu::utils::strict_sum; + +use crate::pdu::completion::ts_urb_result::{TsUrbIsochTransferResult, TsUrbResult, TsUrbResultPayload}; +use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader}; +#[cfg(doc)] +use crate::pdu::usb_dev::{ + InternalIoControl, IoControl, RegisterRequestCallback, TransferInRequest, TransferOutRequest, +}; +use crate::pdu::utils::{HResult, RequestIdIoctl, RequestIdTransferInOut}; + +/// * [MS-ERREF § 2.2 Win32 Error Codes][1] +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/18d8fbe8-a967-4f1c-ae50-99ca8e491d2d +const ERROR_INSUFFICIENT_BUFFER: u32 = 0x7A; + +/// * [MS-ERREF § 2.1.2 HRESULT From WIN32 Error Code Macro][1] +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/0c0bcf55-277e-4120-b5dc-f6115fc8dc38 +const FACILITY_WIN32: u32 = 0x7; + +pub mod ts_urb_result; + +/// * [MS-ERREF § 2.1.2 HRESULT From WIN32 Error Code Macro][1] +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/0c0bcf55-277e-4120-b5dc-f6115fc8dc38 +macro_rules! HRESULT_FROM_WIN32 { + ($x: expr) => {{ + if $x & 0x80000000 != 0 || $x == 0 { + $x + } else { + $x & 0x0000FFFF | (FACILITY_WIN32 << 16) | 0x80000000 + } + }}; +} + +const HRESULT_FROM_WIN32_ERROR_INSUFFICIENT_BUFFER: u32 = HRESULT_FROM_WIN32!(ERROR_INSUFFICIENT_BUFFER); + +/// [\[MS-RDPEUSB\] 2.2.7.1 IO Control Completion (IOCONTROL_COMPLETION)][1] packet. +/// +/// Sent from the client to the server as the final result of an [`IoControl`] or +/// [`InternalIoControl`] request. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/b1722374-0658-47ba-8368-87bf9d3db4d4 +#[doc(alias = "IOCONTROL_COMPLETION")] +#[derive(Debug, PartialEq, Clone)] +pub struct IoControlCompletion { + pub msg_id: MessageId, + /// The interface ID provided by the server in the `RequestCompletion` field of the prior + /// [`RegisterRequestCallback`] message. + pub completion_iface: InterfaceId, + pub request_id: RequestIdIoctl, + pub hresult: HResult, + pub information: u32, + pub output_buffer_size: u32, + pub output_buffer: Vec, +} + +impl IoControlCompletion { + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.completion_iface.with_mask(Mask::Proxy), + msg_id: self.msg_id, + function_id: Some(FunctionId::IOCONTROL_COMPLETION), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { + const FIXED: usize = 4 /* RequestId */ + 4 /* HResult */ + 4 /* Information */ + 4 /* OutputBufferSize */; + ensure_size!(in: src, size: FIXED); + + let request_id = src.read_u32(); + let hresult = src.read_u32(); + let information = src.read_u32(); + let output_buffer_size = src.read_u32(); + + let n = output_buffer_size.try_into().map_err(|e| other_err!(source: e))?; + + let output_buffer = match hresult { + 0 => { + if information != output_buffer_size { + return Err(invalid_field_err!( + "Information != OutputBufferSize", + "HResult is: 0x0 (IOCTL success), but Information != OutputBufferSize" + )); + } + ensure_size!(in: src, size: n); + src.read_slice(n).to_vec() + } + HRESULT_FROM_WIN32_ERROR_INSUFFICIENT_BUFFER => { + ensure_size!(in: src, size: n); + src.read_slice(n).to_vec() + } + _ => { + if output_buffer_size != 0 { + // > If the HResult field is equal to HRESULT_FROM_WIN32(ERROR_INSUFFICIENT_BUFFER) + // > then ... . For any other case `OutputBufferSize` **MUST** be set to 0 ... + // + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/b1722374-0658-47ba-8368-87bf9d3db4d4 + return Err(invalid_field_err!( + "OutputBufferSize", + "HResult is not one of: 0x0 (success), 0x8007007A (insufficient buffer error), \ + so expected OutputBufferSize: 0x0" + )); + } + Vec::new() + } + }; + + Ok(Self { + msg_id, + completion_iface: udev_iface, + request_id, + hresult, + information, + output_buffer_size, + output_buffer, + }) + } +} + +impl Encode for IoControlCompletion { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + self.header().encode(dst)?; + + dst.write_u32(self.request_id); + dst.write_u32(self.hresult); + dst.write_u32(self.information); + dst.write_u32(self.output_buffer_size); + + dst.write_slice(&self.output_buffer); + + Ok(()) + } + + fn name(&self) -> &'static str { + "IOCONTROL_COMPLETION" + } + + fn size(&self) -> usize { + strict_sum(&[SharedMsgHeader::SIZE_REQ + + const { + size_of::(/* RequestId */) + + size_of::() + + 4 /* Information */ + + 4 /* OutputBufferSize */ + } + + self.output_buffer.len()]) + } +} + +impl DvcEncode for IoControlCompletion {} + +/// [\[MS-RDPEUSB\] 2.2.7.2 URB Completion (URB_COMPLETION)][1] packet. +/// +/// Sent from the client to the server as the final result of a [`TransferInRequest`] that contains +/// output data. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/5bfa9c84-a74b-4942-9d09-e770b21081eb +#[doc(alias = "URB_COMPLETION")] +#[derive(Debug, PartialEq, Clone)] +pub struct UrbCompletion { + pub msg_id: MessageId, + /// The interface ID provided by the server in the `RequestCompletion` field of the prior + /// [`RegisterRequestCallback`] message. + pub completion_iface: InterfaceId, + pub req_id: RequestIdTransferInOut, + pub ts_urb_result: TsUrbResult, + pub hresult: HResult, + pub output_buffer: Vec, +} + +impl UrbCompletion { + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.completion_iface.with_mask(Mask::Proxy), + msg_id: self.msg_id, + function_id: Some(FunctionId::URB_COMPLETION), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { + ensure_size!(in: src, size: 4 /* RequestId */ + 4 /* CbTsUrbResult */); + let req_id = RequestIdTransferInOut::try_from(src.read_u32())?; + + let cb_ts_urb_result: usize = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; + ensure_size!(in: src, size: cb_ts_urb_result); + let mut ts_urb_result = TsUrbResult::decode(&mut ReadCursor::new(src.read_slice(cb_ts_urb_result)))?; + let TsUrbResultPayload::Raw(bytes) = ts_urb_result.payload else { + unreachable!("TsUrbResultPayload::decode always returns Raw(_)") + }; + ts_urb_result.payload = if bytes.is_empty() { + TsUrbResultPayload::Raw(bytes) + } else { + // URB_COMPLETION's TsUrbResult can only have a payload iff it's isoch + TsUrbResultPayload::Isoch(TsUrbIsochTransferResult::decode(&mut ReadCursor::new(&bytes))?) + }; + + ensure_size!(in: src, size: 4 /* HResult */ + 4 /* OutputBufferSize */); + let hresult = src.read_u32(); + let output_buffer_size = usize::try_from(src.read_u32()).map_err(|e| other_err!(source: e))?; + ensure_size!(in: src, size: output_buffer_size); + let output_buffer = src.read_slice(output_buffer_size).to_vec(); + Ok(Self { + msg_id, + completion_iface: udev_iface, + req_id, + ts_urb_result, + hresult, + output_buffer, + }) + } +} + +impl Encode for UrbCompletion { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + self.header().encode(dst)?; + dst.write_u32(self.req_id.into()); + match u32::try_from(self.ts_urb_result.size()) { + Ok(cb_ts_urb_result) => dst.write_u32(cb_ts_urb_result), + Err(e) => return Err(other_err!(source: e)), + } + if !matches!(self.ts_urb_result.payload, TsUrbResultPayload::Isoch(_)) + && self.ts_urb_result.payload != TsUrbResultPayload::Raw(Vec::new()) + { + return Err(invalid_field_err!( + "URB_COMPLETION::TsUrbResult", + "has non-empty payload but payload is not TS_URB_ISOCH_TRANSFER_RESULT" + )); + } + + self.ts_urb_result.encode(dst)?; + dst.write_u32(self.hresult); + dst.write_u32(self.output_buffer.len().try_into().map_err(|e| other_err!(source: e))?); + dst.write_slice(&self.output_buffer); + Ok(()) + } + + fn name(&self) -> &'static str { + "URB_COMPLETION" + } + + fn size(&self) -> usize { + SharedMsgHeader::SIZE_REQ + + size_of::(/* RequestId */) + + size_of::(/* CbTsUrbResult */) + + self.ts_urb_result.size() + + size_of::(/* HResult */) + + size_of::(/* OutputBufferSize */) + + self.output_buffer.len() + } +} + +impl DvcEncode for UrbCompletion {} + +/// [\[MS-RDPEUSB\] 2.2.7.3 URB Completion No Data (URB_COMPLETION_NO_DATA)][1] packet. +/// +/// Sent from the client to the server as the final result of a [`TransferInRequest`] that contains +/// no output data or a [`TransferOutRequest`]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/994fac8f-d258-47a6-aa35-48783abe49ec +#[doc(alias = "URB_COMPLETION_NO_DATA")] +#[derive(Debug, PartialEq, Clone)] +pub struct UrbCompletionNoData { + pub msg_id: MessageId, + /// The interface ID provided by the server in the `RequestCompletion` field of the prior + /// [`RegisterRequestCallback`] message. + pub completion_iface: InterfaceId, + pub req_id: RequestIdTransferInOut, + pub ts_urb_result: TsUrbResult, + pub hresult: HResult, + pub output_buffer_size: u32, +} + +impl UrbCompletionNoData { + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.completion_iface.with_mask(Mask::Proxy), + msg_id: self.msg_id, + function_id: Some(FunctionId::URB_COMPLETION_NO_DATA), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { + ensure_size!(in: src, size: 4 /* RequestId */ + 4 /* CbTsUrbResult */); + let req_id = RequestIdTransferInOut::try_from(src.read_u32())?; + + let cb_ts_urb_result = usize::try_from(src.read_u32()).map_err(|e| other_err!(source: e))?; + ensure_size!(in: src, size: cb_ts_urb_result); + let ts_urb_result = TsUrbResult::decode(&mut ReadCursor::new(src.read_slice(cb_ts_urb_result)))?; + ensure_size!(in: src, size: 4 /* HResult */ + 4 /* OutputBufferSize */); + let hresult = src.read_u32(); + let output_buffer_size = src.read_u32(); + Ok(Self { + msg_id, + completion_iface: udev_iface, + req_id, + ts_urb_result, + hresult, + output_buffer_size, + }) + } +} + +impl Encode for UrbCompletionNoData { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + self.header().encode(dst)?; + dst.write_u32(self.req_id.into()); + match self.ts_urb_result.size().try_into() { + Ok(cb_ts_urb_result) => dst.write_u32(cb_ts_urb_result), + Err(e) => return Err(other_err!(source: e)), + } + self.ts_urb_result.encode(dst)?; + dst.write_u32(self.hresult); + dst.write_u32(self.output_buffer_size); + Ok(()) + } + + fn name(&self) -> &'static str { + "URB_COMPLETION_NO_DATA" + } + + fn size(&self) -> usize { + SharedMsgHeader::SIZE_REQ + + size_of::(/* RequestId */) + + size_of::(/* CbTsUrbResult */) + + self.ts_urb_result.size() + + size_of::(/* HResult */) + + size_of::(/* OutputBufferSize */) + } +} + +impl DvcEncode for UrbCompletionNoData {} diff --git a/crates/ironrdp-rdpeusb/src/pdu/completion/ts_urb_result.rs b/crates/ironrdp-rdpeusb/src/pdu/completion/ts_urb_result.rs new file mode 100644 index 0000000000..0263f8f158 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/pdu/completion/ts_urb_result.rs @@ -0,0 +1,590 @@ +//! Packets sent as responses to [`TsUrbIn`] and [`TsUrbOut`] received from the server as part of +//! [`TransferInRequest`] and [`TransferOutRequest`] messages. +//! +//! The [`TsUrbResult`] packets are sent as part of [`UrbCompletion`] or [`UrbCompletionNoData`]. + +use alloc::vec::Vec; + +use ironrdp_core::{ + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, + invalid_field_err, other_err, read_padding, write_padding, +}; + +use crate::pdu::utils::{ConfigHandle, FrameNumber, PipeHandle, UsbdIsoPacketDesc, UsbdStatus}; +#[cfg(doc)] +use crate::pdu::{ + completion::{UrbCompletion, UrbCompletionNoData}, + header::SharedMsgHeader, + usb_dev::{ + InternalIoControl, IoControl, RegisterRequestCallback, TransferInRequest, TransferOutRequest, + ts_urb::{TsUrb, TsUrbGetCurrFrameNum, TsUrbIsochTransfer, TsUrbSelectConfig, TsUrbSelectInterface}, + }, +}; + +/// [\[MS-RDPEUSB\] 2.2.10 TS_URB_RESULT][1] structure. +/// +/// Sent in response to the [`TransferInRequest`] and [`TransferOutRequest`] messages, these +/// structures are sent via the [`UrbCompletion`] or [`UrbCompletionNoData`] messages. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/5a797c73-8ea0-46db-901c-cfb56f1a04a0 +#[doc(alias = "TS_URB_RESULT")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbResult { + pub header: TsUrbResultHeader, + pub payload: TsUrbResultPayload, +} + +impl Decode<'_> for TsUrbResult { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: size_of::(/* TS_URB_RESULT_HEADER::Size */)); + let urb_size: usize = src.read_u16().into(); + let header = TsUrbResultHeader::decode(src)?; + const ACTUAL_HEADER_SIZE: usize = size_of::(/* Size */) + TsUrbResultHeader::FIXED_PART_SIZE; + if urb_size < ACTUAL_HEADER_SIZE { + return Err(invalid_field_err!("TS_URB_RESULT_HEADER::Size", "is smaller than 8")); + } + let payload_size = urb_size - ACTUAL_HEADER_SIZE; + ensure_size!(in: src, size: payload_size); + let payload = TsUrbResultPayload::decode(&mut ReadCursor::new(src.read_slice(payload_size)))?; + Ok(Self { header, payload }) + } +} + +impl Encode for TsUrbResult { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + dst.write_u16(self.size().try_into().map_err(|e| other_err!(source: e))?); + self.header.encode(dst)?; + self.payload.encode(dst) + } + + fn name(&self) -> &'static str { + "TS_URB_RESULT" + } + + fn size(&self) -> usize { + size_of::(/* TS_URB_RESULT_HEADER::Size */) + self.header.size() + self.payload.size() + } +} + +/// [\[MS-RDPEUSB\] 2.2.10.1.1 TS_URB_RESULT_HEADER][1]. +/// +/// Common header for all [`TsUrbResult`] structures analogous to how [`SharedMsgHeader`] is for +/// all "top-level" *\[MS-RDPEUSB\]* messages. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/9161e272-be27-4184-86c9-4ab1103eec0e +#[doc(alias = "TS_URB_RESULT_HEADER")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbResultHeader { + pub usbd_status: UsbdStatus, +} + +impl TsUrbResultHeader { + pub const FIXED_PART_SIZE: usize = size_of::(/* Padding */) + size_of::(/* UsbdStatus */); +} + +impl Decode<'_> for TsUrbResultHeader { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + read_padding!(src, 2); + let usbd_status = src.read_u32(); + + Ok(Self { usbd_status }) + } +} + +impl Encode for TsUrbResultHeader { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + write_padding!(dst, 2); + dst.write_u32(self.usbd_status); + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB_RESULT_HEADER" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// Extra payload for (any of) the [`TsUrbResult`] structures in addition to the header. +/// +/// While encoding, any of the non-[`Raw`][1] variants should be used. On decoding, always gives a +/// [`Raw`][1] variant. The raw bytes will have to be decoded to any of the non-raw variants +/// depending upon the `RequestId` field of the outer [`UrbCompletionNoData`] packet. For a +/// [`UrbCompletion`] packet, the payload can, and should, always be decoded to +/// [`TsUrbIsochTransferResult`]. In the case there's no extra payload, this will decode to a +/// `Raw(vec![])`. +/// +/// [1]: TsUrbResultPayload::Raw +// +// The Raw variant exists cause successfully decoding to an actual result variant will require +// request id's for all four variants, passed by the state machine from outside mod pdu. Instead, +// we could return Raw variant by default while decoding (or merely "reading" in this case) and +// using request ID the raw bytes can be synthesized into actual variants. +#[non_exhaustive] +#[doc(alias = "TS_URB_RESULT")] +#[derive(Debug, PartialEq, Clone)] +pub enum TsUrbResultPayload { + SelectConfig(TsUrbSelectConfigResult), + SelectIface(TsUrbSelectInterfaceResult), + FrameNum(TsUrbGetCurrFrameNumResult), + Isoch(TsUrbIsochTransferResult), + Raw(Vec), +} + +impl Decode<'_> for TsUrbResultPayload { + /// Reads all remaining bytes. Decode to the [`Raw`][TsUrbResultPayload::Raw] variant. + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + Ok(Self::Raw(src.remaining().into())) + } +} + +impl Encode for TsUrbResultPayload { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + match self { + Self::SelectConfig(select_config_res_payload) => select_config_res_payload.encode(dst), + Self::SelectIface(select_iface_res_payload) => select_iface_res_payload.encode(dst), + Self::FrameNum(frame_num_res_payload) => frame_num_res_payload.encode(dst), + Self::Isoch(isoch_transfer_res_payload) => isoch_transfer_res_payload.encode(dst), + Self::Raw(bytes) => { + ensure_size!(in: dst, size: bytes.len()); + dst.write_slice(bytes); + Ok(()) + } + } + } + + fn name(&self) -> &'static str { + match self { + Self::SelectConfig(payload) => payload.name(), + Self::SelectIface(payload) => payload.name(), + Self::FrameNum(payload) => payload.name(), + Self::Isoch(payload) => payload.name(), + Self::Raw(_) => "TS_URB_RESULT (raw payload)", + } + } + + fn size(&self) -> usize { + match self { + Self::SelectConfig(payload) => payload.size(), + Self::SelectIface(payload) => payload.size(), + Self::FrameNum(payload) => payload.size(), + Self::Isoch(payload) => payload.size(), + Self::Raw(bytes) => bytes.len(), + } + } +} + +/// Payload for the [\[MS-RDPEUSB\] 2.2.10.2 TS_URB_SELECT_CONFIGURATION_RESULT][1] packet. +/// +/// Represents the result of [`TransferInRequest`] with [`TsUrbSelectConfig`]. This packet is sent +/// via the [`UrbCompletionNoData`] message. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/d79d7cd5-1294-4529-9849-c27436a399bc +#[doc(alias = "TS_URB_SELECT_CONFIGURATION_RESULT")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbSelectConfigResult { + pub config_handle: ConfigHandle, + pub interface: Vec, +} + +impl TsUrbSelectConfigResult { + pub const FIXED_PART_SIZE: usize = size_of::(/* ConfigurationHandle */) + size_of::(/* NumInterfaces */); + + pub fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let config_handle = src.read_u32(); + let num_interfaces = src.read_u32(); + #[expect(clippy::map_with_unused_argument_over_ranges)] + let interface = (0..num_interfaces) + .map(|_| TsUsbdInterfaceInfoResult::decode(src)) + .collect::, _>>()?; + + Ok(Self { + config_handle, + interface, + }) + } +} + +impl Encode for TsUrbSelectConfigResult { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u32(self.config_handle); + dst.write_u32(self.interface.len().try_into().map_err(|_| { + invalid_field_err!( + "TS_URB_SELECT_CONFIGURATION_RESULT::Interface", + "too many interfaces / alternate settings; count exceeded field NumInterfaces (4 bytes)" + ) + })?); + self.interface + .iter() + .try_for_each(|interface_result| interface_result.encode(dst)) + } + + fn name(&self) -> &'static str { + "TS_URB_SELECT_CONFIGURATION_RESULT" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.interface.iter().map(Encode::size).sum::() + } +} + +/// Payload for the [\[MS-RDPEUSB\] 2.2.10.3 TS_URB_SELECT_INTERFACE_RESULT][1] packet. +/// +/// Represents the result of [`TransferInRequest`] with [`TsUrbSelectInterface`]. This packet is +/// sent via the [`UrbCompletionNoData`] message. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/1831dd13-c367-4eff-a256-79c1acfeac17 +#[doc(alias = "TS_URB_SELECT_INTERFACE_RESULT")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbSelectInterfaceResult { + pub interface: TsUsbdInterfaceInfoResult, +} + +impl TsUrbSelectInterfaceResult { + pub fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + TsUsbdInterfaceInfoResult::decode(src).map(|interface| Self { interface }) + } +} + +impl Encode for TsUrbSelectInterfaceResult { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + self.interface.encode(dst) + } + + fn name(&self) -> &'static str { + "TS_URB_SELECT_INTERFACE_RESULT" + } + + fn size(&self) -> usize { + self.interface.size() + } +} + +/// Payload for the [\[MS-RDPEUSB\] 2.2.10.4 TS_URB_GET_CURRENT_FRAME_NUMBER_RESULT][1] packet. +/// +/// Represents the result of [`TransferInRequest`] with [`TsUrbGetCurrFrameNum`]. This packet is +/// sent via the [`UrbCompletionNoData`] message. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/5cd61d0c-3adb-4009-afbc-1550bc54ac2b +#[doc(alias = "TS_URB_GET_CURRENT_FRAME_NUMBER_RESULT")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbGetCurrFrameNumResult { + pub frame_number: FrameNumber, +} + +impl TsUrbGetCurrFrameNumResult { + pub const FIXED_PART_SIZE: usize = size_of::(/* FrameNumber */); + + pub fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + let frame_number = src.read_u32(); + Ok(Self { frame_number }) + } +} + +impl Encode for TsUrbGetCurrFrameNumResult { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + dst.write_u32(self.frame_number); + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB_GET_CURRENT_FRAME_NUMBER_RESULT" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// Payload for the [\[MS-RDPEUSB\] 2.2.10.5 TS_URB_ISOCH_TRANSFER_RESULT][1] packet. +/// +/// Represents the result of [`TransferInRequest`] or [`TransferOutRequest`] with +/// [`TsUrbIsochTransfer`]. This packet is sent via the [`UrbCompletion`] message if there is data +/// to be sent back, or [`UrbCompletionNoData`] message if there is no data to send back. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/6f072673-52b8-4750-ac91-9d2313f13b17 +#[doc(alias = "TS_URB_ISOCH_TRANSFER_RESULT")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbIsochTransferResult { + pub start_frame: FrameNumber, + // Only used for URB_COMPLETION_NO_DATA in response to TRANSFER_OUT_REQUEST + pub error_count: u32, + pub iso_packet: Vec, +} + +impl TsUrbIsochTransferResult { + pub const FIXED_PART_SIZE: usize = + size_of::(/* StartFrame */) + size_of::(/* NumberOfPackets */) + size_of::(/* ErrorCount */); + + pub fn count_error(iso_packets: &[UsbdIsoPacketDesc]) -> usize { + iso_packets.iter().filter(|iso| iso.status < 0).count() + } + + pub fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + let start_frame = src.read_u32(); + let number_of_packets = src.read_u32(); + let error_count = src.read_u32(); + #[expect(clippy::map_with_unused_argument_over_ranges)] + let iso_packet = (0..number_of_packets) + .map(|_| UsbdIsoPacketDesc::decode(src)) + .collect::, _>>()?; + + Ok(Self { + start_frame, + error_count, + iso_packet, + }) + } +} + +impl Encode for TsUrbIsochTransferResult { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + dst.write_u32(self.start_frame); + dst.write_u32(self.iso_packet.len().try_into().map_err(|_| { + invalid_field_err!( + "TS_URB_ISOCH_TRANSFER_RESULT::IsoPacket", + "too many packets: count exceeded field NumberOfPackets (4 bytes)" + ) + })?); + dst.write_u32(Self::count_error(&self.iso_packet).try_into().map_err(|_| { + invalid_field_err!( + "TS_URB_ISOCH_TRANSFER_RESULT::IsoPacket", + "too many failed transfers: count exceeded field ErrorCount (4 bytes)" + ) + })?); + self.iso_packet.iter().try_for_each(|iso| iso.encode(dst)) + } + + fn name(&self) -> &'static str { + "TS_URB_ISOCH_TRANSFER_RESULT" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.iso_packet.len() * UsbdIsoPacketDesc::FIXED_PART_SIZE + } +} + +/// The [\[MS-RDPEUSB\] 2.2.10.1.2 TS_USBD_INTERFACE_INFORMATION_RESULT][1] structure. +/// +/// Based on the [`USBD_INTERFACE_INFORMATION`][2] structure. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/b27abecf-2827-453a-a885-94dc3198e6d5 +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_usbd_interface_information +#[doc(alias = "TS_USBD_INTERFACE_INFORMATION_RESULT")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUsbdInterfaceInfoResult { + pub interface_number: u8, + pub alternate_setting: u8, + pub class: u8, + pub sub_class: u8, + pub protocol: u8, + pub interface_handle: u32, + pub pipes: Vec, +} + +impl TsUsbdInterfaceInfoResult { + pub const FIXED_PART_SIZE: usize = size_of::(/* Length */) + + size_of::(/* InterfaceNumber */) + + size_of::(/* AlternateSetting */) + + size_of::(/* Class */) + + size_of::(/* SubClass */) + + size_of::(/* Protocol */) + + size_of::(/* Padding */) + + size_of::(/* InterfaceHandle */) + + size_of::(/* NumberOfPipes */); + + /// # Panics + /// + /// If *(number-of-pipes * 20) + 16* is greater than `u16::MAX`. + #[inline] + pub fn length(&self) -> u16 { + (Self::FIXED_PART_SIZE + self.pipes.len() * TsUsbdPipeInfoResult::FIXED_PART_SIZE) + .try_into() + .expect("Max: 16 + 30 * 20 = 616") + } +} + +impl Decode<'_> for TsUsbdInterfaceInfoResult { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let length @ 16.. = src.read_u16() else { + return Err(invalid_field_err!( + "TS_USBD_INTERFACE_INFORMATION_RESULT::Length", + "is less than min reqd value of 16" + )); + }; + let remaining_length = usize::from(length) - 2 /* Length */; + ensure_size!(in: src, size: remaining_length); + let mut src = ReadCursor::new(src.read_slice(remaining_length)); + let interface_number = src.read_u8(); + let alternate_setting = src.read_u8(); + let class = src.read_u8(); + let sub_class = src.read_u8(); + let protocol = src.read_u8(); + read_padding(&mut src, 1); + let interface_handle = src.read_u32(); + #[expect(clippy::map_with_unused_argument_over_ranges)] + let pipes = (0..src.read_u32()) + .map(|_| TsUsbdPipeInfoResult::decode(&mut src)) + .collect::, _>>()?; + + Ok(Self { + interface_number, + alternate_setting, + class, + sub_class, + protocol, + interface_handle, + pipes, + }) + } +} + +impl Encode for TsUsbdInterfaceInfoResult { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u16(self.length()); + dst.write_u8(self.interface_number); + dst.write_u8(self.alternate_setting); + dst.write_u8(self.class); + dst.write_u8(self.sub_class); + dst.write_u8(self.protocol); + write_padding!(dst, 1); + dst.write_u32(self.interface_handle); + dst.write_u32(self.pipes.len().try_into().map_err(|e| other_err!(source: e))?); + self.pipes.iter().try_for_each(|pipe| pipe.encode(dst)) + } + + fn name(&self) -> &'static str { + "TS_USBD_INTERFACE_INFORMATION_RESULT" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + self.pipes.len() * TsUsbdPipeInfoResult::FIXED_PART_SIZE + } +} + +/// The [\[MS-RDPEUSB\] 2.2.10.1.3 TS_USBD_PIPE_INFORMATION_RESULT][2] structure. +/// +/// Based on the [`USBD_PIPE_INFORMATION`][1] structure. +/// +/// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_usbd_pipe_information +/// [2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/b27abecf-2827-453a-a885-94dc3198e6d5 +#[doc(alias = "TS_USBD_PIPE_INFORMATION_RESULT")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUsbdPipeInfoResult { + pub max_packet_size: u16, + pub endpoint_address: u8, + pub interval: u8, + pub pipe_type: UsbdPipeType, + pub pipe_handle: PipeHandle, + pub max_transfer_size: u32, + pub pipe_flags: u32, +} + +impl TsUsbdPipeInfoResult { + pub const FIXED_PART_SIZE: usize = size_of::(/* MaximumPacketSize */) + + size_of::(/* EndpointAddress */) + + size_of::(/* Interval */) + + size_of::(/* PipeType */) + + size_of::(/* PipeHandle */) + + size_of::(/* MaximumTransferSize */) + + size_of::(/* PipeFlags */); +} + +impl Decode<'_> for TsUsbdPipeInfoResult { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let max_packet_size = src.read_u16(); + let endpoint_address = src.read_u8(); + let interval = src.read_u8(); + let pipe_type = match src.read_u32() { + 0 => UsbdPipeType::Control, + 1 => UsbdPipeType::Isochronous, + 2 => UsbdPipeType::Bulk, + 3 => UsbdPipeType::Interrupt, + _ => { + return Err(invalid_field_err!( + "TS_USBD_PIPE_INFORMATION_RESULT::PipeType", + "is not one of: \ + 0x0 (UsbdPipeTypeControl),\ + 0x1 (UsbdPipeTypeIsochronous),\ + 0x2 (UsbdPipeTypeBulk),\ + 0x3 (UsbdPipeTypeInterrupt)" + )); + } + }; + let pipe_handle = src.read_u32(); + let max_transfer_size = src.read_u32(); + let pipe_flags = src.read_u32(); + + Ok(Self { + max_packet_size, + endpoint_address, + interval, + pipe_type, + pipe_handle, + max_transfer_size, + pipe_flags, + }) + } +} + +impl Encode for TsUsbdPipeInfoResult { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u16(self.max_packet_size); + dst.write_u8(self.endpoint_address); + dst.write_u8(self.interval); + #[expect(clippy::as_conversions)] + dst.write_u32(self.pipe_type as u32); + dst.write_u32(self.pipe_handle); + dst.write_u32(self.max_transfer_size); + dst.write_u32(self.pipe_flags); + + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_USBD_PIPE_INFORMATION_RESULT" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// The [`USBD_PIPE_TYPE`][1] enumeration indicating the type of pipe. +/// +/// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ne-usb-_usbd_pipe_type +#[repr(u32)] +#[doc(alias = "USBD_PIPE_TYPE")] +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum UsbdPipeType { + /// Indicates that the pipe is a control pipe. + Control = 0x0, + /// Indicates that the pipe is an isochronous transfer pipe. + Isochronous = 0x1, + /// Indicates that the pipe is a bulk transfer pipe. + Bulk = 0x2, + /// Indicates that the pipe is an interrupt pipe. + Interrupt = 0x3, +} diff --git a/crates/ironrdp-rdpeusb/src/pdu/header.rs b/crates/ironrdp-rdpeusb/src/pdu/header.rs new file mode 100644 index 0000000000..72457788f8 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/pdu/header.rs @@ -0,0 +1,301 @@ +//! Common header used by all [MS-RDPEUSB][1] messages. +//! +//! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a1004d0e-99e9-4968-894b-0b924ef2f125 + +use ironrdp_core::{ + Decode, DecodeError, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_size, invalid_field_err, +}; + +#[cfg(doc)] +use crate::pdu::caps::{RimExchangeCapabilityRequest, RimExchangeCapabilityResponse}; + +/// Unique ID for a "top-level" request-response pair. +pub type MessageId = u32; + +/// Indicates in what context is a [`SharedMsgHeader`] being used. +#[repr(u8)] +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum Mask { + /// Indicates that the [`SharedMsgHeader`] is being used in a response message. + #[doc(alias = "STREAM_ID_STUB")] + Stub = 0x2, + + /// Indicates that the [`SharedMsgHeader`] is not being used in a response message. + #[doc(alias = "STREAM_ID_PROXY")] + Proxy = 0x1, + + /// Indicates that the [`SharedMsgHeader`] is being used in a message for capabilities exchange + /// ([`RimExchangeCapabilityRequest`], [`RimExchangeCapabilityResponse`]). This value **MUST + /// NOT** be used for any other messages. + #[doc(alias = "STREAM_ID_NONE")] + None = 0x0, +} + +impl From for u32 { + #[expect(clippy::as_conversions)] + fn from(value: Mask) -> Self { + value as Self + } +} + +impl TryFrom for Mask { + type Error = DecodeError; + + fn try_from(value: u8) -> Result { + match value { + 0x0 => Ok(Self::None), + 0x1 => Ok(Self::Proxy), + 0x2 => Ok(Self::Stub), + _ => Err(invalid_field_err!("try_from", "Mask", "invalid mask")), + } + } +} + +/// Groups similar kinds of messages together. +/// +/// An interface is a "group" of similar kinds of messages. Some interfaces have default ID's +/// (see associated constants), while other interfaces like the **USB Device** and **Request Completion** +/// get allotted interface ID's during the lifecycle a USB redirection channel. +/// +/// Goes without saying, server-client should maintain the interface ID's for the **Request +/// Completion** and **USB Devices** interfaces and match them with decoded interface ID's. +/// +/// Max value for interface ID's: `0x3F_FF_FF_FF` (30 bits). +#[repr(transparent)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InterfaceId(pub(in crate::pdu) u32); + +impl InterfaceId { + pub const FIXED_PART_SIZE: usize = size_of::(); + + /// **Exchange Capabilities** interface (ID: `0x0`). Used by both client and server to + /// exchange capabilities for interface manipulation. + /// + /// * [MS-RDPEUSB § 2.2.3 Interface Manipulation Exchange Capabilities Interface][1] + /// + /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/6aee4e70-9d3b-49d7-a9b9-3c437cb27c8e + pub const CAPABILITIES: Self = Self(0x0); + + /// **Device Sink** interface (ID: `0x1`). Used by the client to communicate with the server + /// about new USB devices. + /// + /// * [MS-RDPEUSB § 2.2.4 Device Sink Interface][1] + /// + /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a9a8add7-4e99-4697-abd0-ad64c80c788d + pub const DEVICE_SINK: Self = Self(0x1); + + /// **Channel Notification** interface (ID: `0x2`). Used by the server to communicate with the + /// client. + /// + /// * [MS-RDPEUSB § 2.2.5 Channel Notification Interface][1] + /// + /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a7ea1b33-80bb-4197-a502-ee62 + pub const NOTIFY_CLIENT: Self = Self(0x2); + + /// **Channel Notification** interface (ID: `0x3`). Used by the client to communicate with the + /// server. + /// + /// * [MS-RDPEUSB § 2.2.5 Channel Notification Interface][1] + /// + /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a7ea1b33-80bb-4197-a502-ee62 + pub const NOTIFY_SERVER: Self = Self(0x3); + + #[inline] + pub(crate) fn with_mask(self, mask: Mask) -> u32 { + self.0 | (u32::from(mask) << 30) + } + + pub(crate) const fn from_raw(value: u32) -> Self { + Self(value & 0x3F_FF_FF_FF) + } +} + +impl TryFrom for InterfaceId { + type Error = DecodeError; + + fn try_from(value: u32) -> Result { + if value <= 0x3F_FF_FF_FF { + Ok(InterfaceId(value)) + } else { + Err(invalid_field_err!( + "try_from", + "InterfaceId", + "InterfaceId greater than 30 bits" + )) + } + } +} + +impl From for u32 { + fn from(value: InterfaceId) -> Self { + value.0 + } +} + +impl core::fmt::Display for InterfaceId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[inline] +pub(crate) fn unpack(id: u32) -> DecodeResult<(InterfaceId, Mask)> { + #[expect(clippy::as_conversions)] + Ok((InterfaceId::from_raw(id), Mask::try_from((id >> 30) as u8)?)) +} + +/// Indicates a task/function to perform. +/// +/// Function ID's are defined for all interfaces: +/// +/// * Interface Manipulation Exchange Capabilities Interface +/// * [`FunctionId::RIM_EXCHANGE_CAPABILITY_REQUEST`] +/// * Device Sink Interface +/// * [`FunctionId::ADD_VIRTUAL_CHANNEL`] +/// * [`FunctionId::ADD_DEVICE`] +/// * Channel Notification Interface +/// * [`FunctionId::CHANNEL_CREATED`] +/// * USB Device Interface +/// * [`FunctionId::CANCEL_REQUEST`] +/// * [`FunctionId::REGISTER_REQUEST_CALLBACK`] +/// * [`FunctionId::IO_CONTROL`] +/// * [`FunctionId::INTERNAL_IO_CONTROL`] +/// * [`FunctionId::QUERY_DEVICE_TEXT`] +/// * [`FunctionId::TRANSFER_IN_REQUEST`] +/// * [`FunctionId::TRANSFER_OUT_REQUEST`] +/// * [`FunctionId::RETRACT_DEVICE`] +/// * Request Completion Interface +/// * [`FunctionId::IOCONTROL_COMPLETION`] +/// * [`FunctionId::URB_COMPLETION`] +/// * [`FunctionId::URB_COMPLETION_NO_DATA`] +/// +/// See [`InterfaceId`] for more info on interfaces. +/// +/// * [MS-RDPEUSB § 2.2.1 Shared Message Header (SHARED_MSG_HEADER)][1] +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/71cfb32c-ba15-4f95-9241-70f9df273909 +#[repr(transparent)] +#[derive(Clone, Copy, PartialEq, Debug)] +pub struct FunctionId(pub(in crate::pdu) u32); + +impl FunctionId { + pub const FIXED_PART_SIZE: usize = size_of::(); + // Needed for QI_REQ and QI_RSP + // + /// Release the given interface ID. + pub const RIMCALL_RELEASE: Self = Self(0x00000001); + pub const RIMCALL_QUERYINTERFACE: Self = Self(0x00000002); + + // -------------------- Exchange Capabilities Interface --------------------------------------- + + /// The server sends the [`RIM_EXCHANGE_CAPABILITY_REQUEST`][1] message. + /// + /// [1]: crate::pdu::caps::RimExchangeCapabilityRequest + pub const RIM_EXCHANGE_CAPABILITY_REQUEST: Self = Self(0x100); + + // -------------------- Request Completion Interface ------------------------------------------ + + pub const IOCONTROL_COMPLETION: Self = Self(0x100); + pub const URB_COMPLETION: Self = Self(0x101); + pub const URB_COMPLETION_NO_DATA: Self = Self(0x102); + + // -------------------- USB Device Interface -------------------------------------------------- + + pub const CANCEL_REQUEST: Self = Self(0x100); + pub const REGISTER_REQUEST_CALLBACK: Self = Self(0x101); + pub const IO_CONTROL: Self = Self(0x102); + pub const INTERNAL_IO_CONTROL: Self = Self(0x103); + pub const QUERY_DEVICE_TEXT: Self = Self(0x104); + pub const TRANSFER_IN_REQUEST: Self = Self(0x105); + pub const TRANSFER_OUT_REQUEST: Self = Self(0x106); + pub const RETRACT_DEVICE: Self = Self(0x107); + + // -------------------- Device Sink Interface ------------------------------------------------- + + /// The client sends the [`ADD_VIRTUAL_CHANNEL`][1] message. + /// + /// [1]: super::device_sink::AddVirtualChannel + pub const ADD_VIRTUAL_CHANNEL: Self = Self(0x100); + pub const ADD_DEVICE: Self = Self(0x101); + + // -------------------- Channel Notification Interface ---------------------------------------- + + pub const CHANNEL_CREATED: Self = Self(0x100); +} + +impl From for FunctionId { + fn from(value: u32) -> Self { + Self(value) + } +} + +impl core::fmt::Display for FunctionId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{:#X}", self.0) + } +} + +/// [\[MS-RDPEUSB\] 2.2.1 Shared Message Header (SHARED_MSG_HEADER)][1]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/71cfb32c-ba15-4f95-9241-70f9df273909 +#[doc(alias = "SHARED_MSG_HEADER")] +#[derive(Debug, PartialEq, Clone)] +pub struct SharedMsgHeader { + pub iface_id: u32, + pub msg_id: MessageId, + pub function_id: Option, +} + +impl SharedMsgHeader { + pub const SIZE_RSP: usize = size_of::(/* InterfaceId, Mask */) + size_of::(); + + pub const SIZE_REQ: usize = Self::SIZE_RSP + FunctionId::FIXED_PART_SIZE; + + pub(super) fn decode_with_function_id(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: Self::SIZE_REQ); + Ok(Self { + iface_id: src.read_u32(), + msg_id: src.read_u32(), + function_id: Some(FunctionId(src.read_u32())), + }) + } +} + +impl Encode for SharedMsgHeader { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u32(self.iface_id); + dst.write_u32(self.msg_id); + + if let Some(id) = self.function_id { + dst.write_u32(id.0); + } + + Ok(()) + } + + fn name(&self) -> &'static str { + "SHARED_MSG_HEADER" + } + + fn size(&self) -> usize { + if self.function_id.is_some() { + Self::SIZE_REQ + } else { + Self::SIZE_RSP + } + } +} + +impl Decode<'_> for SharedMsgHeader { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: Self::SIZE_RSP ); + Ok(Self { + iface_id: src.read_u32(), + msg_id: src.read_u32(), + function_id: None, + }) + } +} diff --git a/crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs b/crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs new file mode 100644 index 0000000000..671dcf46d4 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/pdu/iface_manipulation.rs @@ -0,0 +1,185 @@ +//! Messages specific to [Interface Manipulation][1] interface. +//! +//! MS-RDPEUSB utilizes the same Interface Query and Interface Release messages that are defined in +//! [MS-RDPEXPS][2]. +//! +//! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/6dd37383-9aed-4f9e-ba74-febe3a21f0f5 +//! [2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpexps/ebe401f0-f22e-4de4-9cd3-2a55e5493500 + +use ironrdp_core::{Decode, Encode, ensure_fixed_part_size, ensure_size, invalid_field_err}; +use ironrdp_dvc::DvcEncode; + +use crate::pdu::header::{FunctionId, MessageId, SharedMsgHeader}; + +/// [\[MS-RDPEXPS\] 2.2.2.2 Interface Release (IFACE_RELEASE)][1] message. +/// +/// One-way message that terminates an interface's lifetime. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpexps/5db96fd4-617f-432f-b4ec-58f75564eb06 +#[doc(alias = "IFACE_RELEASE")] +#[derive(Debug, PartialEq)] +pub struct InterfaceRelease { + pub iface_id: u32, + pub msg_id: MessageId, +} + +impl InterfaceRelease { + pub const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ; + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.iface_id, + msg_id: self.msg_id, + function_id: Some(FunctionId::RIMCALL_RELEASE), + } + } + + pub(super) fn from_header(header: SharedMsgHeader) -> Self { + Self { + iface_id: header.iface_id, + msg_id: header.msg_id, + } + } +} + +impl Decode<'_> for InterfaceRelease { + fn decode(src: &mut ironrdp_core::ReadCursor<'_>) -> ironrdp_core::DecodeResult { + ensure_fixed_part_size!(in: src); + let iface_id = src.read_u32(); + let msg_id = src.read_u32(); + if FunctionId(src.read_u32()) != FunctionId::RIMCALL_RELEASE { + return Err(invalid_field_err!( + "SHARED_MSG_HEADER::FunctionId", + "must be 0x1 (RIMCALL_RELEASE)" + )); + } + + Ok(Self { iface_id, msg_id }) + } +} + +impl Encode for InterfaceRelease { + fn encode(&self, dst: &mut ironrdp_core::WriteCursor<'_>) -> ironrdp_core::EncodeResult<()> { + self.header().encode(dst) + } + + fn name(&self) -> &'static str { + "IFACE_RELEASE" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl DvcEncode for InterfaceRelease {} + +/// [\[MS-RDPEXPS\] 2.2.2.1.1 Query Interface Request (QI_REQ)][1] message. +/// +/// Request a new interface ID. Per [MS-RDPEXPS § 3.1.5.2.1.1] the server MUST NOT send `QI_REQ`; +/// MS-RDPEUSB inherits this restriction. We decode incoming `QI_REQ` for ecosystem tolerance and +/// answer with a failure [`QueryInterfaceFailureResponse`]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpexps/10757445-d7dd-4602-b75f-772540c01a5d +#[doc(alias = "QI_REQ")] +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct QueryInterfaceRequest { + pub iface_id: u32, + pub msg_id: MessageId, + pub new_interface_guid: u128, +} + +impl QueryInterfaceRequest { + pub const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ + 16 /* NewInterfaceGUID */; + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.iface_id, + msg_id: self.msg_id, + function_id: Some(FunctionId::RIMCALL_QUERYINTERFACE), + } + } + + pub(super) fn decode( + src: &mut ironrdp_core::ReadCursor<'_>, + header: SharedMsgHeader, + ) -> ironrdp_core::DecodeResult { + ensure_size!(in: src, size: 16); + Ok(Self { + iface_id: header.iface_id, + msg_id: header.msg_id, + new_interface_guid: src.read_u128(), + }) + } +} + +impl Encode for QueryInterfaceRequest { + fn encode(&self, dst: &mut ironrdp_core::WriteCursor<'_>) -> ironrdp_core::EncodeResult<()> { + self.header().encode(dst)?; + ensure_size!(in: dst, size: 16); + dst.write_u128(self.new_interface_guid); + Ok(()) + } + + fn name(&self) -> &'static str { + "QI_REQ" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEXPS\] 2.2.2.1.2 Query Interface Response (QI_RSP)][1] — **failure** variant. +/// +/// Per [MS-RDPEXPS § 3.1.5.2.1.2], on receiving a `QI_REQ` the receiver SHOULD return the failure +/// version of `QI_RSP` — a `QI_RSP` **omitting** the optional `NewInterfaceId` field. The +/// originating side MUST interpret this as "interface not supported". +/// +/// We never advertise any negotiable interface, so we always reply with this failure variant; +/// there is no `QueryInterfaceSuccessResponse` type. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpexps/bcf53670-4db2-450a-b53e-879756ca18a8 +#[doc(alias = "QI_RSP")] +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct QueryInterfaceFailureResponse { + pub iface_id: u32, + pub msg_id: MessageId, +} + +impl QueryInterfaceFailureResponse { + pub const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_RSP; + + pub fn for_request(req: &QueryInterfaceRequest) -> Self { + Self { + iface_id: req.iface_id, + msg_id: req.msg_id, + } + } + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.iface_id, + msg_id: self.msg_id, + function_id: None, + } + } +} + +impl Encode for QueryInterfaceFailureResponse { + fn encode(&self, dst: &mut ironrdp_core::WriteCursor<'_>) -> ironrdp_core::EncodeResult<()> { + // Failure QI_RSP = SHARED_MSG_HEADER only, no body. + self.header().encode(dst) + } + + fn name(&self) -> &'static str { + "QI_RSP" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl DvcEncode for QueryInterfaceRequest {} +impl DvcEncode for QueryInterfaceFailureResponse {} diff --git a/crates/ironrdp-rdpeusb/src/pdu/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/mod.rs new file mode 100644 index 0000000000..1833486b88 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/pdu/mod.rs @@ -0,0 +1,396 @@ +//! Message packets from [\[MS-RDPEUSB\]][1], and helpers for encoding and decoding from wire. +//! +//! These messages are split into four enums by direction (server, client) and DVC role +//! (the singleton control DVC vs. per-device DVCs): [`UrbdrcServerControlPdu`], +//! [`UrbdrcServerDevicePdu`], [`UrbdrcClientControlPdu`], [`UrbdrcClientDevicePdu`]. +//! +//! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a1004d0e-99e9-4968-894b-0b924ef2f125 + +use ironrdp_core::{ + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_size, invalid_field_err, +}; + +use crate::pdu::caps::{RimExchangeCapabilityRequest, RimExchangeCapabilityResponse}; +use crate::pdu::completion::{IoControlCompletion, UrbCompletion, UrbCompletionNoData}; +use crate::pdu::header::{FunctionId, InterfaceId, Mask, SharedMsgHeader, unpack}; +use crate::pdu::iface_manipulation::{InterfaceRelease, QueryInterfaceRequest}; +use crate::pdu::notify::ChannelCreated; +use crate::pdu::sink::{AddDevice, AddVirtualChannel}; +use crate::pdu::usb_dev::{ + CancelRequest, InternalIoControl, IoControl, QueryDeviceText, QueryDeviceTextRsp, RegisterRequestCallback, + RetractDevice, TransferInRequest, TransferOutRequest, +}; + +pub mod caps; +pub mod completion; +pub mod header; +pub mod iface_manipulation; +pub mod notify; +pub mod sink; +pub mod usb_dev; +pub mod utils; + +/// A message sent from the server to the client. +pub enum UrbdrcServerControlPdu { + Caps(RimExchangeCapabilityRequest), + ChanCreated(ChannelCreated), + IfaceRelease(InterfaceRelease), + QueryIfaceReq(QueryInterfaceRequest), +} + +impl UrbdrcServerControlPdu { + fn decode_caps(src: &mut ReadCursor<'_>, f_id: FunctionId, header: SharedMsgHeader) -> DecodeResult { + match f_id { + FunctionId::RIM_EXCHANGE_CAPABILITY_REQUEST => { + RimExchangeCapabilityRequest::decode(src, header).map(Self::Caps) + } + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid RIM_EXCHANGE_CAPABILITY_REQUEST header" + )), + } + } + + fn decode_notification(src: &mut ReadCursor<'_>, f_id: FunctionId, header: SharedMsgHeader) -> DecodeResult { + match f_id { + FunctionId::CHANNEL_CREATED => ChannelCreated::decode(src, header).map(Self::ChanCreated), + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid CHANNEL_CREATED header" + )), + } + } +} + +pub enum UrbdrcServerDevicePdu { + ChanCreated(ChannelCreated), + IfaceRelease(InterfaceRelease), + QueryIfaceReq(QueryInterfaceRequest), + CancelReq(CancelRequest), + RegReqCb(RegisterRequestCallback), + IoCtl(IoControl), + InternalIoCtl(InternalIoControl), + DevText(QueryDeviceText), + TransferIn(TransferInRequest), + TransferOut(TransferOutRequest), + Retract(RetractDevice), +} + +impl UrbdrcServerDevicePdu { + fn decode_notification(src: &mut ReadCursor<'_>, f_id: FunctionId, header: SharedMsgHeader) -> DecodeResult { + match f_id { + FunctionId::CHANNEL_CREATED => ChannelCreated::decode(src, header).map(Self::ChanCreated), + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid CHANNEL_CREATED header" + )), + } + } +} + +impl Decode<'_> for UrbdrcServerControlPdu { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let header = SharedMsgHeader::decode_with_function_id(src)?; + let f_id = header.function_id.expect("missing function id"); + + match unpack(header.iface_id)? { + (InterfaceId::CAPABILITIES, Mask::None) => Self::decode_caps(src, f_id, header), + (InterfaceId::NOTIFY_CLIENT, Mask::Proxy) => Self::decode_notification(src, f_id, header), + _ => Err(invalid_field_err!("SHARED_MSG_HEADER", "invalid header")), + } + } +} + +impl Decode<'_> for UrbdrcServerDevicePdu { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let header = SharedMsgHeader::decode_with_function_id(src)?; + let f_id = header.function_id.expect("missing function id"); + + match unpack(header.iface_id)? { + (InterfaceId::NOTIFY_CLIENT, Mask::Proxy) => Self::decode_notification(src, f_id, header), + (udev_iface, Mask::Proxy) => match f_id { + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => { + QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq) + } + FunctionId::CANCEL_REQUEST => { + CancelRequest::decode(src, header.msg_id, udev_iface).map(Self::CancelReq) + } + FunctionId::REGISTER_REQUEST_CALLBACK => { + RegisterRequestCallback::decode(src, header.msg_id, udev_iface).map(Self::RegReqCb) + } + FunctionId::IO_CONTROL => IoControl::decode(src, header.msg_id, udev_iface).map(Self::IoCtl), + FunctionId::INTERNAL_IO_CONTROL => { + InternalIoControl::decode(src, header.msg_id, udev_iface).map(Self::InternalIoCtl) + } + FunctionId::QUERY_DEVICE_TEXT => { + QueryDeviceText::decode(src, header.msg_id, udev_iface).map(Self::DevText) + } + FunctionId::TRANSFER_IN_REQUEST => { + TransferInRequest::decode(src, header.msg_id, udev_iface).map(Self::TransferIn) + } + FunctionId::TRANSFER_OUT_REQUEST => { + TransferOutRequest::decode(src, header.msg_id, udev_iface).map(Self::TransferOut) + } + FunctionId::RETRACT_DEVICE => RetractDevice::decode(src, header.msg_id, udev_iface).map(Self::Retract), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER::FunctionId", + "unsupported function id for USB device interface" + )), + }, + _ => Err(invalid_field_err!("SHARED_MSG_HEADER", "invalid header")), + } + } +} + +macro_rules! fill_server_ctl_pdu_arms { + ($pdu:expr, $($tokens:tt)*) => {{ + use UrbdrcServerControlPdu::*; + match <&UrbdrcServerControlPdu>::from($pdu) { + Caps(rim_exchange_capability_request) => rim_exchange_capability_request$($tokens)*, + ChanCreated(channel_created) => channel_created$($tokens)*, + IfaceRelease(iface_release) => iface_release$($tokens)*, + QueryIfaceReq(query_iface_req) => query_iface_req$($tokens)*, + } + }}; +} + +macro_rules! fill_server_dev_pdu_arms { + ($pdu:expr, $($tokens:tt)*) => {{ + use UrbdrcServerDevicePdu::*; + match <&UrbdrcServerDevicePdu>::from($pdu) { + ChanCreated(channel_created) => channel_created$($tokens)*, + IfaceRelease(iface_release) => iface_release$($tokens)*, + QueryIfaceReq(query_iface_req) => query_iface_req$($tokens)*, + CancelReq(cancel_request) => cancel_request$($tokens)*, + RegReqCb(register_request_callback) => register_request_callback$($tokens)*, + IoCtl(io_control) => io_control$($tokens)*, + InternalIoCtl(internal_io_ctl) => internal_io_ctl$($tokens)*, + DevText(query_device_text) => query_device_text$($tokens)*, + TransferIn(transfer_in_request) => transfer_in_request$($tokens)*, + TransferOut(transfer_out_request) => transfer_out_request$($tokens)*, + Retract(retract_device) => retract_device$($tokens)*, + } + }}; +} + +impl Encode for UrbdrcServerControlPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + fill_server_ctl_pdu_arms!(self, .encode(dst)) + } + + fn name(&self) -> &'static str { + fill_server_ctl_pdu_arms!(self, .name()) + } + + fn size(&self) -> usize { + fill_server_ctl_pdu_arms!(self, .size()) + } +} + +impl Encode for UrbdrcServerDevicePdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + fill_server_dev_pdu_arms!(self, .encode(dst)) + } + + fn name(&self) -> &'static str { + fill_server_dev_pdu_arms!(self, .name()) + } + + fn size(&self) -> usize { + fill_server_dev_pdu_arms!(self, .size()) + } +} + +/// A message sent from the client to the server. +pub enum UrbdrcClientControlPdu { + Caps(RimExchangeCapabilityResponse), + ChanCreated(ChannelCreated), + AddChan(AddVirtualChannel), + IfaceRelease(InterfaceRelease), + QueryIfaceReq(QueryInterfaceRequest), +} + +pub enum UrbdrcClientDevicePdu { + ChanCreated(ChannelCreated), + AddDev(AddDevice), + DevTextRsp(QueryDeviceTextRsp), + IoctlComp(IoControlCompletion), + UrbComp(UrbCompletion), + UrbCompNoData(UrbCompletionNoData), + IfaceRelease(InterfaceRelease), + QueryIfaceReq(QueryInterfaceRequest), +} + +impl UrbdrcClientControlPdu { + fn decode_sink(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: 4 /* function id */); + let f_id = FunctionId(src.read_u32()); + match f_id { + FunctionId::ADD_VIRTUAL_CHANNEL => AddVirtualChannel::decode(src, header).map(Self::AddChan), + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid function id in DEVICE_SINK" + )), + } + } + fn decode_notification(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: 4 /* function id */); + let f_id = FunctionId(src.read_u32()); + match f_id { + FunctionId::CHANNEL_CREATED => ChannelCreated::decode(src, header).map(Self::ChanCreated), + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid function id in CHANNEL_CREATED" + )), + } + } +} + +impl Decode<'_> for UrbdrcClientControlPdu { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let header = SharedMsgHeader::decode(src)?; + + match unpack(header.iface_id)? { + (InterfaceId::CAPABILITIES, Mask::None) => { + RimExchangeCapabilityResponse::decode(src, header).map(Self::Caps) + } + (InterfaceId::DEVICE_SINK, Mask::Proxy) => Self::decode_sink(src, header), + (InterfaceId::NOTIFY_SERVER, Mask::Proxy) => Self::decode_notification(src, header), + _ => Err(invalid_field_err!("SHARED_MSG_HEADER", "invalid header")), + } + } +} + +impl UrbdrcClientDevicePdu { + fn decode_sink(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: 4 /* function id */); + let f_id = FunctionId(src.read_u32()); + match f_id { + FunctionId::ADD_DEVICE => AddDevice::decode(src, header).map(Self::AddDev), + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid function id in DEVICE_SINK" + )), + } + } + fn decode_notification(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: 4 /* function id */); + let f_id = FunctionId(src.read_u32()); + match f_id { + FunctionId::CHANNEL_CREATED => ChannelCreated::decode(src, header).map(Self::ChanCreated), + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq), + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER", + "invalid function id in CHANNEL_CREATED" + )), + } + } +} + +impl Decode<'_> for UrbdrcClientDevicePdu { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let header = SharedMsgHeader::decode(src)?; + + match unpack(header.iface_id)? { + (InterfaceId::DEVICE_SINK, Mask::Proxy) => Self::decode_sink(src, header), + (InterfaceId::NOTIFY_SERVER, Mask::Proxy) => Self::decode_notification(src, header), + (udev_iface, Mask::Stub) => { + QueryDeviceTextRsp::decode(src, header.msg_id, udev_iface).map(Self::DevTextRsp) + } + (udev_iface, Mask::Proxy) => { + ensure_size!(in: src, size: 4 /* function id */); + match FunctionId(src.read_u32()) { + FunctionId::RIMCALL_RELEASE => Ok(Self::IfaceRelease(InterfaceRelease::from_header(header))), + FunctionId::RIMCALL_QUERYINTERFACE => { + QueryInterfaceRequest::decode(src, header).map(Self::QueryIfaceReq) + } + FunctionId::IOCONTROL_COMPLETION => { + IoControlCompletion::decode(src, header.msg_id, udev_iface).map(Self::IoctlComp) + } + FunctionId::URB_COMPLETION => { + UrbCompletion::decode(src, header.msg_id, udev_iface).map(Self::UrbComp) + } + FunctionId::URB_COMPLETION_NO_DATA => { + UrbCompletionNoData::decode(src, header.msg_id, udev_iface).map(Self::UrbCompNoData) + } + _ => Err(invalid_field_err!( + "SHARED_MSG_HEADER::InterfaceId", + "unknown interface id" + )), + } + } + _ => Err(invalid_field_err!("SHARED_MSG_HEADER", "invalid header")), + } + } +} + +macro_rules! fill_client_ctl_pdu_arms { + ($pdu:expr, $($tokens:tt)*) => {{ + use UrbdrcClientControlPdu::*; + match <&UrbdrcClientControlPdu>::from($pdu) { + Caps(rim_exchange_capability_response) => rim_exchange_capability_response$($tokens)*, + AddChan(add_virtual_channel) => add_virtual_channel$($tokens)*, + ChanCreated(channel_created) => channel_created$($tokens)*, + IfaceRelease(iface_release) => iface_release$($tokens)*, + QueryIfaceReq(query_iface_req) => query_iface_req$($tokens)*, + } + }}; +} + +macro_rules! fill_client_dev_pdu_arms { + ($pdu:expr, $($tokens:tt)*) => {{ + use UrbdrcClientDevicePdu::*; + match <&UrbdrcClientDevicePdu>::from($pdu) { + ChanCreated(channel_created) => channel_created$($tokens)*, + IfaceRelease(iface_release) => iface_release$($tokens)*, + QueryIfaceReq(query_iface_req) => query_iface_req$($tokens)*, + AddDev(add_dev) => add_dev$($tokens)*, + DevTextRsp(query_device_text_rsp) => query_device_text_rsp$($tokens)*, + IoctlComp(iocontrol_completion) => iocontrol_completion$($tokens)*, + UrbComp(urb_completion) => urb_completion$($tokens)*, + UrbCompNoData(urb_completion_no_data) => urb_completion_no_data$($tokens)*, + } + }}; +} + +impl Encode for UrbdrcClientControlPdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + fill_client_ctl_pdu_arms!(self, .encode(dst)) + } + + fn name(&self) -> &'static str { + fill_client_ctl_pdu_arms!(self, .name()) + } + + fn size(&self) -> usize { + fill_client_ctl_pdu_arms!(self, .size()) + } +} + +impl Encode for UrbdrcClientDevicePdu { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + fill_client_dev_pdu_arms!(self, .encode(dst)) + } + + fn name(&self) -> &'static str { + fill_client_dev_pdu_arms!(self, .name()) + } + + fn size(&self) -> usize { + fill_client_dev_pdu_arms!(self, .size()) + } +} diff --git a/crates/ironrdp-rdpeusb/src/pdu/notify.rs b/crates/ironrdp-rdpeusb/src/pdu/notify.rs new file mode 100644 index 0000000000..2399dfd799 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/pdu/notify.rs @@ -0,0 +1,118 @@ +//! Messages specific to the [Channel Notification][1] interface. +//! +//! Used by both the client and the server to communicate with the other side. For server-to-client +//! notifications, the default interface ID is `0x00000002`; for client-to-server notifications, the +//! default interface ID is `0x00000003`. +//! +//! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a7ea1b33-80bb-4197-a502-ee62394399c0 + +use alloc::format; + +use ironrdp_core::{ + DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, + unsupported_value_err, +}; +use ironrdp_dvc::DvcEncode; + +use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader, unpack}; + +/// [\[MS-RDPEUSB\] 2.2.5.1 Channel Created Message (CHANNEL_CREATED)][1] packet. +/// +/// Sent from both the client and the server to inform the other side of the RDP USB device +/// redirection version supported. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/e2859c23-acda-47d4-a2fc-9e7415e4b8d6 +#[doc(alias = "CHANNEL_CREATED")] +#[derive(Debug, PartialEq, Clone)] +pub struct ChannelCreated { + pub msg_id: MessageId, + pub direction: Direction, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Direction { + ToServer, + ToClient, +} + +impl ChannelCreated { + const PAYLOAD_SIZE: usize = + size_of::(/* MajorVersion */) + size_of::(/* MinorVersion */) + size_of::(/* Capabilities */); + + pub const FIXED_PART_SIZE: usize = Self::PAYLOAD_SIZE + SharedMsgHeader::SIZE_REQ; + + /// The major version of RDP USB redirection supported. + #[doc(alias = "MajorVersion")] + pub const MAJOR_VER: u32 = 1; + + /// The minor version of RDP USB redirection supported. + #[doc(alias = "MinorVersion")] + pub const MINOR_VER: u32 = 0; + + /// The capabilities of RDP USB redirection supported. + #[doc(alias = "Capabilities")] + pub const CAPS: u32 = 0; + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: if let Direction::ToServer = self.direction { + InterfaceId::NOTIFY_SERVER + } else { + InterfaceId::NOTIFY_CLIENT + } + .with_mask(Mask::Proxy), + msg_id: self.msg_id, + function_id: Some(FunctionId::CHANNEL_CREATED), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: Self::PAYLOAD_SIZE); + + let major = src.read_u32(); + if major != Self::MAJOR_VER { + return Err(unsupported_value_err!("MajorVersion", format!("{major}"))); + } + let minor = src.read_u32(); + if minor != Self::MINOR_VER { + return Err(unsupported_value_err!("MinorVersion", format!("{minor}"))); + } + let capabilities = src.read_u32(); + if capabilities != Self::CAPS { + return Err(unsupported_value_err!("Capabilities", format!("{capabilities}"))); + } + + Ok(Self { + msg_id: header.msg_id, + direction: match unpack(header.iface_id)?.0 { + InterfaceId::NOTIFY_CLIENT => Direction::ToClient, + InterfaceId::NOTIFY_SERVER => Direction::ToServer, + _ => unreachable!("dispatcher must filter interface_id to NOTIFY_CLIENT/NOTIFY_SERVER"), + }, + }) + } +} + +impl Encode for ChannelCreated { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + self.header().encode(dst)?; + + dst.write_u32(Self::MAJOR_VER); + dst.write_u32(Self::MINOR_VER); + dst.write_u32(Self::CAPS); + + Ok(()) + } + + fn name(&self) -> &'static str { + "CHANNEL_CREATED" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl DvcEncode for ChannelCreated {} diff --git a/crates/ironrdp-rdpeusb/src/pdu/sink.rs b/crates/ironrdp-rdpeusb/src/pdu/sink.rs new file mode 100644 index 0000000000..b6cc66d505 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/pdu/sink.rs @@ -0,0 +1,375 @@ +//! Messages specific to the [Device Sink][1] interface. +//! +//! Identified by the default interface ID `0x00000001`, this interface is used by the client to +//! communicate with the server about new USB devices. +//! +//! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a9a8add7-4e99-4697-abd0-ad64c80c788d + +use alloc::format; + +use ironrdp_core::{ + Decode, DecodeOwned as _, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, + ensure_size, invalid_field_err, unsupported_value_err, +}; +use ironrdp_dvc::DvcEncode; +use ironrdp_pdu::utils::strict_sum; +use ironrdp_str::multi_sz::MultiSzString; +use ironrdp_str::prefixed::Cch32String; + +use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader}; + +/// [\[MS-RDPEUSB\] 2.2.4.1 Add Virtual Channel Message (ADD_VIRTUAL_CHANNEL)][1] packet. +/// +/// Sent from the client to the server to create a new instance of dynamic virtual channel. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/5b6005ed-03a6-4c70-9513-07a571367337 +#[doc(alias = "ADD_VIRTUAL_CHANNEL")] +#[derive(Debug, PartialEq)] +pub struct AddVirtualChannel { + pub msg_id: MessageId, +} + +impl AddVirtualChannel { + pub const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ /* Header */; + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: InterfaceId::DEVICE_SINK.with_mask(Mask::Proxy), + msg_id: self.msg_id, + function_id: Some(FunctionId::ADD_VIRTUAL_CHANNEL), + } + } + + pub(crate) fn decode(_: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + Ok(Self { msg_id: header.msg_id }) + } +} + +impl Encode for AddVirtualChannel { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + self.header().encode(dst) + } + + fn name(&self) -> &'static str { + "ADD_VIRTUAL_CHANNEL" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl DvcEncode for AddVirtualChannel {} + +/// [\[MS-RDPEUSB\] 2.2.4.2 Add Device Message (ADD_DEVICE)][1] packet. +/// +/// Sent from the client to the server in order to create a redirected USB device on the server. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a26bcb6d-d45d-48a9-b9bd-22e0107d8393 +#[doc(alias = "ADD_DEVICE")] +#[derive(Debug, PartialEq)] +pub struct AddDevice { + pub msg_id: MessageId, + /// The (unique) interface ID to be used by request messages in the [USB Devices][1] interface. + /// + /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/034257d7-f7a8-4fe1-b8c2-87ac8dc4f50e + pub usb_device: InterfaceId, + pub device_instance_id: Cch32String, + pub hw_ids: Option, + pub compat_ids: Option, + pub container_id: Cch32String, + pub usb_device_caps: UsbDeviceCaps, +} + +impl AddDevice { + pub const NUM_USB_DEVICE: u32 = 0x1; + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: InterfaceId::DEVICE_SINK.with_mask(Mask::Proxy), + msg_id: self.msg_id, + function_id: Some(FunctionId::ADD_DEVICE), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, header: SharedMsgHeader) -> DecodeResult { + ensure_size!(in: src, size: 4 /* NumUsbDevice */); + let num_usb_device = src.read_u32(); + if num_usb_device != 0x1 { + return Err(unsupported_value_err!("NumUsbDevice", format!("{num_usb_device}"))); + } + + ensure_size!(in: src, size: InterfaceId::FIXED_PART_SIZE); + let usb_device = match src.read_u32() { + 0x0..=0x3 => { + return Err(invalid_field_err!("UsbDevice", "conflict with default interfaces")); + } + value => InterfaceId::try_from(value)?, + }; + + let device_instance_id = Cch32String::decode_owned(src)?; + + ensure_size!(in: src, size: 4 /* cchHwIds */); + let hw_ids = if src.peek_u32() != 0 { + Some(MultiSzString::decode_owned(src)?) + } else { + let _ = src.read_u32(); // skip cchHwIds + None + }; + + ensure_size!(in: src, size: 4 /* cchCompatIds */); + let compat_ids = if src.peek_u32() != 0 { + Some(MultiSzString::decode_owned(src)?) + } else { + let _ = src.read_u32(); // skip cchCompatIds + None + }; + + let container_id = Cch32String::decode_owned(src)?; + let usb_device_caps = UsbDeviceCaps::decode(src)?; + + Ok(Self { + msg_id: header.msg_id, + usb_device, + device_instance_id, + hw_ids, + compat_ids, + container_id, + usb_device_caps, + }) + } +} + +impl Encode for AddDevice { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + self.header().encode(dst)?; + + dst.write_u32(Self::NUM_USB_DEVICE); + dst.write_u32(self.usb_device.into()); + self.device_instance_id.encode(dst)?; + match &self.hw_ids { + Some(ids) => ids.encode(dst)?, + None => dst.write_u32(0x0), + }; + match &self.compat_ids { + Some(ids) => ids.encode(dst)?, + None => dst.write_u32(0x0), + }; + self.container_id.encode(dst)?; + self.usb_device_caps.encode(dst)?; + + Ok(()) + } + + fn name(&self) -> &'static str { + "ADD_DEVICE" + } + + fn size(&self) -> usize { + let device_instance_id = self.device_instance_id.size(); + let hw_ids = match &self.hw_ids { + Some(hardware_ids) => hardware_ids.size(), + None => const { size_of::() }, // cchHwIds + }; + let compat_ids = match &self.compat_ids { + Some(compatibility_ids) => compatibility_ids.size(), + None => const { size_of::() }, // cchCompatIds + }; + let container_id = self.container_id.size(); + + strict_sum(&[SharedMsgHeader::SIZE_REQ + + 4 // NumUsbDevice + + InterfaceId::FIXED_PART_SIZE // UsbDevice + + device_instance_id + + hw_ids + + compat_ids + + container_id + + UsbDeviceCaps::FIXED_PART_SIZE]) + } +} + +impl DvcEncode for AddDevice {} + +/// [\[MS-RDPEUSB\] 2.2.11 USB_DEVICE_CAPABILITIES][1] packet. +/// +/// Defines the capabilities of a USB device. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/98d4650e-b6d8-47e5-b71b-4d320ab542ee +#[doc(alias = "USB_DEVICE_CAPABILITIES")] +#[derive(Debug, PartialEq)] +pub struct UsbDeviceCaps { + pub usb_bus_iface_ver: UsbBusIfaceVer, + pub usbdi_ver: UsbdiVer, + pub supported_usb_ver: SupportedUsbVer, + pub device_speed: DeviceSpeed, + pub no_ack_isoch_write_jitter_buf_size: NoAckIsochWriteJitterBufSizeInMs, +} + +impl UsbDeviceCaps { + pub const CB_SIZE: u32 = 28; + + pub const HCD_CAPS: u32 = 0; + + #[expect(clippy::as_conversions)] + pub const FIXED_PART_SIZE: usize = Self::CB_SIZE as usize; + + const fn check_device_speed( + usb_bus_iface_ver: UsbBusIfaceVer, + device_speed: DeviceSpeed, + ) -> Result<(), &'static str> { + if matches!(usb_bus_iface_ver, UsbBusIfaceVer::V0) && matches!(device_speed, DeviceSpeed::HighSpeed) { + Err("must be 0x00000000 when UsbBusInterfaceVersion is 0x00000000") + } else { + Ok(()) + } + } +} + +impl Encode for UsbDeviceCaps { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + Self::check_device_speed(self.usb_bus_iface_ver, self.device_speed) + .map_err(|reason| invalid_field_err!("USB_DEVICE_CAPABILITIES::DeviceIsHighSpeed", reason))?; + + ensure_fixed_part_size!(in: dst); + + dst.write_u32(Self::CB_SIZE); + + #[expect(clippy::as_conversions)] + { + dst.write_u32(self.usb_bus_iface_ver as u32); + dst.write_u32(self.usbdi_ver as u32); + dst.write_u32(self.supported_usb_ver as u32); + } + + dst.write_u32(Self::HCD_CAPS); + + #[expect(clippy::as_conversions)] + dst.write_u32(self.device_speed as u32); + + dst.write_u32(self.no_ack_isoch_write_jitter_buf_size.0); + + Ok(()) + } + + fn name(&self) -> &'static str { + "USB_DEVICE_CAPABILITIES" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl Decode<'_> for UsbDeviceCaps { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let cb_size = src.read_u32(); + if cb_size != Self::CB_SIZE { + return Err(unsupported_value_err!("CbSize", format!("{cb_size}"))); + } + let usb_bus_iface_ver = match src.read_u32() { + 0x0 => UsbBusIfaceVer::V0, + 0x1 => UsbBusIfaceVer::V1, + 0x2 => UsbBusIfaceVer::V2, + value => return Err(unsupported_value_err!("UsbBusInterfaceVersion", format!("{value}"))), + }; + let usbdi_ver = match src.read_u32() { + 0x500 => UsbdiVer::V0x500, + 0x600 => UsbdiVer::V0x600, + value => return Err(unsupported_value_err!("USBDI_Version", format!("{value}"))), + }; + let supported_usb_ver = match src.read_u32() { + 0x100 => SupportedUsbVer::Usb10, + 0x110 => SupportedUsbVer::Usb11, + 0x200 => SupportedUsbVer::Usb20, + value => return Err(unsupported_value_err!("SupportedUsbVersion", format!("{value}"))), + }; + let hcd_caps = src.read_u32(); + if hcd_caps != Self::HCD_CAPS { + return Err(unsupported_value_err!("HcdCapabilities", format!("{hcd_caps}"))); + } + let device_speed = match src.read_u32() { + 0x0 => DeviceSpeed::FullSpeed, + 0x1 => DeviceSpeed::HighSpeed, + value => return Err(unsupported_value_err!("DeviceIsHighSpeed", format!("{value}"))), + }; + Self::check_device_speed(usb_bus_iface_ver, device_speed) + .map_err(|reason| invalid_field_err!("USB_DEVICE_CAPABILITIES::DeviceIsHighSpeed", reason))?; + let no_ack_isoch_write_jitter_buf_size = match src.read_u32() { + 0 => NoAckIsochWriteJitterBufSizeInMs::TS_URB_ISOCH_TRANSFER_NOT_SUPPORTED, + value @ 10..=512 => NoAckIsochWriteJitterBufSizeInMs(value), + value => { + return Err(unsupported_value_err!( + "NoAckIsochWriteJitterBufferSizeInMs", + format!("{value}") + )); + } + }; + + Ok(Self { + usb_bus_iface_ver, + usbdi_ver, + supported_usb_ver, + device_speed, + no_ack_isoch_write_jitter_buf_size, + }) + } +} + +#[repr(u32)] +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum UsbBusIfaceVer { + V0 = 0x0, + V1 = 0x1, + V2 = 0x2, +} + +#[repr(u32)] +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum UsbdiVer { + V0x500 = 0x500, + V0x600 = 0x600, +} + +#[repr(u32)] +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum SupportedUsbVer { + Usb10 = 0x100, + Usb11 = 0x110, + Usb20 = 0x200, +} + +#[repr(u32)] +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum DeviceSpeed { + FullSpeed = 0x0, + HighSpeed = 0x1, +} + +#[repr(transparent)] +#[derive(Debug, PartialEq, Clone, Copy)] +pub struct NoAckIsochWriteJitterBufSizeInMs(u32); + +impl NoAckIsochWriteJitterBufSizeInMs { + const TS_URB_ISOCH_TRANSFER_NOT_SUPPORTED: Self = Self(0); + + pub fn outstanding_isoch_data(&self) -> Option { + (self.0 != 0).then_some(self.0) + } +} + +impl TryFrom for NoAckIsochWriteJitterBufSizeInMs { + type Error = &'static str; + // type Error = DecodeError; + + fn try_from(value: u32) -> Result { + match value { + 0 => Ok(Self::TS_URB_ISOCH_TRANSFER_NOT_SUPPORTED), + 10..=512 => Ok(Self(value)), + _ => Err("is not in the range: [10, 512]"), + } + } +} diff --git a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs new file mode 100644 index 0000000000..7b013b4924 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/mod.rs @@ -0,0 +1,874 @@ +//! Messages specific to the [USB Device][1] interface. +//! +//! The USB device interface is used by the server to send IO-related requests to the client. +//! +//! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/034257d7-f7a8-4fe1-b8c2-87ac8dc4f50e + +use alloc::format; +use alloc::vec::Vec; + +use ironrdp_core::{ + Decode as _, DecodeOwned as _, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, + ensure_size, invalid_field_err, other_err, unsupported_value_err, +}; +use ironrdp_dvc::DvcEncode; +use ironrdp_str::prefixed::Cch32String; + +use crate::pdu::header::{FunctionId, InterfaceId, Mask, MessageId, SharedMsgHeader}; +use crate::pdu::usb_dev::ts_urb::{TsUrbIn, TsUrbInKind, TsUrbOut}; +use crate::pdu::utils::{HResult, RequestId, RequestIdIoctl, RequestIdTransferInOut}; +#[cfg(doc)] +use crate::pdu::{ + completion::{IoControlCompletion, UrbCompletion, UrbCompletionNoData}, + sink::AddDevice, +}; + +pub mod ts_urb; + +/// [\[MS-RDPEUSB\] 2.2.6.1 Cancel Request Message (CANCEL_REQUEST)][1] message. +/// +/// Sent from the server to the client to cancel an outstanding IO request. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/93912b05-1fc8-4a43-8abd-78d9aab65d71 +#[doc(alias = "CANCEL_REQUEST")] +#[derive(Debug, PartialEq, Clone)] +pub struct CancelRequest { + pub msg_id: MessageId, + pub udev_iface: InterfaceId, + pub req_id: RequestId, +} + +impl CancelRequest { + const PAYLOAD_SIZE: usize = 4 /* RequestId */; + + const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ /* Header */ + Self::PAYLOAD_SIZE /* RequestId */; + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.udev_iface.with_mask(Mask::Proxy), + msg_id: self.msg_id, + function_id: Some(FunctionId::CANCEL_REQUEST), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { + ensure_size!(in: src, size: Self::PAYLOAD_SIZE); + let req_id = src.read_u32(); + + Ok(Self { + msg_id, + udev_iface, + req_id, + }) + } +} + +impl Encode for CancelRequest { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + self.header().encode(dst)?; + dst.write_u32(self.req_id); + Ok(()) + } + + fn name(&self) -> &'static str { + "CANCEL_REQUEST" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl DvcEncode for CancelRequest {} + +/// [\[MS-RDPEUSB\] 2.2.6.2 Register Request Callback Message (REGISTER_REQUEST_CALLBACK)][1] message. +/// +/// Sent from the server to the client in order to provide an interface ID for Request Completion +/// to the client. This interface ID is to be used by the subsequent [`IoControlCompletion`], +/// [`UrbCompletion`] and [`UrbCompletionNoData`] messages. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/8693de72-5e87-4b64-a252-101e865311a5 +#[doc(alias = "REGISTER_REQUEST_CALLBACK")] +#[derive(Debug, PartialEq, Clone)] +pub struct RegisterRequestCallback { + pub msg_id: MessageId, + pub udev_iface: InterfaceId, + pub request_completion: Option, +} + +impl RegisterRequestCallback { + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.udev_iface.with_mask(Mask::Proxy), + msg_id: self.msg_id, + function_id: Some(FunctionId::REGISTER_REQUEST_CALLBACK), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { + ensure_size!(in: src, size: 4 /* NumRequestCompletion */); + let request_completion = match src.read_u32() { + 0x0 => None, + _ => { + ensure_size!(in: src, size: InterfaceId::FIXED_PART_SIZE); + match src.read_u32() { + 0x0..=0x3 => { + return Err(invalid_field_err!( + "RequestCompletion", + "conflict with default interfaces" + )); + } + value => Some(InterfaceId::try_from(value)?), + } + } + }; + Ok(Self { + msg_id, + udev_iface, + request_completion, + }) + } +} + +impl Encode for RegisterRequestCallback { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + self.header().encode(dst)?; + if let Some(request_completion) = self.request_completion { + dst.write_u32(0x1); + dst.write_u32(request_completion.into()); + } else { + dst.write_u32(0x0); + } + + Ok(()) + } + + fn name(&self) -> &'static str { + "REGISTER_REQUEST_CALLBACK" + } + + fn size(&self) -> usize { + let request_completion_size = if self.request_completion.is_some() { 4 } else { 0 }; + SharedMsgHeader::SIZE_REQ + 4 + request_completion_size + } +} + +impl DvcEncode for RegisterRequestCallback {} + +/// [\[MS-RDPEUSB\] 2.2.6.3 IO Control Message (IO_CONTROL)][1] message. +/// +/// Sent from the server to the client to submit an IO control request to the USB device. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/021733cb-8e3b-49ac-b3e3-f7a764b11141 +#[doc(alias = "IO_CONTROL")] +#[derive(Debug, PartialEq, Clone)] +pub struct IoControl { + pub msg_id: MessageId, + pub udev_iface: InterfaceId, + pub ioctl_code: IoctlInternalUsb, + pub input_buffer: Vec, + pub output_buffer_size: u32, + pub req_id: RequestIdIoctl, +} + +impl IoControl { + /// Minimum payload size, assuming `InputBuffer` is empty. + pub const PAYLOAD_MIN_SIZE: usize = IoctlInternalUsb::FIXED_PART_SIZE // IoControlCode + + 4 // InputBufferSize + + 4 // OutputBufferSize + + 4; // RequestId + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.udev_iface.with_mask(Mask::Proxy), + msg_id: self.msg_id, + function_id: Some(FunctionId::IO_CONTROL), + } + } + + pub fn check_output_buffer_size(&self) -> Result<(), &'static str> { + match self.ioctl_code { + IoctlInternalUsb::ResetPort if self.output_buffer_size != 0 => { + Err("is not: 0; IO_CONTROL::IoControlCode: IOCTL_INTERNAL_USB_RESET_PORT") + } + IoctlInternalUsb::GetPortStatus if self.output_buffer_size != 4 => { + Err("is not: 4; IO_CONTROL::IoControlCode: IOCTL_INTERNAL_USB_GET_PORT_STATUS") + } + IoctlInternalUsb::GetHubCount if self.output_buffer_size != 4 => { + Err("is not: 4; IO_CONTROL::IoControlCode: IOCTL_INTERNAL_USB_GET_HUB_COUNT") + } + IoctlInternalUsb::CyclePort if self.output_buffer_size != 0 => { + Err("is not: 0; IO_CONTROL::IoControlCode: IOCTL_INTERNAL_USB_CYCLE_PORT") + } + // USB_BUS_NOTIFICATION will prolly not really be defined in IronRDP since libusb does + // not really provide APIs to fill all the fields of a USB_BUS_NOTIFICATION structure. + // Client should return IOCONTROL_COMPLETION with empty output buffer for + // IOCTL_INTERNAL_USB_GET_BUS_INFO + // https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usbioctl/ns-usbioctl-_usb_bus_notification + IoctlInternalUsb::GetBusInfo if self.output_buffer_size != 16 => Err( + "is not: 16 (size of USB_BUS_NOTIFICATION); IO_CONTROL::IoControlCode: IOCTL_INTERNAL_USB_GET_BUS_INFO", + ), + _ => Ok(()), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { + ensure_size!(in: src, size: Self::PAYLOAD_MIN_SIZE); + let ioctl_code = match src.read_u32() { + 0x220_007 => IoctlInternalUsb::ResetPort, + 0x220_013 => IoctlInternalUsb::GetPortStatus, + 0x220_01B => IoctlInternalUsb::GetHubCount, + 0x220_01F => IoctlInternalUsb::CyclePort, + 0x220_020 => IoctlInternalUsb::GetHubName, + 0x220_420 => IoctlInternalUsb::GetBusInfo, + 0x220_424 => IoctlInternalUsb::GetControllerName, + value => return Err(unsupported_value_err!("IoControlCode", format!("{value}"))), + }; + let input_buffer_size = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; + ensure_size!(in: src, + size: input_buffer_size); + // TODO: size limit + let input_buffer = src.read_slice(input_buffer_size).to_vec(); + ensure_size!(in: src, size: 4 /*output buffer size */ + 4 /* request id */); + let output_buffer_size = src.read_u32(); + let req_id = src.read_u32(); + let io_control = Self { + msg_id, + udev_iface, + ioctl_code, + input_buffer, + output_buffer_size, + req_id, + }; + + io_control + .check_output_buffer_size() + .map(|()| io_control) + .map_err(|reason| invalid_field_err!("IO_CONTROL::OutputBufferSize", reason)) + } +} + +impl Encode for IoControl { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + self.check_output_buffer_size() + .map_err(|reason| invalid_field_err!("IO_CONTROL::OutputBufferSize", reason))?; + ensure_size!(in: dst, size: self.size()); + self.header().encode(dst)?; + + #[expect(clippy::as_conversions)] + dst.write_u32(self.ioctl_code as u32); + + dst.write_u32(self.input_buffer.len().try_into().map_err(|e| other_err!(source: e))?); // InputBufferSize + dst.write_slice(&self.input_buffer); + + dst.write_u32(self.output_buffer_size); + dst.write_u32(self.req_id); + + Ok(()) + } + + fn name(&self) -> &'static str { + "IO_CONTROL" + } + + fn size(&self) -> usize { + SharedMsgHeader::SIZE_REQ + Self::PAYLOAD_MIN_SIZE + self.input_buffer.len() + } +} + +impl DvcEncode for IoControl {} + +/// [\[MS-RDPEUSB\] 2.2.12 USB IO Control Code][1]s. +/// +/// IO Control Codes are sent as part of an [`IoControl`] request, and these codes specify what +/// operation is requested in the I/O request. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/4f4574f0-9368-4708-8f98-06aa2f44e198 +#[repr(u32)] +#[non_exhaustive] +#[doc(alias = "IOCTL_INTERNAL_USB")] +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum IoctlInternalUsb { + /// [\[MS-RDPEUSB\] 2.2.12.1 IOCTL_INTERNAL_USB_RESET_PORT][1]. + /// + /// Used by a driver to reset the upstream port of the device it manages. For using this IOCTL + /// with an [`IoControl`] message, `input_buffer` should be empty, and `output_buffer_size` + /// should be set to `0`. See [WDK: IOCTL_INTERNAL_USB_RESET_PORT][2]. + /// + /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/8f13c014-2ece-481d-a843-9ae9b03d45fe + /// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usbioctl/ni-usbioctl-ioctl_internal_usb_reset_port + #[doc(alias = "IOCTL_INTERNAL_USB_RESET_PORT")] + ResetPort = 0x220_007, + + /// [\[MS-RDPEUSB\] 2.2.12.2 IOCTL_INTERNAL_USB_GET_PORT_STATUS][1]. + /// + /// Used to query the status of the device. For using this IOCTL with an [`IoControl`] message, + /// `input_buffer` should be empty, and `output_buffer_size` should be set to `4`. See [WDK: + /// IOCTL_INTERNAL_USB_GET_PORT_STATUS][2]. + /// + /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/598c5366-576d-4fe4-b928-0e1990f88098 + /// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usbioctl/ni-usbioctl-ioctl_internal_usb_get_port_status + #[doc(alias = "IOCTL_INTERNAL_USB_GET_PORT_STATUS")] + GetPortStatus = 0x220_013, + + /// [\[MS-RDPEUSB\] 2.2.12.3 IOCTL_INTERNAL_USB_GET_HUB_COUNT][1]. + /// + /// For using this IOCTL with an [`IoControl`] message, `input_buffer` should be empty, and + /// `output_buffer_size` should be set to `4`. See [WDK: IOCTL_INTERNAL_USB_GET_HUB_COUNT][2]. + /// + /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/9ce32995-3886-4c35-8f19-67e6a86a33ca + /// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usbioctl/ni-usbioctl-ioctl_internal_usb_get_hub_count + #[doc(alias = "IOCTL_INTERNAL_USB_GET_HUB_COUNT")] + GetHubCount = 0x220_01B, + + /// [\[MS-RDPEUSB\] 2.2.12.4 IOCTL_INTERNAL_USB_CYCLE_PORT][1]. + /// + /// Used to simulate a device unplug and replug of the USB device. For using this IOCTL with an + /// [`IoControl`] message, `input_buffer` should be empty, and `output_buffer_size` should be + /// set to `0`. See [WDK: IOCTL_INTERNAL_USB_CYCLE_PORT][2]. + /// + /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/5909123b-8a5c-4302-9eab-0bd43419573d + /// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usbioctl/ni-usbioctl-ioctl_internal_usb_cycle_port + #[doc(alias = "IOCTL_INTERNAL_USB_CYCLE_PORT")] + CyclePort = 0x220_01F, + + /// [\[MS-RDPEUSB\] 2.2.12.5 IOCTL_INTERNAL_USB_GET_HUB_NAME][1]. + /// + /// Used to retrieve the unicode symbolic name for the USB device if the USB device is a hub. + /// For using this IOCTL with an [`IoControl`] message, `input_buffer` should be empty. See + /// [WDK: IOCTL_INTERNAL_USB_GET_HUB_NAME][2]. + /// + /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/09ba1399-d642-4bdb-b9ec-41a4a34c4e98 + /// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usbioctl/ni-usbioctl-ioctl_internal_usb_get_hub_name + #[doc(alias = "IOCTL_INTERNAL_USB_GET_HUB_NAME")] + GetHubName = 0x220_020, + + /// [\[MS-RDPEUSB\] 2.2.12.6 IOCTL_INTERNAL_USB_GET_BUS_INFO][1]. + /// + /// Used to query for certain bus information (the fields of [`USB_BUS_NOTIFICATION`][2]). For + /// using this IOCTL with an [`IoControl`] message, `input_buffer` should be empty, and + /// `output_buffer_size` should be set to `16`. See [WDK: IOCTL_INTERNAL_USB_GET_BUS_INFO][2]. + /// + /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/632e208e-1aea-480d-b600-dfe9a25e05a2 + /// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usbioctl/ns-usbioctl-_usb_bus_notification + #[doc(alias = "IOCTL_INTERNAL_USB_GET_BUS_INFO")] + GetBusInfo = 0x220_420, + + /// [\[MS-RDPEUSB\] 2.2.12.7 IOCTL_INTERNAL_USB_GET_CONTROLLER_NAME][1]. + /// + /// Used to query the device name of the USB host controller. For using this IOCTL with an + /// [`IoControl`] message, `input_buffer` should be empty. See [WDK: + /// IOCTL_INTERNAL_USB_GET_CONTROLLER_NAME][2]. + /// + /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/f6fbc0ba-7736-49c2-a52e-bf538a8d6c15 + /// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usbioctl/ni-usbioctl-ioctl_internal_usb_get_controller_name + #[doc(alias = "IOCTL_INTERNAL_USB_GET_CONTROLLER_NAME")] + GetControllerName = 0x220_424, +} + +impl IoctlInternalUsb { + pub const FIXED_PART_SIZE: usize = 4 /* IoControlCode */; +} + +/// [\[MS-RDPEUSB\] 2.2.13 USB Internal IO Control Code][1]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/55d1cd44-eda3-4cba-931c-c3cb8b3c3c92 +#[derive(Debug, PartialEq, Clone, Copy)] +#[doc(alias = "IOCTL_TSUSBGD_IOCTL_USBDI_QUERY_BUS_TIME")] +pub struct UsbInternalIoctlCode(pub u32); + +impl UsbInternalIoctlCode { + /// [\[MS-RDPEUSB\] 2.2.13.1 IOCTL_TSUSBGD_IOCTL_USBDI_QUERY_BUS_TIME][1]. + /// + /// Sent when the server receives a request its system to query the device's current frame + /// number (as specified in *USB 2.0 Specification, section 10.2.3 Frame and Microframe + /// Generation*). To use with an [`InternalIoControl`] message, `input_buffer` should be empty, + /// and `output_buffer_size` should be set to `4`. This IOCTL is defined only in the context of + /// \[MS-RDPEUSB\] and not WDK. + /// + /// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/68506bc9-fedc-4fc1-b826-3cdbb1988774 + #[doc(alias = "IOCTL_TSUSBGD_IOCTL_USBDI_QUERY_BUS_TIME")] + pub const QUERY_BUS_TIME: Self = Self(0x00224000); +} + +/// [\[MS-RDPEUSB\] 2.2.6.4 Internal IO Control Message (INTERNAL_IO_CONTROL)][1] message. +/// +/// Sent from the server to the client to submit an internal IO control request to the USB device. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/c3f3e320-336d-4d1b-84c9-51e0ed330ffe +#[doc(alias = "INTERNAL_IO_CONTROL")] +#[derive(Debug, PartialEq, Clone)] +pub struct InternalIoControl { + pub msg_id: MessageId, + pub udev_iface: InterfaceId, + pub ioctl_code: UsbInternalIoctlCode, + pub input_buffer: Vec, + pub output_buffer_size: u32, + pub req_id: RequestIdIoctl, +} + +impl InternalIoControl { + pub const PAYLOAD_MIN_SIZE: usize = 4 // IoControlCode + + 4 // InputBufferSize + + 4 // OutputBufferSize + + 4; // RequestId + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.udev_iface.with_mask(Mask::Proxy), + msg_id: self.msg_id, + function_id: Some(FunctionId::INTERNAL_IO_CONTROL), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { + ensure_size!(in: src, size: Self::PAYLOAD_MIN_SIZE); + + let code = src.read_u32(); + + let size = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; + ensure_size!(in: src, size: size); + let input_buffer = src.read_slice(size).to_vec(); + + ensure_size!(in: src, size: 4 /*output buffer size */ + 4 /* request id */); + let output_buffer_size = src.read_u32(/* OutputBufferSize */); + let req_id = src.read_u32(); + + Ok(Self { + msg_id, + udev_iface, + ioctl_code: UsbInternalIoctlCode(code), + input_buffer, + output_buffer_size, + req_id, + }) + } +} + +impl Encode for InternalIoControl { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + self.header().encode(dst)?; + dst.write_u32(self.ioctl_code.0); // IoControlCode + dst.write_u32(self.input_buffer.len().try_into().map_err(|e| other_err!(source: e))?); // InputBufferSize + dst.write_slice(&self.input_buffer); // InputBuffer + dst.write_u32(self.output_buffer_size); // OutputBufferSize + dst.write_u32(self.req_id); + + Ok(()) + } + + fn name(&self) -> &'static str { + "INTERNAL_IO_CONTROL" + } + + fn size(&self) -> usize { + SharedMsgHeader::SIZE_REQ + Self::PAYLOAD_MIN_SIZE + self.input_buffer.len() + } +} + +impl DvcEncode for InternalIoControl {} + +/// [\[MS-RDPEUSB\] 2.2.6.5 Query Device Text Message (QUERY_DEVICE_TEXT)][1] message. +/// +/// Sent from the server to the client in order to query the USB's device text (like description or +/// location information) when it receives a query device text request +/// ([`IRP_MN_QUERY_DEVICE_TEXT`][2]) from its system. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/d03a7696-2d56-4f20-b7a9-a5e72a045956 +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/irp-mn-query-device-text +#[doc(alias = "QUERY_DEVICE_TEXT")] +#[derive(Debug, PartialEq, Clone)] +pub struct QueryDeviceText { + pub msg_id: MessageId, + pub udev_iface: InterfaceId, + pub text_type: u32, + // TODO: Find out if MS-LCID and USB language ID's are same + pub locale_id: u32, +} + +impl QueryDeviceText { + pub const PAYLOAD_SIZE: usize = 4 /* TextType */ + 4 /* LocaleId */; + + pub const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ /* Header */ + Self::PAYLOAD_SIZE; + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.udev_iface.with_mask(Mask::Proxy), + msg_id: self.msg_id, + function_id: Some(FunctionId::QUERY_DEVICE_TEXT), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { + ensure_size!(in: src, size: Self::PAYLOAD_SIZE); + + let text_type = src.read_u32(); + let locale_id = src.read_u32(); + + Ok(Self { + msg_id, + udev_iface, + text_type, + locale_id, + }) + } +} + +impl Encode for QueryDeviceText { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + self.header().encode(dst)?; + dst.write_u32(self.text_type); + dst.write_u32(self.locale_id); + + Ok(()) + } + + fn name(&self) -> &'static str { + "QUERY_DEVICE_TEXT" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl DvcEncode for QueryDeviceText {} + +/// [\[MS-RDPEUSB\] 2.2.6.6 Query Device Text Response Message (QUERY_DEVICE_TEXT_RSP)][1] message. +/// +/// Sent from the client in response to a [`QueryDeviceText`] message sent by the server. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/acffdcfa-c792-40a4-a8ee-c545ea5b0a38 +#[doc(alias = "QUERY_DEVICE_TEXT_RSP")] +#[derive(Debug, PartialEq, Clone)] +pub struct QueryDeviceTextRsp { + pub msg_id: MessageId, + pub udev_iface: InterfaceId, + pub device_description: Cch32String, + pub hresult: HResult, +} + +impl QueryDeviceTextRsp { + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.udev_iface.with_mask(Mask::Stub), + msg_id: self.msg_id, + function_id: None, + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { + let device_description = Cch32String::decode_owned(src)?; + + ensure_size!(in: src, size: 4 /* HResult */); + let hresult = src.read_u32(); + + Ok(Self { + msg_id, + udev_iface, + device_description, + hresult, + }) + } +} + +impl Encode for QueryDeviceTextRsp { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + self.header().encode(dst)?; + self.device_description.encode(dst)?; + + dst.write_u32(self.hresult); + + Ok(()) + } + + fn name(&self) -> &'static str { + "QUERY_DEVICE_TEXT_RSP" + } + + fn size(&self) -> usize { + SharedMsgHeader::SIZE_RSP /* Header */ + + self.device_description.size() // cchDeviceDescription + DeviceDescription + + 4 /* HResult */ + } +} + +impl DvcEncode for QueryDeviceTextRsp {} + +/// [\[MS-RDPEUSB\] 2.2.6.7 Transfer In Request (TRANSFER_IN_REQUEST)][1] message. +/// +/// Sent from the server to the client in order to request data from the USB device. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/e40f7738-bdd3-480f-a8bb-e1557a83a151 +#[doc(alias = "TRANSFER_IN_REQUEST")] +#[derive(Debug, PartialEq, Clone)] +pub struct TransferInRequest { + pub msg_id: MessageId, + pub udev_iface: InterfaceId, + pub ts_urb: TsUrbIn, + pub output_buffer_size: u32, +} + +impl TransferInRequest { + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.udev_iface.with_mask(Mask::Proxy), + msg_id: self.msg_id, + function_id: Some(FunctionId::TRANSFER_IN_REQUEST), + } + } + + pub fn request_id(&self) -> RequestIdTransferInOut { + self.ts_urb.header.req_id + } + + pub fn check_output_buffer_size(&self) -> Result<(), &'static str> { + use TsUrbInKind::*; + + match &self.ts_urb.kind { + SelectConfig(_) if self.output_buffer_size != 0 => { + Err("is not: 0; TRANSFER_IN_REQUEST::TsUrb: TS_URB_SELECT_CONFIGURATION") + } + SelectIface(_) if self.output_buffer_size != 0 => { + Err("is not: 0; TRANSFER_IN_REQUEST::TsUrb: TS_URB_SELECT_INTERFACE") + } + PipeReq(_) if self.output_buffer_size != 0 => { + Err("is not: 0; TRANSFER_IN_REQUEST::TsUrb: TS_URB_PIPE_REQUEST") + } + GetCurFrameNum(_) if self.output_buffer_size != 0 => { + Err("is not: 0; TRANSFER_IN_REQUEST::TsUrb: TS_URB_GET_CURRENT_FRAME_NUMBER") + } + CtlFeatReq(_) if self.output_buffer_size != 0 => { + Err("is not: 0; TRANSFER_IN_REQUEST::TsUrb: TS_URB_CONTROL_FEATURE_REQUEST") + } + CtlGetStatus(_) if self.output_buffer_size != 2 => { + Err("is not: 2; TRANSFER_IN_REQUEST::TsUrb: TS_URB_CONTROL_GET_STATUS_REQUEST") + } + CtlGetConfig(_) if self.output_buffer_size != 1 => { + Err("is not: 1; TRANSFER_IN_REQUEST::TsUrb: TS_URB_CONTROL_GET_CONFIGURATION_REQUEST") + } + CtlGetIface(_) if self.output_buffer_size != 1 => { + Err("is not: 1; TRANSFER_IN_REQUEST::TsUrb: TS_URB_CONTROL_GET_INTERFACE_REQUEST") + } + // At the time of writing, MS OS Feature Descriptor size can be max 4 * 1024 bytes. + // But still, can't really enforce any bounds on OutputBufferSize. + OsFeatDescReq(_) => Ok(()), + // No bounds whatsoever for all the other TS_URB's + _ => Ok(()), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { + ensure_size!(in: src, size: 4 /* CbTsUrb */); + let cb_ts_urb = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; + + ensure_size!(in: src, size: cb_ts_urb); + let ts_urb = TsUrbIn::decode(&mut ReadCursor::new(src.read_slice(cb_ts_urb)))?; + + ensure_size!(in: src, size: 4 /* OutputBufferSize */); + let output_buffer_size = src.read_u32(); + + let transfer_in_req = Self { + msg_id, + udev_iface, + ts_urb, + output_buffer_size, + }; + + transfer_in_req + .check_output_buffer_size() + .map_err(|reason| invalid_field_err!("TRANSFER_IN_REQUEST::OutputBufferSize", reason))?; + + Ok(transfer_in_req) + } +} + +impl Encode for TransferInRequest { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + self.check_output_buffer_size() + .map_err(|reason| invalid_field_err!("TRANSFER_IN_REQUEST::OutputBufferSize", reason))?; + ensure_size!(in: dst, size: self.size()); + + self.header().encode(dst)?; + dst.write_u32(self.ts_urb.size().try_into().map_err(|e| other_err!(source: e))?); + self.ts_urb.encode(dst)?; + dst.write_u32(self.output_buffer_size); + + Ok(()) + } + + fn name(&self) -> &'static str { + "TRANSFER_IN_REQUEST" + } + + fn size(&self) -> usize { + SharedMsgHeader::SIZE_REQ /* Header */ + + 4 /* CbTsUrb */ + + self.ts_urb.size() /* TsUrb */ + + 4 /* OutputBufferSize */ + } +} + +impl DvcEncode for TransferInRequest {} + +/// [\[MS-RDPEUSB\] 2.2.6.8 Transfer Out Request (TRANSFER_OUT_REQUEST)][1] message. +/// +/// Sent from the server to the client in order to submit data to the USB device. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/6d6c85b2-47bb-4674-975a-dc7d8ed684cd +#[doc(alias = "TRANSFER_OUT_REQUEST")] +#[derive(Debug, PartialEq, Clone)] +pub struct TransferOutRequest { + pub msg_id: MessageId, + pub udev_iface: InterfaceId, + pub ts_urb: TsUrbOut, + pub output_buffer: Vec, +} + +impl TransferOutRequest { + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.udev_iface.with_mask(Mask::Proxy), + msg_id: self.msg_id, + function_id: Some(FunctionId::TRANSFER_OUT_REQUEST), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { + let ts_urb = { + ensure_size!(in: src, size: 4 /* CbTsUrb */); + let cb_ts_urb = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; + ensure_size!(in: src, size: cb_ts_urb); + let mut src = ReadCursor::new(src.read_slice(cb_ts_urb)); + TsUrbOut::decode(&mut src)? + }; + + ensure_size!(in: src, size: 4 /* OutputBufferSize */); + let output_buffer_size = src.read_u32().try_into().map_err(|e| other_err!(source: e))?; + // TODO: limit size + ensure_size!(in: src, size: output_buffer_size); + let output_buffer = src.read_slice(output_buffer_size).to_vec(); + + Ok(Self { + msg_id, + udev_iface, + ts_urb, + output_buffer, + }) + } +} + +impl Encode for TransferOutRequest { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + self.header().encode(dst)?; + + dst.write_u32(self.ts_urb.size().try_into().map_err(|e| other_err!(source: e))?); + + self.ts_urb.encode(dst)?; + + dst.write_u32(self.output_buffer.len().try_into().map_err(|e| other_err!(source: e))?); + + dst.write_slice(&self.output_buffer); + + Ok(()) + } + + fn name(&self) -> &'static str { + "TRANSFER_OUT_REQUEST" + } + + fn size(&self) -> usize { + SharedMsgHeader::SIZE_REQ /* Header */ + + 4 /* CbTsUrb */ + + self.ts_urb.size() /* TsUrb */ + + 4 /* OutputBufferSize */ + + self.output_buffer.len() /* OutputBuffer */ + } +} + +impl DvcEncode for TransferOutRequest {} + +/// [\[MS-RDPEUSB\] 2.2.6.9 Retract Device (RETRACT_DEVICE)][1] message. +/// +/// Sent from the server to the client in order to stop redirecting the USB device. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/92eeb057-9314-48ab-bc37-199d892ebc9f +#[doc(alias = "RETRACT_DEVICE")] +#[derive(Debug, PartialEq, Clone)] +pub struct RetractDevice { + pub msg_id: MessageId, + pub udev_iface: InterfaceId, + pub reason: UsbRetractReason, +} + +impl RetractDevice { + pub const PAYLOAD_SIZE: usize = 4 /* Reason */; + + pub const FIXED_PART_SIZE: usize = SharedMsgHeader::SIZE_REQ /* Header */ + Self::PAYLOAD_SIZE; + + pub fn header(&self) -> SharedMsgHeader { + SharedMsgHeader { + iface_id: self.udev_iface.with_mask(Mask::Proxy), + msg_id: self.msg_id, + function_id: Some(FunctionId::RETRACT_DEVICE), + } + } + + pub(crate) fn decode(src: &mut ReadCursor<'_>, msg_id: MessageId, udev_iface: InterfaceId) -> DecodeResult { + ensure_size!(in: src, size: Self::PAYLOAD_SIZE); + + let reason = src.read_u32(); + #[expect(clippy::as_conversions)] + if reason != UsbRetractReason::BlockedByPolicy as u32 { + return Err(unsupported_value_err!("RETRACT_DEVICE::Reason", format!("{reason}"))); + } + + Ok(Self { + msg_id, + udev_iface, + reason: UsbRetractReason::BlockedByPolicy, + }) + } +} + +impl Encode for RetractDevice { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + self.header().encode(dst)?; + #[expect(clippy::as_conversions)] + dst.write_u32(self.reason as u32); + Ok(()) + } + + fn name(&self) -> &'static str { + "RETRACT_DEVICE" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEUSB\] 2.2.8 USB_RETRACT_REASON Constants][1]. +/// +/// The reason why the server requests the client to stop redirecting a USB device. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/f3a2ce5e-7c9a-4b0d-b98a-d0241f538b10 +#[repr(u32)] +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum UsbRetractReason { + /// The USB device is to be stopped from being redirected because the device is blocked by the + /// server's (group) policy. + BlockedByPolicy = 0x1, +} + +impl DvcEncode for RetractDevice {} diff --git a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/mod.rs b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/mod.rs new file mode 100644 index 0000000000..3b4824c744 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/mod.rs @@ -0,0 +1,1406 @@ +//! Packets sent to the client as part of [`TransferInRequest`] and [`TransferOutRequest`] messages +//! when the server receives a URB request from its system. +//! +//! A [`TsUrbIn`] packet is sent as part of a [`TransferInRequest`], a [`TsUrbOut`] packet is sent +//! as part of a [`TransferOutRequest`]. + +use alloc::format; +use alloc::vec::Vec; + +use ironrdp_core::{ + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, + invalid_field_err, read_padding, unsupported_value_err, write_padding, +}; +use ironrdp_pdu::{PduResult, pdu_other_err}; + +use crate::pdu::usb_dev::ts_urb::utils::{SetupPacket, TsUrbHeader, TsUsbdInterfaceInfo, UrbFunction, UsbConfigDesc}; +#[cfg(doc)] +use crate::pdu::usb_dev::{TransferInRequest, TransferOutRequest}; +use crate::pdu::utils::{ConfigHandle, FrameNumber, PipeHandle, USBD_TRANSFER_DIRECTION_IN, UsbdIsoPacketDesc}; + +pub mod utils; + +macro_rules! ensure_transfer_flag { + ($direction:expr, $transfer_flags:expr, $ts_urb_name:expr) => { + let flag_in = ($transfer_flags & USBD_TRANSFER_DIRECTION_IN) == USBD_TRANSFER_DIRECTION_IN; + let transfer_in = matches!($direction, TransferDirection::In); + if transfer_in && !flag_in { + return Err(invalid_field_err!( + concat!("TRANSFER_IN_REQUEST::TsUrb: ", $ts_urb_name, "::TransferFlags"), + "does not contain USBD_TRANSFER_DIRECTION_IN" + )); + } else if !transfer_in && flag_in { + return Err(invalid_field_err!( + concat!("TRANSFER_OUT_REQUEST::TsUrb: ", $ts_urb_name, "::TransferFlags"), + "contains USBD_TRANSFER_DIRECTION_IN" + )); + } + }; +} + +/// Enumeration of all the [\[MS-RDPEUSB\] 2.2.9 TS_URB TRANSFER_IN_REQUEST Structures][1]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/eed35296-3ca1-4271-bd0a-597138131b47 +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbIn { + pub kind: TsUrbInKind, + pub header: TsUrbHeader, +} + +impl Decode<'_> for TsUrbIn { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let header = TsUrbHeader::decode(src)?; + if header.no_ack { + return Err(invalid_field_err!( + "TRANSFER_IN_REQUEST::TsUrb::TS_URB_HEADER::NoAck", + "is non-zero: NoAck MUST be set to zero for TRANSFER_IN_REQUEST" + )); + } + + let kind = TsUrbInKind::decode(src, header)?; + + Ok(Self { kind, header }) + } +} + +impl Encode for TsUrbIn { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + if self.header.no_ack { + return Err(invalid_field_err!( + "TRANSFER_IN_REQUEST::TsUrb::TS_URB_HEADER::NoAck", + "is non-zero: NoAck MUST be set to zero for TRANSFER_IN_REQUEST" + )); + } + if !self.kind.matches_func(self.header.func) { + return Err(invalid_field_err!( + "TRANSFER_IN_REQUEST::TsUrb::TS_URB_HEADER::URB_Function", + "does not match TS_URB payload" + )); + } + + ensure_size!(in: dst, size: self.size()); + self.header.encode_with_size(dst, self.size())?; + self.kind.encode(dst) + } + + fn name(&self) -> &'static str { + "TS_URB" + } + + fn size(&self) -> usize { + TsUrbHeader::FIXED_PART_SIZE + self.kind.size() + } +} + +/// Enumeration of all the [\[MS-RDPEUSB\] 2.2.9 TS_URB TRANSFER_IN_REQUEST Structures][1]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/eed35296-3ca1-4271-bd0a-597138131b47 +#[derive(Debug, PartialEq, Clone)] +pub enum TsUrbInKind { + SelectConfig(TsUrbSelectConfig), + SelectIface(TsUrbSelectInterface), + PipeReq(TsUrbPipeRequest), + GetCurFrameNum(TsUrbGetCurrFrameNum), + CtlTransfer(TsUrbControlTransfer), + BulkInterruptTransfer(TsUrbBulkOrInterruptTransfer), + IsochTransfer(TsUrbIsochTransfer), + CtlDescReq(TsUrbControlDescRequest), + CtlFeatReq(TsUrbControlFeatRequest), + CtlGetStatus(TsUrbControlGetStatusRequest), + VendorClassReq(TsUrbControlVendorClassRequest), + CtlGetConfig(TsUrbControlGetConfigRequest), + CtlGetIface(TsUrbControlGetInterfaceRequest), + OsFeatDescReq(TsUrbOsFeatDescRequest), + CtlTransferEx(TsUrbControlTransferEx), +} + +impl TsUrbInKind { + pub fn ts_urb_size(&self) -> PduResult { + u16::try_from(TsUrbHeader::FIXED_PART_SIZE + self.size()) + .map_err(|_| pdu_other_err!("converts usize to u16 failed")) + } + + pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { + let payload_size = usize::from(header.ts_urb_size) - header.size(); + ensure_size!(in: src, size: payload_size); + let mut src = ReadCursor::new(src.read_slice(payload_size)); + + let ts_urb = match header.func { + UrbFunction::URB_FUNCTION_SELECT_CONFIGURATION => Self::SelectConfig(TsUrbSelectConfig::decode(&mut src)?), + UrbFunction::URB_FUNCTION_SELECT_INTERFACE => Self::SelectIface(TsUrbSelectInterface::decode(&mut src)?), + UrbFunction::URB_FUNCTION_ABORT_PIPE + | UrbFunction::URB_FUNCTION_SYNC_RESET_PIPE_AND_CLEAR_STALL + | UrbFunction::URB_FUNCTION_SYNC_RESET_PIPE + | UrbFunction::URB_FUNCTION_SYNC_CLEAR_STALL + | UrbFunction::URB_FUNCTION_CLOSE_STATIC_STREAMS => Self::PipeReq(TsUrbPipeRequest::decode(&mut src)?), + UrbFunction::URB_FUNCTION_GET_CURRENT_FRAME_NUMBER => { + Self::GetCurFrameNum(TsUrbGetCurrFrameNum::decode(&mut src)?) + } + UrbFunction::URB_FUNCTION_CONTROL_TRANSFER => { + let urb = TsUrbControlTransfer::decode(&mut src)?; + ensure_transfer_flag!(TransferDirection::In, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER"); + Self::CtlTransfer(urb) + } + UrbFunction::URB_FUNCTION_CONTROL_TRANSFER_EX => { + let urb = TsUrbControlTransferEx::decode(&mut src)?; + ensure_transfer_flag!(TransferDirection::In, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER_EX"); + Self::CtlTransferEx(urb) + } + UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER + | UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL => { + let urb = TsUrbBulkOrInterruptTransfer::decode(&mut src)?; + ensure_transfer_flag!( + TransferDirection::In, + urb.transfer_flags, + "TS_URB_BULK_OR_INTERRUPT_TRANSFER" + ); + Self::BulkInterruptTransfer(urb) + } + UrbFunction::URB_FUNCTION_ISOCH_TRANSFER | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL => { + let urb = TsUrbIsochTransfer::decode(&mut src)?; + ensure_transfer_flag!(TransferDirection::In, urb.transfer_flags, "TS_URB_ISOCH_TRANSFER"); + Self::IsochTransfer(urb) + } + UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE + | UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT + | UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE => { + Self::CtlDescReq(TsUrbControlDescRequest::decode(&mut src)?) + } + UrbFunction::URB_FUNCTION_SET_FEATURE_TO_DEVICE + | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_INTERFACE + | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_ENDPOINT + | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_OTHER + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_DEVICE + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_INTERFACE + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_ENDPOINT + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_OTHER => { + Self::CtlFeatReq(TsUrbControlFeatRequest::decode(&mut src)?) + } + UrbFunction::URB_FUNCTION_GET_STATUS_FROM_DEVICE + | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_INTERFACE + | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_ENDPOINT + | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_OTHER => { + Self::CtlGetStatus(TsUrbControlGetStatusRequest::decode(&mut src)?) + } + UrbFunction::URB_FUNCTION_VENDOR_DEVICE + | UrbFunction::URB_FUNCTION_VENDOR_INTERFACE + | UrbFunction::URB_FUNCTION_VENDOR_ENDPOINT + | UrbFunction::URB_FUNCTION_VENDOR_OTHER + | UrbFunction::URB_FUNCTION_CLASS_DEVICE + | UrbFunction::URB_FUNCTION_CLASS_INTERFACE + | UrbFunction::URB_FUNCTION_CLASS_ENDPOINT + | UrbFunction::URB_FUNCTION_CLASS_OTHER => { + let urb = TsUrbControlVendorClassRequest::decode(&mut src)?; + ensure_transfer_flag!( + TransferDirection::In, + urb.transfer_flags, + "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST" + ); + Self::VendorClassReq(urb) + } + UrbFunction::URB_FUNCTION_GET_CONFIGURATION => { + Self::CtlGetConfig(TsUrbControlGetConfigRequest::decode(&mut src)?) + } + + UrbFunction::URB_FUNCTION_GET_INTERFACE => { + Self::CtlGetIface(TsUrbControlGetInterfaceRequest::decode(&mut src)?) + } + + UrbFunction::URB_FUNCTION_GET_MS_FEATURE_DESCRIPTOR => { + Self::OsFeatDescReq(TsUrbOsFeatDescRequest::decode(&mut src)?) + } + func => return Err(unsupported_value_err!("URB Function", format!("{}", u16::from(func)))), + }; + + Ok(ts_urb) + } + + pub(crate) fn matches_func(&self, func: UrbFunction) -> bool { + matches!( + (self, func), + (Self::SelectConfig(_), UrbFunction::URB_FUNCTION_SELECT_CONFIGURATION) + | (Self::SelectIface(_), UrbFunction::URB_FUNCTION_SELECT_INTERFACE) + | ( + Self::PipeReq(_), + UrbFunction::URB_FUNCTION_ABORT_PIPE + | UrbFunction::URB_FUNCTION_SYNC_RESET_PIPE_AND_CLEAR_STALL + | UrbFunction::URB_FUNCTION_SYNC_RESET_PIPE + | UrbFunction::URB_FUNCTION_SYNC_CLEAR_STALL + | UrbFunction::URB_FUNCTION_CLOSE_STATIC_STREAMS + ) + | ( + Self::GetCurFrameNum(_), + UrbFunction::URB_FUNCTION_GET_CURRENT_FRAME_NUMBER + ) + | (Self::CtlTransfer(_), UrbFunction::URB_FUNCTION_CONTROL_TRANSFER) + | ( + Self::BulkInterruptTransfer(_), + UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER + | UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL + ) + | ( + Self::IsochTransfer(_), + UrbFunction::URB_FUNCTION_ISOCH_TRANSFER + | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL + ) + | ( + Self::CtlDescReq(_), + UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE + | UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT + | UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE + ) + | ( + Self::CtlFeatReq(_), + UrbFunction::URB_FUNCTION_SET_FEATURE_TO_DEVICE + | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_INTERFACE + | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_ENDPOINT + | UrbFunction::URB_FUNCTION_SET_FEATURE_TO_OTHER + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_DEVICE + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_INTERFACE + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_ENDPOINT + | UrbFunction::URB_FUNCTION_CLEAR_FEATURE_TO_OTHER + ) + | ( + Self::CtlGetStatus(_), + UrbFunction::URB_FUNCTION_GET_STATUS_FROM_DEVICE + | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_INTERFACE + | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_ENDPOINT + | UrbFunction::URB_FUNCTION_GET_STATUS_FROM_OTHER + ) + | ( + Self::VendorClassReq(_), + UrbFunction::URB_FUNCTION_VENDOR_DEVICE + | UrbFunction::URB_FUNCTION_VENDOR_INTERFACE + | UrbFunction::URB_FUNCTION_VENDOR_ENDPOINT + | UrbFunction::URB_FUNCTION_VENDOR_OTHER + | UrbFunction::URB_FUNCTION_CLASS_DEVICE + | UrbFunction::URB_FUNCTION_CLASS_INTERFACE + | UrbFunction::URB_FUNCTION_CLASS_ENDPOINT + | UrbFunction::URB_FUNCTION_CLASS_OTHER + ) + | (Self::CtlGetConfig(_), UrbFunction::URB_FUNCTION_GET_CONFIGURATION) + | (Self::CtlGetIface(_), UrbFunction::URB_FUNCTION_GET_INTERFACE) + | ( + Self::OsFeatDescReq(_), + UrbFunction::URB_FUNCTION_GET_MS_FEATURE_DESCRIPTOR + ) + | (Self::CtlTransferEx(_), UrbFunction::URB_FUNCTION_CONTROL_TRANSFER_EX) + ) + } +} + +impl Encode for TsUrbInKind { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + use TsUrbInKind::*; + match self { + SelectConfig(urb) => urb.encode(dst), + SelectIface(urb) => urb.encode(dst), + PipeReq(urb) => urb.encode(dst), + GetCurFrameNum(urb) => urb.encode(dst), + CtlTransfer(urb) => { + ensure_transfer_flag!(TransferDirection::In, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER"); + urb.encode(dst) + } + BulkInterruptTransfer(urb) => { + ensure_transfer_flag!( + TransferDirection::In, + urb.transfer_flags, + "TS_URB_BULK_OR_INTERRUPT_TRANSFER" + ); + urb.encode(dst) + } + IsochTransfer(urb) => { + ensure_transfer_flag!(TransferDirection::In, urb.transfer_flags, "TS_URB_ISOCH_TRANSFER"); + urb.encode(dst) + } + CtlDescReq(urb) => urb.encode(dst), + CtlFeatReq(urb) => urb.encode(dst), + CtlGetStatus(urb) => urb.encode(dst), + VendorClassReq(urb) => { + ensure_transfer_flag!( + TransferDirection::In, + urb.transfer_flags, + "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST" + ); + urb.encode(dst) + } + CtlGetConfig(urb) => urb.encode(dst), + CtlGetIface(urb) => urb.encode(dst), + OsFeatDescReq(urb) => urb.encode(dst), + CtlTransferEx(urb) => { + ensure_transfer_flag!(TransferDirection::In, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER_EX"); + urb.encode(dst) + } + } + } + + fn size(&self) -> usize { + use TsUrbInKind::*; + match self { + SelectConfig(urb) => urb.size(), + SelectIface(urb) => urb.size(), + PipeReq(urb) => urb.size(), + GetCurFrameNum(urb) => urb.size(), + CtlTransfer(urb) => urb.size(), + BulkInterruptTransfer(urb) => urb.size(), + IsochTransfer(urb) => urb.size(), + CtlDescReq(urb) => urb.size(), + CtlFeatReq(urb) => urb.size(), + CtlGetStatus(urb) => urb.size(), + VendorClassReq(urb) => urb.size(), + CtlGetConfig(urb) => urb.size(), + CtlGetIface(urb) => urb.size(), + OsFeatDescReq(urb) => urb.size(), + CtlTransferEx(urb) => urb.size(), + } + } + + fn name(&self) -> &'static str { + "TS_URB" + } +} + +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbOut { + pub kind: TsUrbOutKind, + pub header: TsUrbHeader, +} + +impl Decode<'_> for TsUrbOut { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let header = TsUrbHeader::decode(src)?; + + let kind = TsUrbOutKind::decode(src, header)?; + Ok(Self { kind, header }) + } +} + +impl Encode for TsUrbOut { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + if !self.kind.matches_func(self.header.func) { + return Err(invalid_field_err!( + "TRANSFER_OUT_REQUEST::TsUrb::TS_URB_HEADER::URB_Function", + "does not match TS_URB payload" + )); + } + if self.header.no_ack + && !matches!( + self.header.func, + UrbFunction::URB_FUNCTION_ISOCH_TRANSFER | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL + ) + { + return Err(invalid_field_err!( + "TRANSFER_OUT_REQUEST::TsUrb::TS_URB_HEADER::NoAck", + "can only be set for TS_URB_ISOCH_TRANSFER" + )); + } + + ensure_size!(in: dst, size: self.size()); + self.header.encode_with_size(dst, self.size())?; + self.kind.encode(dst)?; + + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB" + } + + fn size(&self) -> usize { + TsUrbHeader::FIXED_PART_SIZE + self.kind.size() + } +} + +/// Enumeration of all the [\[MS-RDPEUSB\] 2.2.9 TS_URB TRANSFER_OUT_REQUEST Structures][1]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/eed35296-3ca1-4271-bd0a-597138131b47 +#[derive(Debug, PartialEq, Clone)] +pub enum TsUrbOutKind { + CtlTransfer(TsUrbControlTransfer), + BulkInterruptTransfer(TsUrbBulkOrInterruptTransfer), + IsochTransfer(TsUrbIsochTransfer), + CtlDescReq(TsUrbControlDescRequest), + VendorClassReq(TsUrbControlVendorClassRequest), + CtlTransferEx(TsUrbControlTransferEx), +} + +impl TsUrbOutKind { + pub fn ts_urb_size(&self) -> PduResult { + u16::try_from(TsUrbHeader::FIXED_PART_SIZE + self.size()) + .map_err(|_| pdu_other_err!("converts usize to u16 failed")) + } + + pub fn decode(src: &mut ReadCursor<'_>, header: TsUrbHeader) -> DecodeResult { + let payload_size = usize::from(header.ts_urb_size) - header.size(); + ensure_size!(in: src, size: payload_size); + let mut src = ReadCursor::new(src.read_slice(payload_size)); + + let ts_urb = match header.func { + UrbFunction::URB_FUNCTION_CONTROL_TRANSFER => { + let urb = TsUrbControlTransfer::decode(&mut src)?; + ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER"); + Self::CtlTransfer(urb) + } + UrbFunction::URB_FUNCTION_CONTROL_TRANSFER_EX => { + let urb = TsUrbControlTransferEx::decode(&mut src)?; + ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER_EX"); + Self::CtlTransferEx(urb) + } + UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER + | UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL => { + let urb = TsUrbBulkOrInterruptTransfer::decode(&mut src)?; + ensure_transfer_flag!( + TransferDirection::Out, + urb.transfer_flags, + "TS_URB_BULK_OR_INTERRUPT_TRANSFER" + ); + Self::BulkInterruptTransfer(urb) + } + UrbFunction::URB_FUNCTION_ISOCH_TRANSFER | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL => { + let urb = TsUrbIsochTransfer::decode(&mut src)?; + ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_ISOCH_TRANSFER"); + Self::IsochTransfer(urb) + } + UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE + | UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT + | UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE => { + Self::CtlDescReq(TsUrbControlDescRequest::decode(&mut src)?) + } + UrbFunction::URB_FUNCTION_VENDOR_DEVICE + | UrbFunction::URB_FUNCTION_VENDOR_INTERFACE + | UrbFunction::URB_FUNCTION_VENDOR_ENDPOINT + | UrbFunction::URB_FUNCTION_VENDOR_OTHER + | UrbFunction::URB_FUNCTION_CLASS_DEVICE + | UrbFunction::URB_FUNCTION_CLASS_INTERFACE + | UrbFunction::URB_FUNCTION_CLASS_ENDPOINT + | UrbFunction::URB_FUNCTION_CLASS_OTHER => { + let urb = TsUrbControlVendorClassRequest::decode(&mut src)?; + ensure_transfer_flag!( + TransferDirection::Out, + urb.transfer_flags, + "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST" + ); + Self::VendorClassReq(urb) + } + func => return Err(unsupported_value_err!("URB Function", format!("{}", u16::from(func)))), + }; + + Ok(ts_urb) + } + + pub(crate) fn matches_func(&self, func: UrbFunction) -> bool { + matches!( + (self, func), + (Self::CtlTransfer(_), UrbFunction::URB_FUNCTION_CONTROL_TRANSFER) + | ( + Self::BulkInterruptTransfer(_), + UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER + | UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL + ) + | ( + Self::IsochTransfer(_), + UrbFunction::URB_FUNCTION_ISOCH_TRANSFER + | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL + ) + | ( + Self::CtlDescReq(_), + UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE + | UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT + | UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE + ) + | ( + Self::VendorClassReq(_), + UrbFunction::URB_FUNCTION_VENDOR_DEVICE + | UrbFunction::URB_FUNCTION_VENDOR_INTERFACE + | UrbFunction::URB_FUNCTION_VENDOR_ENDPOINT + | UrbFunction::URB_FUNCTION_VENDOR_OTHER + | UrbFunction::URB_FUNCTION_CLASS_DEVICE + | UrbFunction::URB_FUNCTION_CLASS_INTERFACE + | UrbFunction::URB_FUNCTION_CLASS_ENDPOINT + | UrbFunction::URB_FUNCTION_CLASS_OTHER + ) + | (Self::CtlTransferEx(_), UrbFunction::URB_FUNCTION_CONTROL_TRANSFER_EX) + ) + } +} + +impl Encode for TsUrbOutKind { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + use TsUrbOutKind::*; + match self { + CtlTransfer(urb) => { + ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER"); + urb.encode(dst) + } + BulkInterruptTransfer(urb) => { + ensure_transfer_flag!( + TransferDirection::Out, + urb.transfer_flags, + "TS_URB_BULK_OR_INTERRUPT_TRANSFER" + ); + urb.encode(dst) + } + IsochTransfer(urb) => { + ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_ISOCH_TRANSFER"); + urb.encode(dst) + } + CtlDescReq(urb) => urb.encode(dst), + VendorClassReq(urb) => { + ensure_transfer_flag!( + TransferDirection::Out, + urb.transfer_flags, + "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST" + ); + urb.encode(dst) + } + CtlTransferEx(urb) => { + ensure_transfer_flag!(TransferDirection::Out, urb.transfer_flags, "TS_URB_CONTROL_TRANSFER_EX"); + urb.encode(dst) + } + } + } + + fn size(&self) -> usize { + use TsUrbOutKind::*; + match self { + CtlTransfer(urb) => urb.size(), + BulkInterruptTransfer(urb) => urb.size(), + IsochTransfer(urb) => urb.size(), + CtlDescReq(urb) => urb.size(), + VendorClassReq(urb) => urb.size(), + CtlTransferEx(urb) => urb.size(), + } + } + + fn name(&self) -> &'static str { + "TS_URB" + } +} + +#[repr(u8)] +#[derive(PartialEq, Clone, Copy)] +pub(crate) enum TransferDirection { + Out = 0x0, + In = 0x1, +} + +/// [\[MS-RDPEUSB\] 2.2.9.2 TS_URB_SELECT_CONFIGURATION][1] packet. +/// +/// This packet represents [`URB_SELECT_CONFIGURATION`][2], and is sent using [`TransferInRequest`] +/// (with `output_buffer_size` set to `0`). +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/196e83a1-9bfd-45fb-97cc-a27c6a0c74ee +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_select_configuration +#[doc(alias = "TS_URB_SELECT_CONFIGURATION")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbSelectConfig { + pub usbd_ifaces: Vec, + pub desc: Option, +} + +impl Decode<'_> for TsUrbSelectConfig { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + let desc = src.read_u8(/* ConfigurationDescriptorIsValid */) != 0; + + ensure_size!(in: src, size: const { 3 * size_of::() }); + read_padding!(src, 3); + + let usbd_ifaces = { + ensure_size!(in: src, size: const { size_of::() }); + let num_usbd_ifaces = src.read_u32(/* NumInterfaces */); + + let mut usbd_ifaces = Vec::new(); + for _ in 0..num_usbd_ifaces { + usbd_ifaces.push(TsUsbdInterfaceInfo::decode(src)?) + } + usbd_ifaces + }; + + let desc = if desc { Some(UsbConfigDesc::decode(src)?) } else { None }; + + Ok(Self { usbd_ifaces, desc }) + } +} + +impl Encode for TsUrbSelectConfig { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + // ConfigurationDescriptorIsValid + dst.write_u8(self.desc.is_some().into()); + + write_padding!(dst, 3); + + // NumInterfaces + dst.write_u32( + self.usbd_ifaces + .len() + .try_into() + .expect("max 255 since bNumInterfaces is 1 byte"), + ); + + // TS_USBD_INTERFACE_INFORMATION + for usbd_iface in &self.usbd_ifaces { + usbd_iface.encode(dst)?; + } + + if let Some(config_desc) = self.desc.as_ref() { + config_desc.encode(dst)?; // USB_CONFIGURATION_DESCRIPTOR + } + + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB_SELECT_CONFIGURATION" + } + + fn size(&self) -> usize { + 1 /* ConfigurationDescriptorIsValid */ + + 3 /* Padding */ + + 4 /* NumInterfaces */ + + self.usbd_ifaces.iter().map(Encode::size).sum::() + + self.desc.as_ref().map(Encode::size).unwrap_or_default() + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.3 TS_URB_SELECT_INTERFACE][1] packet. +/// +/// This packet represents [`URB_SELECT_INTERFACE`][2], and is sent using [`TransferInRequest`] +/// (with `output_buffer_size` set to `0`). +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/36c33bed-8ce1-43b6-9ccd-030884a030c9 +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_select_interface +#[doc(alias = "TS_URB_SELECT_INTERFACE")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbSelectInterface { + pub config_handle: ConfigHandle, + pub usbd_iface: TsUsbdInterfaceInfo, +} + +impl Decode<'_> for TsUrbSelectInterface { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: const { + size_of::(/* ConfigurationHandle */) + }); + + let config_handle = src.read_u32(); + + let usbd_iface = TsUsbdInterfaceInfo::decode(src)?; + + Ok(Self { + config_handle, + usbd_iface, + }) + } +} + +impl Encode for TsUrbSelectInterface { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + dst.write_u32(self.config_handle); + self.usbd_iface.encode(dst) + } + + fn name(&self) -> &'static str { + "TS_URB_SELECT_INTERFACE" + } + + fn size(&self) -> usize { + (const { + size_of::(/* ConfigurationHandle */) + }) + self.usbd_iface.size() + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.4 TS_URB_PIPE_REQUEST][1] packet. +/// +/// This packet represents [`URB_PIPE_REQUEST`][2], and is sent using [`TransferInRequest`] (with +/// `output_buffer_size` set to `0`). +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/dcba564e-de14-4d60-82ac-a0fe7a52b312 +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_pipe_request +#[doc(alias = "TS_URB_PIPE_REQUEST")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbPipeRequest { + pub pipe_handle: PipeHandle, +} + +impl TsUrbPipeRequest { + pub const FIXED_PART_SIZE: usize = size_of::(/* PipeHandle */); +} + +impl Decode<'_> for TsUrbPipeRequest { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let pipe_handle = src.read_u32(); + + Ok(Self { pipe_handle }) + } +} + +impl Encode for TsUrbPipeRequest { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + dst.write_u32(self.pipe_handle); + + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB_PIPE_REQUEST" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.5 TS_URB_GET_CURRENT_FRAME_NUMBER][1] packet. +/// +/// This packet represents [`URB_GET_CURRENT_FRAME_NUMBER`][2], and is sent using +/// [`TransferInRequest`] (with `output_buffer_size` set to `0`). +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/4985b1dc-5bd9-4988-97a6-063969dc26b4 +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_get_current_frame_number +#[doc(alias = "TS_URB_GET_CURRENT_FRAME_NUMBER")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbGetCurrFrameNum; + +impl TsUrbGetCurrFrameNum { + pub const FIXED_PART_SIZE: usize = 0; +} + +impl Decode<'_> for TsUrbGetCurrFrameNum { + fn decode(_: &mut ReadCursor<'_>) -> DecodeResult { + Ok(Self) + } +} + +impl Encode for TsUrbGetCurrFrameNum { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB_GET_CURRENT_FRAME_NUMBER" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.6 TS_URB_CONTROL_TRANSFER][1] packet. +/// +/// This packet represents [`URB_CONTROL_TRANSFER`][2]. Transfer flags MUST contain +/// `USBD_TRANSFER_DIRECTION_IN` to send using [`TransferInRequest`]; MUST not contain +/// `USBD_TRANSFER_DIRECTION_IN` to send using [`TransferOutRequest`]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/859aefe0-0209-4d31-af7c-7a1bf1c7e49a +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_control_transfer +#[doc(alias = "TS_URB_CONTROL_TRANSFER")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbControlTransfer { + pub pipe: PipeHandle, + pub transfer_flags: u32, + pub setup_packet: SetupPacket, +} + +impl TsUrbControlTransfer { + pub const FIXED_PART_SIZE: usize = + size_of::(/* PipeHandle */) + size_of::(/* TransferFlags */) + SetupPacket::FIXED_PART_SIZE; +} + +impl Decode<'_> for TsUrbControlTransfer { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let pipe_handle = src.read_u32(); + let transfer_flags = src.read_u32(); + let setup_packet = SetupPacket::decode(src)?; + + Ok(Self { + pipe: pipe_handle, + transfer_flags, + setup_packet, + }) + } +} + +impl Encode for TsUrbControlTransfer { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + dst.write_u32(self.pipe); + dst.write_u32(self.transfer_flags); + self.setup_packet.encode(dst) + } + + fn name(&self) -> &'static str { + "TS_URB_CONTROL_TRANSFER" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.7 TS_URB_BULK_OR_INTERRUPT_TRANSFER][1] packet. +/// +/// This packet represents [`URB_BULK_OR_INTERRUPT_TRANSFER`][2]. Transfer flags MUST contain +/// `USBD_TRANSFER_DIRECTION_IN` to send using [`TransferInRequest`]; MUST not contain +/// `USBD_TRANSFER_DIRECTION_IN` to send using [`TransferOutRequest`]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/8c06982e-3a7b-4a27-a554-5f7f9d3f210a +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_bulk_or_interrupt_transfer +#[doc(alias = "TS_URB_BULK_OR_INTERRUPT_TRANSFER")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbBulkOrInterruptTransfer { + pub pipe_handle: PipeHandle, + pub transfer_flags: u32, +} + +impl TsUrbBulkOrInterruptTransfer { + pub const FIXED_PART_SIZE: usize = size_of::(/* PipeHandle */) + size_of::(/* TransferFlags */); +} + +impl Decode<'_> for TsUrbBulkOrInterruptTransfer { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let pipe_handle = src.read_u32(); + let transfer_flags = src.read_u32(); + + Ok(Self { + pipe_handle, + transfer_flags, + }) + } +} + +impl Encode for TsUrbBulkOrInterruptTransfer { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u32(self.pipe_handle); + dst.write_u32(self.transfer_flags); + + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB_BULK_OR_INTERRUPT_TRANSFER" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.8 TS_URB_ISOCH_TRANSFER][1] packet. +/// +/// This packet represents [`URB_ISOCH_TRANSFER`][2]. Transfer flags MUST contain +/// `USBD_TRANSFER_DIRECTION_IN` to send using [`TransferInRequest`]; MUST not contain +/// `USBD_TRANSFER_DIRECTION_IN` to send using [`TransferOutRequest`]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/6ded5444-daaf-4a59-96bd-c1a3c6468a82 +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_isoch_transfer +#[doc(alias = "TS_URB_ISOCH_TRANSFER")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbIsochTransfer { + pub pipe_handle: PipeHandle, + pub transfer_flags: u32, + pub start_frame: FrameNumber, + pub error_count: u32, + pub iso_packet: Vec, +} + +impl Decode<'_> for TsUrbIsochTransfer { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 20); + + let pipe_handle = src.read_u32(); + let transfer_flags = src.read_u32(); + let start_frame = src.read_u32(); + let number_of_packets = src.read_u32(); + let error_count = src.read_u32(); + + #[expect(clippy::map_with_unused_argument_over_ranges)] + let iso_packet = (0..number_of_packets) + .map(|_| UsbdIsoPacketDesc::decode(src)) + .collect::, _>>()?; + + Ok(Self { + pipe_handle, + transfer_flags, + start_frame, + error_count, + iso_packet, + }) + } +} + +impl Encode for TsUrbIsochTransfer { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u32(self.pipe_handle); + dst.write_u32(self.transfer_flags); + dst.write_u32(self.start_frame); + dst.write_u32(self.iso_packet.len().try_into().map_err(|_| { + invalid_field_err!( + "TS_URB_ISOCH_TRANSFER::IsoPacket", + "too many packets: count exceeded field NumberOfPackets (4 bytes)" + ) + })?); + dst.write_u32(self.error_count); + self.iso_packet.iter().try_for_each(|packet| packet.encode(dst)) + } + + fn name(&self) -> &'static str { + "TS_URB_ISOCH_TRANSFER" + } + + fn size(&self) -> usize { + size_of::() + + size_of::(/* TransferFlags */) + + size_of::(/* StartFrame */) + + size_of::(/* NumberOfPackets */) + + size_of::(/* ErrorCount */) + + self.iso_packet.len() * UsbdIsoPacketDesc::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.9 TS_URB_CONTROL_DESCRIPTOR_REQUEST][1] packet. +/// +/// This packet represents [`URB_CONTROL_DESCRIPTOR_REQUEST`][2], and is sent using +/// [`TransferInRequest`] if URB Function in header is one of +/// [`UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE`], [`UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT`] or +/// [`UrbFunction::URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE`]; otherwise sent using [`TransferOutRequest`] if +/// URB Function in header is one of [`UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE`], +/// [`UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT`] or [`UrbFunction::URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE`]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/c6096d89-01e6-40e1-b1c7-9327487c5fff +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_control_descriptor_request +#[doc(alias = "TS_URB_CONTROL_DESCRIPTOR_REQUEST")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbControlDescRequest { + pub index: u8, + pub desc_type: u8, + pub lang_id: u16, +} + +impl TsUrbControlDescRequest { + pub const FIXED_PART_SIZE: usize = + size_of::(/* Index */) + size_of::(/* DescriptorType */) + size_of::(/* LanguageId */); +} + +impl Decode<'_> for TsUrbControlDescRequest { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let index = src.read_u8(); + let desc_type = src.read_u8(); + let lang_id = src.read_u16(); + + Ok(Self { + index, + desc_type, + lang_id, + }) + } +} + +impl Encode for TsUrbControlDescRequest { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u8(self.index); + dst.write_u8(self.desc_type); + dst.write_u16(self.lang_id); + + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB_CONTROL_DESCRIPTOR_REQUEST" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.10 TS_URB_CONTROL_FEATURE_REQUEST][1] packet. +/// +/// This packet represents [`URB_CONTROL_FEATURE_REQUEST`][2], and is sent using [`TransferInRequest`] +/// (with `output_buffer_size` set to `0`). +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/2ff4f3f0-1205-400d-8e01-88e931855b7a +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_control_feature_request +#[doc(alias = "TS_URB_CONTROL_FEATURE_REQUEST")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbControlFeatRequest { + pub feat_selector: u16, + pub index: u16, +} + +impl TsUrbControlFeatRequest { + pub const FIXED_PART_SIZE: usize = size_of::(/* FeatureSelector */) + size_of::(/* Index */); +} + +impl Decode<'_> for TsUrbControlFeatRequest { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let feat_selector = src.read_u16(); + let index = src.read_u16(); + + Ok(Self { feat_selector, index }) + } +} + +impl Encode for TsUrbControlFeatRequest { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u16(self.feat_selector); + dst.write_u16(self.index); + + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB_CONTROL_FEATURE_REQUEST" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.11 TS_URB_CONTROL_GET_STATUS_REQUEST][1] packet. +/// +/// This packet represents [`URB_CONTROL_GET_STATUS_REQUEST`][2], and is sent using +/// [`TransferInRequest`] (with `output_buffer_size` set to `2`). +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/f2a82d78-14f9-426e-826c-13844f1c93b6 +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_control_get_status_request +#[doc(alias = "TS_URB_CONTROL_GET_STATUS_REQUEST")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbControlGetStatusRequest { + pub index: u16, +} + +impl TsUrbControlGetStatusRequest { + pub const FIXED_PART_SIZE: usize = size_of::(/* Index */) + size_of::(/* Padding */); +} + +impl Decode<'_> for TsUrbControlGetStatusRequest { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let index = src.read_u16(); + read_padding!(src, 2); + + Ok(Self { index }) + } +} + +impl Encode for TsUrbControlGetStatusRequest { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u16(self.index); + write_padding!(dst, 2); + + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB_CONTROL_GET_STATUS_REQUEST" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.12 TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST][1] packet. +/// +/// This packet represents [`URB_CONTROL_VENDOR_OR_CLASS_REQUEST`][2]. Transfer flags MUST contain +/// `USBD_TRANSFER_DIRECTION_IN` to send using [`TransferInRequest`]; MUST not contain +/// `USBD_TRANSFER_DIRECTION_IN` to send using [`TransferOutRequest`]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/b97c5a08-5c42-4c13-bc32-e3e29cb0d3d3 +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_control_vendor_or_class_request +#[doc(alias = "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbControlVendorClassRequest { + pub transfer_flags: u32, + pub request: u8, + pub value: u16, + pub index: u16, +} + +impl TsUrbControlVendorClassRequest { + pub const FIXED_PART_SIZE: usize = size_of::() + + size_of::(/* RequestTypeReservedBits */) + + size_of::(/* Request */) + + size_of::(/* Value */) + + size_of::(/* Index */) + + size_of::(/* Padding */); +} + +impl Decode<'_> for TsUrbControlVendorClassRequest { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let transfer_flags: u32 = src.read_u32(); + src.advance(1); // RequestTypeReservedBits + let request = src.read_u8(); + let value: u16 = src.read_u16(); + let index: u16 = src.read_u16(); + read_padding!(src, 2); + + Ok(Self { + transfer_flags, + request, + value, + index, + }) + } +} + +impl Encode for TsUrbControlVendorClassRequest { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u32(self.transfer_flags); + write_padding!(dst, 1); // RequestTypeReservedBits + dst.write_u8(self.request); + dst.write_u16(self.value); + dst.write_u16(self.index); + write_padding!(dst, 2); + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB_CONTROL_VENDOR_OR_CLASS_REQUEST" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.13 TS_URB_CONTROL_GET_CONFIGURATION_REQUEST][1] packet. +/// +/// This packet represents [`URB_CONTROL_GET_CONFIGURATION_REQUEST`][2], and is sent using +/// [`TransferInRequest`] (with `output_buffer_size` set to `1`). +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/974dabf5-82c2-4f80-a460-9a5f0ac4ede5 +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_control_get_configuration_request +#[doc(alias = "TS_URB_CONTROL_GET_CONFIGURATION_REQUEST")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbControlGetConfigRequest; + +impl TsUrbControlGetConfigRequest { + pub const FIXED_PART_SIZE: usize = 0; +} + +impl Decode<'_> for TsUrbControlGetConfigRequest { + fn decode(_: &mut ReadCursor<'_>) -> DecodeResult { + Ok(Self) + } +} + +impl Encode for TsUrbControlGetConfigRequest { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB_CONTROL_GET_CONFIGURATION_REQUEST" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.14 TS_URB_CONTROL_GET_INTERFACE_REQUEST][1] packet. +/// +/// This packet represents [`URB_CONTROL_GET_INTERFACE_REQUEST`][2], and is sent using +/// [`TransferInRequest`] (with `output_buffer_size` set to `1`). +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/71d890f0-ec15-4b03-83e2-09fa096bc4e2 +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_control_get_interface_request +#[doc(alias = "TS_URB_CONTROL_GET_INTERFACE_REQUEST")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbControlGetInterfaceRequest { + pub interface: u16, +} + +impl TsUrbControlGetInterfaceRequest { + pub const FIXED_PART_SIZE: usize = size_of::(/* Interface */) + size_of::(/* Padding */); +} + +impl Decode<'_> for TsUrbControlGetInterfaceRequest { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + let interface = src.read_u16(); + read_padding!(src, 2); + + Ok(Self { interface }) + } +} + +impl Encode for TsUrbControlGetInterfaceRequest { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + dst.write_u16(self.interface); + write_padding!(dst, 2); + + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB_CONTROL_GET_INTERFACE_REQUEST" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.15 TS_URB_OS_FEATURE_DESCRIPTOR_REQUEST][1] packet. +/// +/// This packet represents [`URB_OS_FEATURE_DESCRIPTOR_REQUEST`][2], and is sent using +/// [`TransferInRequest`]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/9f6c44ac-5f8e-4c03-95da-fac88d33d91d +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_os_feature_descriptor_request +#[doc(alias = "TS_URB_OS_FEATURE_DESCRIPTOR_REQUEST")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbOsFeatDescRequest { + pub recipient: u8, + pub interface_number: u8, + pub ms_feat_desc_index: u16, +} + +impl TsUrbOsFeatDescRequest { + pub const FIXED_PART_SIZE: usize = size_of::(/* Recipient + Padding1 */) + + size_of::(/* InterfaceNumber */) + + size_of::(/* MS_PageIndex */) + + size_of::(/* MS_FeatureDescriptorIndex */) + + (3 * size_of::()/* Padding2 */); +} + +impl Decode<'_> for TsUrbOsFeatDescRequest { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let recipient = src.read_u8() & 0x1F; + let interface_number = src.read_u8(); + // WDK requires MS_PageIndex to be 0; current Windows support is limited to 4 KiB. + if src.read_u8(/* MS_PageIndex */) != 0 { + return Err(invalid_field_err!( + "TRANSFER_IN_REQUEST::TsUrb: TS_URB_OS_FEATURE_DESCRIPTOR_REQUEST::MS_PageIndex", + "must be: 0x0" + )); + } + let ms_feat_desc_index = src.read_u16(); + read_padding!(src, 3); + + Ok(Self { + recipient, + interface_number, + ms_feat_desc_index, + }) + } +} + +impl Encode for TsUrbOsFeatDescRequest { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + dst.write_u8(self.recipient & 0x1F); + dst.write_u8(self.interface_number); + dst.write_u8(0x0); // MS_PageIndex + dst.write_u16(self.ms_feat_desc_index); + write_padding!(dst, 3); + + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB_OS_FEATURE_DESCRIPTOR_REQUEST" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.16 TS_URB_CONTROL_TRANSFER_EX][1] packet. +/// +/// This packet represents [`URB_CONTROL_TRANSFER_EX`][2]. Transfer flags MUST contain +/// `USBD_TRANSFER_DIRECTION_IN` to send using [`TransferInRequest`]; MUST not contain +/// `USBD_TRANSFER_DIRECTION_IN` to send using [`TransferOutRequest`]. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/94be9864-e0f4-4485-b5a0-8df1984dcab8 +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_control_transfer_ex +#[doc(alias = "TS_URB_CONTROL_TRANSFER_EX")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUrbControlTransferEx { + pub pipe: PipeHandle, + pub transfer_flags: u32, + pub timeout: u32, + pub setup_packet: SetupPacket, +} + +impl TsUrbControlTransferEx { + pub const FIXED_PART_SIZE: usize = size_of::() + + size_of::(/* TransferFlags */) + + size_of::(/* Timeout */) + + SetupPacket::FIXED_PART_SIZE; +} + +impl Decode<'_> for TsUrbControlTransferEx { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let pipe_handle = src.read_u32(); + let transfer_flags = src.read_u32(); + let timeout = src.read_u32(); + let setup_packet = SetupPacket::decode(src)?; + + Ok(Self { + pipe: pipe_handle, + transfer_flags, + timeout, + setup_packet, + }) + } +} + +impl Encode for TsUrbControlTransferEx { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + dst.write_u32(self.pipe); + dst.write_u32(self.transfer_flags); + dst.write_u32(self.timeout); + self.setup_packet.encode(dst) + } + + fn name(&self) -> &'static str { + "TS_URB_CONTROL_TRANSFER_EX" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} diff --git a/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/utils.rs b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/utils.rs new file mode 100644 index 0000000000..d1e9202e09 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/pdu/usb_dev/ts_urb/utils.rs @@ -0,0 +1,738 @@ +//! Contains valid URB Functions, the common header [`TsUrbHeader`] for all [`TsUrbIn`] and +//! [`TsUrbOut`] structures, and utility data types. + +use alloc::vec::Vec; + +use ironrdp_core::{ + Decode, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, ensure_size, + invalid_field_err, other_err, read_padding, write_padding, +}; + +use crate::pdu::utils::RequestIdTransferInOut; +#[cfg(doc)] +use crate::pdu::{ + header::SharedMsgHeader, + usb_dev::ts_urb::{ + TsUrbBulkOrInterruptTransfer, TsUrbControlDescRequest, TsUrbControlFeatRequest, TsUrbControlGetConfigRequest, + TsUrbControlGetInterfaceRequest, TsUrbControlGetStatusRequest, TsUrbControlTransfer, TsUrbControlTransferEx, + TsUrbControlVendorClassRequest, TsUrbGetCurrFrameNum, TsUrbIn, TsUrbIsochTransfer, TsUrbOsFeatDescRequest, + TsUrbOutKind, TsUrbPipeRequest, TsUrbSelectConfig, TsUrbSelectInterface, + }, +}; + +/// Numeric code that indicates the requested operation for a [USB Request Block][1]. +/// +/// URB Function codes are used with [`TsUrbHeader`]s. This code indicates to an RDP client which +/// `TS_URB` structure the header is used with. See [`URB_HEADER`][2] for valid URB Function codes +/// and what they indicate. +/// +/// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/usbcon/communicating-with-a-usb-device +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header +// +// NOTE: There are a few variants for Memory Descriptor Lists (MDL). Should a client just behave +// like it did not receive any of the MDL variants? Cause the client receives the data buffer over +// the network, so MDL's don't really make a point. [EDIT] Same behavior for MDL and non-MDL +// variants. +#[repr(transparent)] +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub struct UrbFunction(u16); + +impl UrbFunction { + /// Represents [`URB_FUNCTION_SELECT_CONFIGURATION`][1]. Used with [`TsUrbSelectConfig`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_select_configuration + pub const URB_FUNCTION_SELECT_CONFIGURATION: Self = Self(0); + + /// Represents [`URB_FUNCTION_SELECT_INTERFACE`][1]. Used with [`TsUrbSelectInterface`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_select_interface + pub const URB_FUNCTION_SELECT_INTERFACE: Self = Self(1); + + /// Represents [`URB_FUNCTION_ABORT_PIPE`][1]. Used with [`TsUrbPipeRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_abort_pipe + pub const URB_FUNCTION_ABORT_PIPE: Self = Self(2); + + /// Represents [`URB_FUNCTION_SYNC_RESET_PIPE_AND_CLEAR_STALL`][1]. Used with [`TsUrbPipeRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_sync_reset_pipe_and_clear_stall + pub const URB_FUNCTION_SYNC_RESET_PIPE_AND_CLEAR_STALL: Self = Self(30); + + /// Represents [`URB_FUNCTION_SYNC_RESET_PIPE`][1]. Used with [`TsUrbPipeRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_sync_reset_pipe + pub const URB_FUNCTION_SYNC_RESET_PIPE: Self = Self(48); + + /// Represents [`URB_FUNCTION_SYNC_CLEAR_STALL`][1]. Used with [`TsUrbPipeRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_sync_clear_stall + pub const URB_FUNCTION_SYNC_CLEAR_STALL: Self = Self(49); + + /// Represents [`URB_FUNCTION_CLOSE_STATIC_STREAMS`][1]. Used with [`TsUrbPipeRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_close_static_streams + pub const URB_FUNCTION_CLOSE_STATIC_STREAMS: Self = Self(54); + + /// Represents [`URB_FUNCTION_GET_CURRENT_FRAME_NUMBER`][1]. Used with [`TsUrbGetCurrFrameNum`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_current_frame_number + pub const URB_FUNCTION_GET_CURRENT_FRAME_NUMBER: Self = Self(7); + + /// Represents [`URB_FUNCTION_CONTROL_TRANSFER`][1]. Used with [`TsUrbControlTransfer`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_control_transfer + pub const URB_FUNCTION_CONTROL_TRANSFER: Self = Self(8); + + /// Represents [`URB_FUNCTION_CONTROL_TRANSFER_EX`][1]. Used with [`TsUrbControlTransferEx`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_control_transfer_ex + pub const URB_FUNCTION_CONTROL_TRANSFER_EX: Self = Self(50); + + /// Represents [`URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER`][1]. Used with + /// [`TsUrbBulkOrInterruptTransfer`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_bulk_or_interrupt_transfer + pub const URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER: Self = Self(9); + + /// Represents [`URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL`][1]. Used with + /// [`TsUrbBulkOrInterruptTransfer`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_bulk_or_interrupt_transfer_using_chained_mdl + pub const URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER_USING_CHAINED_MDL: Self = Self(55); + + /// Represents [`URB_FUNCTION_ISOCH_TRANSFER`][1]. Used with [`TsUrbIsochTransfer`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_isoch_transfer + pub const URB_FUNCTION_ISOCH_TRANSFER: Self = Self(10); + + /// Represents [`URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL`][1]. Used with + /// [`TsUrbIsochTransfer`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_isoch_transfer_using_chained_mdl + pub const URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL: Self = Self(56); + + /// Represents [`URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE`][1]. Used with + /// [`TsUrbControlDescRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_descriptor_from_device + pub const URB_FUNCTION_GET_DESCRIPTOR_FROM_DEVICE: Self = Self(11); + + /// Represents [`URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT`][1]. Used with + /// [`TsUrbControlDescRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_descriptor_from_endpoint + pub const URB_FUNCTION_GET_DESCRIPTOR_FROM_ENDPOINT: Self = Self(36); + + /// Represents [`URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE`][1]. Used with + /// [`TsUrbControlDescRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_descriptor_from_interface + pub const URB_FUNCTION_GET_DESCRIPTOR_FROM_INTERFACE: Self = Self(40); + + /// Represents [`URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE`][1]. Used with + /// [`TsUrbControlDescRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_set_descriptor_to_device + pub const URB_FUNCTION_SET_DESCRIPTOR_TO_DEVICE: Self = Self(12); + + /// Represents [`URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT`][1]. Used with + /// [`TsUrbControlDescRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_set_descriptor_to_endpoint + pub const URB_FUNCTION_SET_DESCRIPTOR_TO_ENDPOINT: Self = Self(37); + + /// Represents [`URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE`][1]. Used with + /// [`TsUrbControlDescRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_set_descriptor_to_interface + pub const URB_FUNCTION_SET_DESCRIPTOR_TO_INTERFACE: Self = Self(41); + + /// Represents [`URB_FUNCTION_SET_FEATURE_TO_DEVICE`][1]. Used with [`TsUrbControlFeatRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_set_feature_to_device + pub const URB_FUNCTION_SET_FEATURE_TO_DEVICE: Self = Self(13); + + /// Represents [`URB_FUNCTION_SET_FEATURE_TO_INTERFACE`][1]. Used with + /// [`TsUrbControlFeatRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_set_feature_to_interface + pub const URB_FUNCTION_SET_FEATURE_TO_INTERFACE: Self = Self(14); + + /// Represents [`URB_FUNCTION_SET_FEATURE_TO_ENDPOINT`][1]. Used with + /// [`TsUrbControlFeatRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_set_feature_to_endpoint + pub const URB_FUNCTION_SET_FEATURE_TO_ENDPOINT: Self = Self(15); + + /// Represents [`URB_FUNCTION_SET_FEATURE_TO_OTHER`][1]. Used with [`TsUrbControlFeatRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_set_feature_to_other + pub const URB_FUNCTION_SET_FEATURE_TO_OTHER: Self = Self(35); + + /// Represents [`URB_FUNCTION_CLEAR_FEATURE_TO_DEVICE`][1]. Used with + /// [`TsUrbControlFeatRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_clear_feature_to_device + pub const URB_FUNCTION_CLEAR_FEATURE_TO_DEVICE: Self = Self(16); + + /// Represents [`URB_FUNCTION_CLEAR_FEATURE_TO_INTERFACE`][1]. Used with + /// [`TsUrbControlFeatRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_clear_feature_to_interface + pub const URB_FUNCTION_CLEAR_FEATURE_TO_INTERFACE: Self = Self(17); + + /// Represents [`URB_FUNCTION_CLEAR_FEATURE_TO_ENDPOINT`][1]. Used with + /// [`TsUrbControlFeatRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_clear_feature_to_endpoint + pub const URB_FUNCTION_CLEAR_FEATURE_TO_ENDPOINT: Self = Self(18); + + /// Represents [`URB_FUNCTION_CLEAR_FEATURE_TO_OTHER`][1]. Used with + /// [`TsUrbControlFeatRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_clear_feature_to_other + pub const URB_FUNCTION_CLEAR_FEATURE_TO_OTHER: Self = Self(34); + + /// Represents [`URB_FUNCTION_GET_STATUS_FROM_DEVICE`][1]. Used with + /// [`TsUrbControlGetStatusRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_status_from_device + pub const URB_FUNCTION_GET_STATUS_FROM_DEVICE: Self = Self(19); + + /// Represents [`URB_FUNCTION_GET_STATUS_FROM_INTERFACE`][1]. Used with + /// [`TsUrbControlGetStatusRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_status_from_interface + pub const URB_FUNCTION_GET_STATUS_FROM_INTERFACE: Self = Self(20); + + /// Represents [`URB_FUNCTION_GET_STATUS_FROM_ENDPOINT`][1]. Used with + /// [`TsUrbControlGetStatusRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_status_from_endpoint + pub const URB_FUNCTION_GET_STATUS_FROM_ENDPOINT: Self = Self(21); + + /// Represents [`URB_FUNCTION_GET_STATUS_FROM_OTHER`][1]. Used with + /// [`TsUrbControlGetStatusRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_status_from_other + pub const URB_FUNCTION_GET_STATUS_FROM_OTHER: Self = Self(33); + + /// Represents [`URB_FUNCTION_VENDOR_DEVICE`][1]. Used with [`TsUrbControlVendorClassRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_vendor_device + pub const URB_FUNCTION_VENDOR_DEVICE: Self = Self(23); + + /// Represents [`URB_FUNCTION_VENDOR_INTERFACE`][1]. Used with + /// [`TsUrbControlVendorClassRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_vendor_interface + pub const URB_FUNCTION_VENDOR_INTERFACE: Self = Self(24); + + /// Represents [`URB_FUNCTION_VENDOR_ENDPOINT`][1]. Used with + /// [`TsUrbControlVendorClassRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_vendor_endpoint + pub const URB_FUNCTION_VENDOR_ENDPOINT: Self = Self(25); + + /// Represents [`URB_FUNCTION_VENDOR_OTHER`][1]. Used with [`TsUrbControlVendorClassRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_vendor_other + pub const URB_FUNCTION_VENDOR_OTHER: Self = Self(32); + + /// Represents [`URB_FUNCTION_CLASS_DEVICE`][1]. Used with [`TsUrbControlVendorClassRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_class_device + pub const URB_FUNCTION_CLASS_DEVICE: Self = Self(26); + + /// Represents [`URB_FUNCTION_CLASS_INTERFACE`][1]. Used with + /// [`TsUrbControlVendorClassRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_class_interface + pub const URB_FUNCTION_CLASS_INTERFACE: Self = Self(27); + + /// Represents [`URB_FUNCTION_CLASS_ENDPOINT`][1]. Used with [`TsUrbControlVendorClassRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_class_endpoint + pub const URB_FUNCTION_CLASS_ENDPOINT: Self = Self(28); + + /// Represents [`URB_FUNCTION_CLASS_OTHER`][1]. Used with [`TsUrbControlVendorClassRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_class_other + pub const URB_FUNCTION_CLASS_OTHER: Self = Self(31); + + /// Represents [`URB_FUNCTION_GET_CONFIGURATION`][1]. Used with + /// [`TsUrbControlGetConfigRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_configuration + pub const URB_FUNCTION_GET_CONFIGURATION: Self = Self(38); + + /// Represents [`URB_FUNCTION_GET_INTERFACE`][1]. Used with [`TsUrbControlGetInterfaceRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_interface + pub const URB_FUNCTION_GET_INTERFACE: Self = Self(39); + + /// Represents [`URB_FUNCTION_GET_MS_FEATURE_DESCRIPTOR`][1]. Used with + /// [`TsUrbOsFeatDescRequest`]. + /// + /// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_urb_header#urb_function_get_ms_feature_descriptor + pub const URB_FUNCTION_GET_MS_FEATURE_DESCRIPTOR: Self = Self(42); +} + +impl From for UrbFunction { + fn from(value: u16) -> Self { + Self(value) + } +} + +impl From for u16 { + fn from(value: UrbFunction) -> Self { + value.0 + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.1.1 TS_URB_HEADER][1]. +/// +/// Common header for all of the [`TsUrbIn`] and [`TsUrbOut`] variants. Analogous to how +/// [`SharedMsgHeader`] is for all the "top-level" packets defined in the spec. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/578da9ca-3116-4608-9737-1bf3df4de3d1 +#[doc(alias = "TS_URB_HEADER")] +#[derive(Debug, PartialEq, Clone, Copy)] +pub struct TsUrbHeader { + /// The size in bytes of the TS_URB structure. + pub ts_urb_size: u16, + /// Indicates what function to perform (see [`UrbFunction`]). + pub func: UrbFunction, + // pub(crate) urb_function: u16, + /// An ID that uniquely identifies a [`TRANSFER_IN_REQUEST`][1] or [`TRANSFER_OUT_REQUEST`][2] + /// message. + pub req_id: RequestIdTransferInOut, + /// Determines if the client is to send a **Request Completion** message for a + /// [`TRANSFER_IN_REQUEST`] or [`TRANSFER_OUT_REQUEST`] message. + /// + /// * If the header is for a [`TRANSFER_IN_REQUEST`] message, this field **MUST** be `false`; + /// and the client is to send a message in response (either [`URB_COMPLETION`][3] or + /// [`URB_COMPLETION_NO_DATA`][4]). + /// + /// * If the header is for a [`TRANSFER_OUT_REQUEST`] message and this field is `false`; + /// the client is to send a ([`URB_COMPLETION_NO_DATA`]) message in response. + /// + /// * If the header is for a [`TRANSFER_OUT_REQUEST`] message and this field is `true`; + /// the client is *not* to send a ([`URB_COMPLETION_NO_DATA`]) message in response. This field + /// *can* be `true` if: + /// + /// 1. `urb_function` is set to [`UrbFunc::IsochTransfer`] (so the header is being used for + /// a [`TS_URB_ISOCH_TRANSFER`][5] structure), and + /// + /// 2. the [`USB_DEVICE_CAPABILITIES.NoAckIsochWriteJitterBufferSizeInMs`][6] field is + /// non-zero, which represents the amount of outstanding isochronous data the client + /// expects from the server (can be checked with + /// [`NoAckIsochWriteJitterBufSizeInMs::outstanding_isoch_data`][7]). + /// + /// + /// [6]: crate::pdu::dev_sink::UsbDeviceCaps::no_ack_isoch_write_jitter_buf_size + /// [7]: crate::pdu::dev_sink::NoAckIsochWriteJitterBufSizeInMs::outstanding_isoch_data + pub no_ack: bool, +} + +impl TsUrbHeader { + pub const FIXED_PART_SIZE: usize = + 2 /* Size */ + 2 /* URB Function */ + 4 /* RequestId, NoAck */; + + pub(super) fn encode_with_size(&self, dst: &mut WriteCursor<'_>, ts_urb_size: usize) -> EncodeResult<()> { + let ts_urb_size = ts_urb_size + .try_into() + .map_err(|_| invalid_field_err!("TS_URB_HEADER::Size", "too large: exceeded 2-byte size field"))?; + + Self { + ts_urb_size, + func: self.func, + req_id: self.req_id, + no_ack: self.no_ack, + } + .encode(dst) + } +} + +impl Encode for TsUrbHeader { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u16(self.ts_urb_size); + dst.write_u16(self.func.into()); + + let no_ack = u32::from(self.no_ack) << 31; + let last32 = u32::from(self.req_id) | no_ack; + dst.write_u32(last32); + + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_URB_HEADER" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl Decode<'_> for TsUrbHeader { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + let size = src.read_u16(); + if usize::from(size) < Self::FIXED_PART_SIZE { + return Err(invalid_field_err!("TS_URB_HEADER::Size", "is smaller than 8")); + } + + let func = UrbFunction::from(src.read_u16()); + let last32 = src.read_u32(); + let req_id = RequestIdTransferInOut::try_from(last32 & 0x7F_FF_FF_FF).expect("value clamped"); + let no_ack = (last32 >> 31) != 0; + if no_ack + && !matches!( + func, + UrbFunction::URB_FUNCTION_ISOCH_TRANSFER | UrbFunction::URB_FUNCTION_ISOCH_TRANSFER_USING_CHAINED_MDL + ) + { + return Err(invalid_field_err!( + "TS_URB_HEADER::NoAck", + "this bit can only be set when URB Function is an isochronous transfer" + )); + } + + Ok(Self { + ts_urb_size: size, + func, + req_id, + no_ack, + }) + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.1.3 TS_USBD_PIPE_INFORMATION][1]. +/// +/// Based on the [`USBD_PIPE_INFORMATION`][2] structure. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/cc12d23f-9712-4bf1-9235-76c3bd70115b +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_usbd_pipe_information +#[doc(alias = "TS_USBD_PIPE_INFORMATION")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUsbdPipeInfo { + pub max_packet_size: u16, + pub max_transfer_size: u32, + pub pipe_flags: u32, +} + +impl TsUsbdPipeInfo { + pub const FIXED_PART_SIZE: usize = size_of::(/* MaximumPacketSize */) + + size_of::(/* Padding */) + + size_of::(/* MaximumTransferSize */) + + size_of::(/* PipeFlags */); +} + +impl Encode for TsUsbdPipeInfo { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u16(self.max_packet_size); + write_padding!(dst, 2); + dst.write_u32(self.max_transfer_size); + dst.write_u32(self.pipe_flags); + + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_USBD_PIPE_INFORMATION" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl Decode<'_> for TsUsbdPipeInfo { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let max_packet_size = src.read_u16(); + read_padding(src, 2); + let max_transfer_size = src.read_u32(); + let pipe_flags = src.read_u32(); + + Ok(Self { + max_packet_size, + max_transfer_size, + pipe_flags, + }) + } +} + +/// [\[MS-RDPEUSB\] 2.2.9.1.2 TS_USBD_INTERFACE_INFORMATION][1]. +/// +/// Based on the [`USBD_INTERFACE_INFORMATION`][2] structure. +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/e8377327-1d22-48d2-b0f1-006f08cddcab +/// [2]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_usbd_interface_information +#[doc(alias = "TS_USBD_INTERFACE_INFORMATION")] +#[derive(Debug, PartialEq, Clone)] +pub struct TsUsbdInterfaceInfo { + pub interface_number: u8, + pub alternate_setting: u8, + /// **MUST NOT** have more than 30 pipe information structures. + pub ts_usbd_pipe_info: Vec, +} + +impl TsUsbdInterfaceInfo { + pub const FIXED_SIZED_FIELDS_SIZE: usize = size_of::(/* Length */) + + size_of::(/* NumberOfPipesExpected */) + + size_of::(/* InterfaceNumber */) + + size_of::(/* AlternateSetting */) + + size_of::(/* Padding */) + + size_of::(/* NumberOfPipes */); + + /// # Panics + /// + /// If *(number-of-pipes * 12) + 12* is greater than `u16::MAX`. + #[inline] + pub fn length(&self) -> u16 { + (Self::FIXED_SIZED_FIELDS_SIZE + self.ts_usbd_pipe_info.len() * TsUsbdPipeInfo::FIXED_PART_SIZE) + .try_into() + .expect("Max: 12 + 30 * 12 = 372") + } +} + +impl Encode for TsUsbdInterfaceInfo { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + dst.write_u16(self.length()); + + // // NOTE: Do *WE* really need to enforce this stuff? + // if self.ts_usbd_pipe_info.len() > MAX_NON_DEFAULT_EP_COUNT { + // return Err(invalid_field_err!( + // "TS_USBD_INTERFACE_INFORMATION::TS_USBD_PIPE_INFORMATION[..]", + // "has more than 30 TS_USBD_PIPE_INFORMATION structures" + // )); + // } + dst.write_u16( + self.ts_usbd_pipe_info + .len() + .try_into() + .map_err(|e| other_err!(source: e))?, + ); + dst.write_u8(self.interface_number); + dst.write_u8(self.alternate_setting); + write_padding!(dst, 2); + dst.write_u32( + self.ts_usbd_pipe_info + .len() + .try_into() + .map_err(|e| other_err!(source: e))?, + ); + self.ts_usbd_pipe_info.iter().try_for_each(|pipe| pipe.encode(dst))?; + + Ok(()) + } + + fn name(&self) -> &'static str { + "TS_USBD_INTERFACE_INFORMATION" + } + + fn size(&self) -> usize { + self.length().into() + } +} + +impl Decode<'_> for TsUsbdInterfaceInfo { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: Self::FIXED_SIZED_FIELDS_SIZE); + + let length @ 12.. = src.read_u16() else { + return Err(invalid_field_err!( + "TS_USBD_INTERFACE_INFORMATION::Length", + "is less than min reqd value of 12" + )); + }; + + let remaining_length = usize::from(length) - 2 /* Length */; + ensure_size!(in: src, size: remaining_length); + let mut src = ReadCursor::new(src.read_slice(remaining_length)); + + let number_of_pipes_expected = src.read_u16(); + let interface_number = src.read_u8(); + let alternate_setting = src.read_u8(); + read_padding!(&mut src, 2); + let number_of_pipes = src.read_u32(); + + if number_of_pipes != number_of_pipes_expected.into() { + return Err(invalid_field_err!( + "TS_USBD_INTERFACE_INFORMATION::NumberOfPipesExpected", + "is not equal to TS_USBD_INTERFACE_INFORMATION::NumberOfPipes" + )); + } + + { + let length_suggested_size = length.checked_sub(Self::FIXED_SIZED_FIELDS_SIZE.try_into().expect("is 12")); + let Some(length_suggested_size) = length_suggested_size else { + return Err(invalid_field_err!( + "TS_USBD_INTERFACE_INFORMATION::Length", + "is too small" + )); + }; + + if usize::from(length_suggested_size) / TsUsbdPipeInfo::FIXED_PART_SIZE + != number_of_pipes.try_into().map_err(|e| other_err!(source: e))? + { + return Err(invalid_field_err!( + "TS_USBD_INTERFACE_INFORMATION::NumberOfPipes", + "does not reflect number of pipes suggested by TS_USBD_INTERFACE_INFORMATION::Length" + )); + } + } + + #[expect(clippy::map_with_unused_argument_over_ranges)] + let ts_usbd_pipe_info: Vec = (0..number_of_pipes) + .map(|_| TsUsbdPipeInfo::decode(&mut src)) + .collect::, _>>()?; + + Ok(Self { + interface_number, + alternate_setting, + ts_usbd_pipe_info, + }) + } +} + +/// USB2.0 spec: 9.6.3 Configuration +#[doc(alias = "USB_CONFIGURATION_DESCRIPTOR")] +#[derive(Debug, PartialEq, Clone)] +pub struct UsbConfigDesc { + pub length: u8, + pub descriptor_type: u8, + pub total_length: u16, + pub num_interfaces: u8, + pub configuration_value: u8, + pub configuration: u8, + pub attributes: u8, + pub max_power: u8, +} + +impl UsbConfigDesc { + pub const FIXED_PART_SIZE: usize = size_of::(/* bLength */) + + size_of::(/* bDescriptorType */) + + size_of::(/* wTotalLength */) + + size_of::(/* bNumInterfaces */) + + size_of::(/* bConfigurationValue */) + + size_of::(/* iConfiguration */) + + size_of::(/* bmAttributes */) + + size_of::(/* MaxPower */); +} + +impl Encode for UsbConfigDesc { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u8(self.length); + dst.write_u8(self.descriptor_type); + dst.write_u16(self.total_length); + dst.write_u8(self.num_interfaces); + dst.write_u8(self.configuration_value); + dst.write_u8(self.configuration); + dst.write_u8(self.attributes); + dst.write_u8(self.max_power); + + Ok(()) + } + + fn name(&self) -> &'static str { + "USB_CONFIGURATION_DESCRIPTOR" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl Decode<'_> for UsbConfigDesc { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + let length = src.read_u8(); + let descriptor_type = src.read_u8(); + let total_length = src.read_u16(); + let num_interfaces = src.read_u8(); + let configuration_value = src.read_u8(); + let configuration = src.read_u8(); + let attributes = src.read_u8(); + let max_power = src.read_u8(); + + Ok(Self { + length, + descriptor_type, + total_length, + num_interfaces, + configuration_value, + configuration, + attributes, + max_power, + }) + } +} + +/// USB2.0 spec: 9.3 USB Device Requests: Table 9-2. Format of Setup Data +#[repr(C)] +#[derive(Debug, PartialEq, Clone)] +pub struct SetupPacket { + pub request_type: u8, + pub request: u8, + pub value: u16, + pub index: u16, + pub length: u16, +} + +impl SetupPacket { + pub const FIXED_PART_SIZE: usize = 8; +} + +impl Encode for SetupPacket { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + + dst.write_u8(self.request_type); + dst.write_u8(self.request); + dst.write_u16(self.value); + dst.write_u16(self.index); + dst.write_u16(self.length); + + Ok(()) + } + + fn name(&self) -> &'static str { + "USB2SetupData" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl Decode<'_> for SetupPacket { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let request_type = src.read_u8(); + let request = src.read_u8(); + let value = src.read_u16(); + let index = src.read_u16(); + let length = src.read_u16(); + + Ok(Self { + request_type, + request, + value, + index, + length, + }) + } +} diff --git a/crates/ironrdp-rdpeusb/src/pdu/utils.rs b/crates/ironrdp-rdpeusb/src/pdu/utils.rs new file mode 100644 index 0000000000..0daffbdad3 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/pdu/utils.rs @@ -0,0 +1,113 @@ +//! Common utilities needed for all the [\[MS-RDPEUSB\]][1] messages. +//! +//! [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a1004d0e-99e9-4968-894b-0b924ef2f125 + +use ironrdp_core::{ + Decode, DecodeError, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_fixed_part_size, + invalid_field_err, +}; + +#[cfg(doc)] +use crate::pdu::usb_dev::{InternalIoControl, IoControl, TransferInRequest, TransferOutRequest}; + +pub type ConfigHandle = u32; + +pub type PipeHandle = u32; + +pub type FrameNumber = u32; + +pub type UsbdStatus = u32; + +/// An integer value that indicates the result or status of an operation. +/// +/// * [MS-ERREF § 2.1 HRESULT][1] +/// +/// [1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/0642cb2f-2075-4469-918c-4441e69c548a +pub type HResult = u32; + +/// Represents the ID of a request previously sent via [`IoControl`], [`InternalIoControl`], +/// [`TransferInRequest`], or [`TransferOutRequest`] message. Think of this like an "umbrella" type +/// for [`RequestIdIoctl`] and [`RequestIdTsUrb`]. +pub type RequestId = u32; + +/// Represents a request ID that uniquely identifies an [`IoControl`] or [`InternalIoControl`] +/// message. +pub type RequestIdIoctl = u32; + +/// Is set to request data from a device. To transfer data to a device, this flag **MUST** be clear. +pub(crate) const USBD_TRANSFER_DIRECTION_IN: u32 = 0x1; + +/// The maximum number of endpoints EP 1-15 (IN + OUT) excluding EP 0, in a USB device. +/// (see USB2.0 Spec 9.6.6 Endpoint). +pub const MAX_NON_DEFAULT_EP_COUNT: usize = 30; + +/// Represents a request ID that uniquely identifies a [`TransferInRequest`] or +/// [`TransferOutRequest`] message. 31 bits. +#[repr(transparent)] +#[derive(Debug, PartialEq, Clone, Copy)] +pub struct RequestIdTransferInOut(u32); + +impl TryFrom for RequestIdTransferInOut { + type Error = DecodeError; + + fn try_from(value: u32) -> Result { + if value <= 0x7F_FF_FF_FF { + Ok(RequestIdTransferInOut(value)) + } else { + Err(invalid_field_err!( + "TsUrbHeader::RequestId", + "value greater than 31 bits" + )) + } + } +} + +impl From for u32 { + fn from(value: RequestIdTransferInOut) -> Self { + value.0 + } +} + +/// Describes an isochronous transfer packet. See [WDK: `USBD_ISO_PACKET_DESCRIPTOR`][1]. +/// +/// [1]: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/usb/ns-usb-_usbd_iso_packet_descriptor +#[doc(alias = "USBD_ISO_PACKET_DESCRIPTOR")] +#[derive(Debug, PartialEq, Clone)] +pub struct UsbdIsoPacketDesc { + pub offset: u32, + pub length: u32, + pub status: i32, +} + +impl UsbdIsoPacketDesc { + pub const FIXED_PART_SIZE: usize = + size_of::(/* Offset */) + size_of::(/* Length */) + size_of::(/* Status */); +} + +impl Encode for UsbdIsoPacketDesc { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_fixed_part_size!(in: dst); + dst.write_u32(self.offset); + dst.write_u32(self.length); + dst.write_i32(self.status); + Ok(()) + } + + fn name(&self) -> &'static str { + "USBD_ISO_PACKET_DESCRIPTOR" + } + + fn size(&self) -> usize { + Self::FIXED_PART_SIZE + } +} + +impl Decode<'_> for UsbdIsoPacketDesc { + fn decode(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_fixed_part_size!(in: src); + let offset = src.read_u32(); + let length = src.read_u32(); + let status = src.read_i32(); + Ok(Self { offset, length, status }) + } +} diff --git a/crates/ironrdp-rdpeusb/src/server.rs b/crates/ironrdp-rdpeusb/src/server.rs new file mode 100644 index 0000000000..99a75f03a1 --- /dev/null +++ b/crates/ironrdp-rdpeusb/src/server.rs @@ -0,0 +1,650 @@ +use alloc::collections::btree_map::{BTreeMap, Entry}; +use alloc::vec::Vec; +use alloc::{boxed::Box, vec}; +use ironrdp_core::{Decode as _, ReadCursor, impl_as_any}; +use ironrdp_dvc::{DvcMessage, DvcProcessor, DvcServerProcessor}; +use ironrdp_pdu::{PduResult, decode_err, pdu_other_err}; + +use crate::io::{ + DeviceAnnounce, DeviceText, InternalIoControlPacket, IoControlCompletionResult, IoControlPacket, ServerIoRequest, + TransferInCompletionResult, TransferInPacket, TransferOutCompletionResult, TransferOutPacket, UsbRetractReason, +}; +use crate::pdu::caps::RimExchangeCapabilityRequest; +use crate::pdu::completion::{IoControlCompletion, UrbCompletion, UrbCompletionNoData}; +use crate::pdu::header::{InterfaceId, Mask, MessageId}; +use crate::pdu::iface_manipulation::{InterfaceRelease, QueryInterfaceFailureResponse}; +use crate::pdu::notify::ChannelCreated; +use crate::pdu::sink::NoAckIsochWriteJitterBufSizeInMs; +use crate::pdu::usb_dev::{ + CancelRequest, QueryDeviceText, RegisterRequestCallback, RetractDevice, TransferInRequest, TransferOutRequest, +}; +use crate::pdu::utils::RequestId; +use crate::pdu::{UrbdrcClientControlPdu, UrbdrcClientDevicePdu}; +use crate::{CHANNEL_NAME, InvalidDeviceInterfaceId}; + +pub struct UrbdrcControlServer { + msg_id_alloc: IdAllocator, + state: State, + backend: Box, +} + +pub trait UrbdrcControlServerBackend: Send { + /// The server makes a new instance of a dynamic virtual channel for USB redirection. + fn create_device_chan(&mut self) -> PduResult<()>; +} + +#[derive(PartialEq)] +enum State { + CapsExchanging, + CapsExchanged, + Ready, +} + +impl UrbdrcControlServer { + pub fn new(backend: Box) -> Self { + Self { + msg_id_alloc: IdAllocator::new(), + state: State::CapsExchanging, + backend, + } + } +} + +struct IdAllocator { + id: u32, +} + +impl IdAllocator { + #[inline] + const fn new() -> Self { + Self { id: 0 } + } + + #[inline] + const fn alloc(&mut self) -> MessageId { + self.id += 1; + self.id + } +} + +struct RequestIdAllocator { + id: u32, +} + +impl RequestIdAllocator { + #[inline] + const fn new() -> Self { + Self { id: 0 } + } + + #[inline] + const fn alloc(&mut self) -> u32 { + self.id += 1; + if self.id > 0x7F_FF_FF_FF { + self.id = 0; + } + self.id + } +} + +impl DvcProcessor for UrbdrcControlServer { + fn channel_name(&self) -> &str { + CHANNEL_NAME + } + + fn start(&mut self, _channel_id: u32) -> PduResult> { + Ok(vec![Box::new(RimExchangeCapabilityRequest { + msg_id: self.msg_id_alloc.alloc(), + capability: crate::pdu::caps::Capability::RimCapabilityVersion01, + })]) + } + + fn process(&mut self, _channel_id: u32, payload: &[u8]) -> PduResult> { + let pdu = UrbdrcClientControlPdu::decode(&mut ReadCursor::new(payload)).map_err(|e| decode_err!(e))?; + + let mut resp: Vec = Vec::new(); + use UrbdrcClientControlPdu::*; + match pdu { + IfaceRelease(_iface_release_pdu) => Ok(resp), + QueryIfaceReq(query_req_pdu) => { + resp.push(Box::new(QueryInterfaceFailureResponse { + msg_id: query_req_pdu.msg_id, + iface_id: query_req_pdu.iface_id, + })); + Ok(resp) + } + Caps(_caps_response_pdu) => { + if self.state != State::CapsExchanging { + return Err(pdu_other_err!("invalid state")); + } + resp.push(Box::new(InterfaceRelease { + iface_id: InterfaceId::CAPABILITIES.with_mask(Mask::None), + msg_id: self.msg_id_alloc.alloc(), + })); + resp.push(Box::new(ChannelCreated { + msg_id: self.msg_id_alloc.alloc(), + direction: crate::pdu::notify::Direction::ToClient, + })); + self.state = State::CapsExchanged; + Ok(resp) + } + ChanCreated(_chan_created_pdu) => { + if self.state != State::CapsExchanged { + return Err(pdu_other_err!("invalid state")); + } + resp.push(Box::new(InterfaceRelease { + msg_id: self.msg_id_alloc.alloc(), + iface_id: InterfaceId::NOTIFY_CLIENT.with_mask(Mask::Proxy), + })); + self.state = State::Ready; + Ok(resp) + } + AddChan(_add_channel_pdu) => { + if self.state != State::Ready { + return Err(pdu_other_err!("invalid state")); + } + self.backend.create_device_chan()?; + Ok(resp) + } + } + } +} + +impl_as_any!(UrbdrcControlServer); + +impl DvcServerProcessor for UrbdrcControlServer {} + +pub trait UrbdrcDeviceServerBackend: Send { + /// [Add Device Message][2.2.4.2]: + /// + /// After receiving the ADD_DEVICE message, the server creates a remote device instance that + /// represents the client-side physical device. + /// + /// [2.2.4.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/a26bcb6d-d45d-48a9-b9bd-22e0107d8393 + fn add_device(&mut self, device: DeviceAnnounce) -> PduResult<()>; + + /// [Query Device Text Response Message][2.2.6.6]: + /// + /// Delivers the device description returned by the client to the server backend. + /// + /// [2.2.6.6]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/acffdcfa-c792-40a4-a8ee-c545ea5b0a38 + fn device_text(&mut self, device_text: DeviceText); + + /// [IO Control Completion Message][2.2.7.1]: + /// + /// Completes the IO control request identified by `request_id`. + /// + /// [2.2.7.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/b1722374-0658-47ba-8368-87bf9d3db4d4 + fn io_control_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: IoControlCompletionResult, + ) -> PduResult<()>; + + /// [IO Control Completion Message][2.2.7.1]: + /// + /// Completes the internal IO control request identified by `request_id`. + /// + /// [2.2.7.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/b1722374-0658-47ba-8368-87bf9d3db4d4 + fn internal_io_control_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: IoControlCompletionResult, + ) -> PduResult<()>; + + /// [URB Completion Message][2.2.7.2] and [URB Completion No Data Message][2.2.7.3]: + /// + /// Completes the transfer-in request identified by `request_id`. + /// + /// [2.2.7.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/5bfa9c84-a74b-4942-9d09-e770b21081eb + /// [2.2.7.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/994fac8f-d258-47a6-aa35-48783abe49ec + fn transfer_in_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: TransferInCompletionResult, + ) -> PduResult<()>; + + /// [URB Completion No Data Message][2.2.7.3]: + /// + /// Completes the transfer-out request identified by `request_id`. + /// + /// [2.2.7.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/994fac8f-d258-47a6-aa35-48783abe49ec + fn transfer_out_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: TransferOutCompletionResult, + ) -> PduResult<()>; +} + +pub struct UrbdrcDeviceServer { + msg_alloc: IdAllocator, + request_id_alloc: RequestIdAllocator, + udev_iface: Option, + comp_iface: InterfaceId, + no_ack_isoch_write_jitter_buf_size: Option, + pending_io: BTreeMap, + backend: Box, +} + +enum Pending { + IoCtl { max_output_buf_size: u32 }, + InternalIoCtl { max_output_buf_size: u32 }, + TransferIn { max_output_buf_size: u32 }, + TransferOut { max_output_buf_size: u32 }, +} + +impl UrbdrcDeviceServer { + pub fn new( + backend: Box, + comp_iface: InterfaceId, + ) -> Result>> { + if u32::from(comp_iface) <= u32::from(InterfaceId::NOTIFY_SERVER) { + return Err(InvalidDeviceInterfaceId::new(backend)); + } + + Ok(Self { + msg_alloc: IdAllocator::new(), + request_id_alloc: RequestIdAllocator::new(), + udev_iface: None, + comp_iface, + no_ack_isoch_write_jitter_buf_size: None, + pending_io: BTreeMap::new(), + backend, + }) + } + + pub fn query_device_text(&mut self, text_type: u32, locale_id: u32) -> PduResult { + let udev_iface = self.usb_device_iface()?; + Ok(Box::new(QueryDeviceText { + msg_id: self.msg_alloc.alloc(), + udev_iface, + text_type, + locale_id, + })) + } + + /// [IO Control Message][2.2.6.3]: + /// + /// Builds an IO control request to be sent to the client-side physical device. + /// + /// [2.2.6.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/021733cb-8e3b-49ac-b3e3-f7a764b11141 + pub fn io_control(&mut self, io_control_packet: IoControlPacket) -> PduResult { + let udev_iface = self.usb_device_iface()?; + let request_id = self.request_id_alloc.alloc(); + let request = io_control_packet.into_pdu(self.msg_alloc.alloc(), request_id, udev_iface); + + request + .check_output_buffer_size() + .map_err(|_| pdu_other_err!("invalid IO_CONTROL output buffer size"))?; + + self.insert_pending_io( + request_id, + Pending::IoCtl { + max_output_buf_size: request.output_buffer_size, + }, + )?; + + Ok(ServerIoRequest { + request_id, + expects_completion: true, + message: Box::new(request), + }) + } + + /// [Internal IO Control Message][2.2.6.4]: + /// + /// Builds an internal IO control request to be sent to the client-side physical device. + /// + /// [2.2.6.4]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/c3f3e320-336d-4d1b-84c9-51e0ed330ffe + pub fn internal_io_control( + &mut self, + internal_io_ctl_packet: InternalIoControlPacket, + ) -> PduResult { + let udev_iface = self.usb_device_iface()?; + let request_id = self.request_id_alloc.alloc(); + + let request = internal_io_ctl_packet.into_pdu(self.msg_alloc.alloc(), request_id, udev_iface); + self.insert_pending_io( + request_id, + Pending::InternalIoCtl { + max_output_buf_size: request.output_buffer_size, + }, + )?; + + Ok(ServerIoRequest { + request_id, + expects_completion: true, + message: Box::new(request), + }) + } + + /// [Transfer In Request][2.2.6.7]: + /// + /// Builds a transfer request that reads data from the client-side physical device. + /// + /// [2.2.6.7]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/e40f7738-bdd3-480f-a8bb-e1557a83a151 + pub fn transfer_in(&mut self, request: TransferInPacket) -> PduResult { + let udev_iface = self.usb_device_iface()?; + let request_id = self.request_id_alloc.alloc(); + let output_buffer_size = request.output_buffer_size; + let ts_urb = request.ts_urb.into_ts_urb(request_id)?; + let pdu = TransferInRequest { + msg_id: self.msg_alloc.alloc(), + udev_iface, + ts_urb, + output_buffer_size, + }; + pdu.check_output_buffer_size() + .map_err(|_| pdu_other_err!("invalid TRANSFER_IN_REQUEST output buffer size"))?; + + self.insert_pending_io( + request_id, + Pending::TransferIn { + max_output_buf_size: output_buffer_size, + }, + )?; + + Ok(ServerIoRequest { + request_id, + expects_completion: true, + message: Box::new(pdu), + }) + } + + /// [Transfer Out Request][2.2.6.8]: + /// + /// Builds a transfer request that writes data to the client-side physical device. + /// + /// [2.2.6.8]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/6d6c85b2-47bb-4674-975a-dc7d8ed684cd + pub fn transfer_out(&mut self, request: TransferOutPacket) -> PduResult { + let udev_iface = self.usb_device_iface()?; + let output_buffer_size = + u32::try_from(request.output_buffer.len()).map_err(|_| pdu_other_err!("convert usize to u32 failed"))?; + + let request_id = self.request_id_alloc.alloc(); + let no_ack = request.ts_urb.no_ack; + let no_ack_isoch_write_jitter_buf_size = self + .no_ack_isoch_write_jitter_buf_size + .ok_or_else(|| pdu_other_err!("USB device capabilities uninitialized"))?; + let ts_urb = request + .ts_urb + .into_ts_urb(request_id, no_ack_isoch_write_jitter_buf_size)?; + let pdu = TransferOutRequest { + msg_id: self.msg_alloc.alloc(), + udev_iface, + ts_urb, + output_buffer: request.output_buffer, + }; + + if !no_ack { + self.insert_pending_io( + request_id, + Pending::TransferOut { + max_output_buf_size: output_buffer_size, + }, + )?; + } + + Ok(ServerIoRequest { + request_id, + expects_completion: !no_ack, + message: Box::new(pdu), + }) + } + + pub fn cancel_request(&mut self, request_id: RequestId) -> PduResult { + let udev_iface = self.usb_device_iface()?; + Ok(Box::new(CancelRequest { + msg_id: self.msg_alloc.alloc(), + udev_iface, + req_id: request_id, + })) + } + + pub fn retract_device(&mut self, reason: UsbRetractReason) -> PduResult { + let udev_iface = self.usb_device_iface()?; + self.pending_io.clear(); + self.no_ack_isoch_write_jitter_buf_size = None; + Ok(Box::new(RetractDevice { + msg_id: self.msg_alloc.alloc(), + udev_iface, + reason, + })) + } + + fn usb_device_iface(&self) -> PduResult { + self.udev_iface + .ok_or_else(|| pdu_other_err!("USB device uninitialized")) + } + + fn insert_pending_io(&mut self, request_id: RequestId, pending: Pending) -> PduResult<()> { + match self.pending_io.entry(request_id) { + Entry::Vacant(entry) => { + entry.insert(pending); + Ok(()) + } + Entry::Occupied(_) => Err(pdu_other_err!("request id collision")), + } + } + + fn handle_io_control_completion( + &mut self, + channel_id: u32, + completion: IoControlCompletion, + ) -> PduResult> { + if completion.completion_iface != self.comp_iface { + return Ok(Vec::new()); + } + + let IoControlCompletion { + request_id, + hresult, + information, + output_buffer_size, + output_buffer, + .. + } = completion; + + let Some(pending) = self.pending_io.remove(&request_id) else { + return Err(pdu_other_err!("completion mismatch")); + }; + + let (is_internal, max_output_buf_size) = match pending { + Pending::IoCtl { max_output_buf_size } => (false, max_output_buf_size), + Pending::InternalIoCtl { max_output_buf_size } => (true, max_output_buf_size), + Pending::TransferIn { .. } | Pending::TransferOut { .. } => { + return Err(pdu_other_err!("completion mismatch")); + } + }; + + if output_buffer_size > max_output_buf_size { + return Err(pdu_other_err!("output buffer exceeds maximum amount")); + } + + let result = IoControlCompletionResult { + hresult, + information, + output_buffer, + }; + + if is_internal { + self.backend + .internal_io_control_completed(channel_id, request_id, result)?; + } else { + self.backend.io_control_completed(channel_id, request_id, result)?; + } + + Ok(Vec::new()) + } + + fn handle_urb_completion(&mut self, channel_id: u32, completion: UrbCompletion) -> PduResult> { + if completion.completion_iface != self.comp_iface { + return Ok(Vec::new()); + } + + let request_id = RequestId::from(completion.req_id); + + let Some(Pending::TransferIn { max_output_buf_size }) = self.pending_io.remove(&request_id) else { + return Err(pdu_other_err!("completion mismatch")); + }; + + let output_buffer_size = + u32::try_from(completion.output_buffer.len()).map_err(|_| pdu_other_err!("convert usize to u32 failed"))?; + if output_buffer_size > max_output_buf_size { + return Err(pdu_other_err!("output buffer exceeds maximum amount")); + } + + self.backend.transfer_in_completed( + channel_id, + request_id, + TransferInCompletionResult { + ts_urb_result: completion.ts_urb_result, + hresult: completion.hresult, + output_buffer: completion.output_buffer, + }, + )?; + + Ok(Vec::new()) + } + + fn handle_urb_completion_no_data( + &mut self, + channel_id: u32, + completion: UrbCompletionNoData, + ) -> PduResult> { + if completion.completion_iface != self.comp_iface { + return Ok(Vec::new()); + } + + let request_id = RequestId::from(completion.req_id); + let Some(pending) = self.pending_io.remove(&request_id) else { + return Err(pdu_other_err!("completion mismatch")); + }; + + let is_transfer_out = match pending { + Pending::TransferIn { .. } => { + if completion.output_buffer_size != 0 { + return Err(pdu_other_err!("output buffer size must be zero")); + } + false + } + Pending::TransferOut { max_output_buf_size } => { + if completion.output_buffer_size > max_output_buf_size { + return Err(pdu_other_err!("output buffer exceeds maximum amount")); + } + true + } + Pending::IoCtl { .. } | Pending::InternalIoCtl { .. } => { + return Err(pdu_other_err!("completion mismatch")); + } + }; + + if is_transfer_out { + self.backend.transfer_out_completed( + channel_id, + request_id, + TransferOutCompletionResult { + ts_urb_result: completion.ts_urb_result, + hresult: completion.hresult, + output_buffer_size: completion.output_buffer_size, + }, + )?; + } else { + self.backend.transfer_in_completed( + channel_id, + request_id, + TransferInCompletionResult { + ts_urb_result: completion.ts_urb_result, + hresult: completion.hresult, + output_buffer: Vec::new(), + }, + )?; + } + + Ok(Vec::new()) + } +} + +impl DvcProcessor for UrbdrcDeviceServer { + fn channel_name(&self) -> &str { + CHANNEL_NAME + } + + fn start(&mut self, _channel_id: u32) -> PduResult> { + Ok(vec![Box::new(ChannelCreated { + msg_id: self.msg_alloc.alloc(), + direction: crate::pdu::notify::Direction::ToClient, + })]) + } + + fn process(&mut self, channel_id: u32, payload: &[u8]) -> PduResult> { + let pdu = UrbdrcClientDevicePdu::decode(&mut ReadCursor::new(payload)).map_err(|e| decode_err!(e))?; + let mut resp: Vec = Vec::new(); + + use UrbdrcClientDevicePdu::*; + match pdu { + ChanCreated(_channel_created_pdu) => { + resp.push(Box::new(InterfaceRelease { + msg_id: self.msg_alloc.alloc(), + iface_id: InterfaceId::NOTIFY_CLIENT.with_mask(Mask::Proxy), + })); + Ok(resp) + } + AddDev(add_dev_pdu) => { + // In the case of the server receiving a duplicate interface ID, the server MUST + // ignore the ADD_DEVICE message. + if self.udev_iface.is_some() { + return Ok(resp); + } + let udev_iface = add_dev_pdu.usb_device; + let no_ack_isoch_write_jitter_buf_size = add_dev_pdu.usb_device_caps.no_ack_isoch_write_jitter_buf_size; + self.udev_iface = Some(udev_iface); + + let device = add_dev_pdu.try_into()?; + + self.backend.add_device(device)?; + self.no_ack_isoch_write_jitter_buf_size = Some(no_ack_isoch_write_jitter_buf_size); + resp.push(Box::new(InterfaceRelease { + msg_id: self.msg_alloc.alloc(), + iface_id: InterfaceId::DEVICE_SINK.with_mask(Mask::Proxy), + })); + resp.push(Box::new(RegisterRequestCallback { + msg_id: self.msg_alloc.alloc(), + udev_iface, + request_completion: Some(self.comp_iface), + })); + Ok(resp) + } + IfaceRelease(_iface_release_pdu) => Ok(resp), + DevTextRsp(dev_text_rsp_pdu) => { + let device_text = DeviceText { + hresult: dev_text_rsp_pdu.hresult, + description: dev_text_rsp_pdu + .device_description + .into_native() + .map_err(|e| pdu_other_err!("invalid device description").with_source(e))?, + }; + self.backend.device_text(device_text); + Ok(resp) + } + IoctlComp(ioctl_comp_pdu) => self.handle_io_control_completion(channel_id, ioctl_comp_pdu), + UrbComp(urb_comp_pdu) => self.handle_urb_completion(channel_id, urb_comp_pdu), + UrbCompNoData(urb_comp_no_data_pdu) => self.handle_urb_completion_no_data(channel_id, urb_comp_no_data_pdu), + QueryIfaceReq(query_iface_req_pdu) => { + resp.push(Box::new(QueryInterfaceFailureResponse { + msg_id: query_iface_req_pdu.msg_id, + iface_id: query_iface_req_pdu.iface_id, + })); + Ok(resp) + } + } + } +} + +impl_as_any!(UrbdrcDeviceServer); + +impl DvcServerProcessor for UrbdrcDeviceServer {} diff --git a/crates/ironrdp-rdpfile/CHANGELOG.md b/crates/ironrdp-rdpfile/CHANGELOG.md new file mode 100644 index 0000000000..9b929a7aed --- /dev/null +++ b/crates/ironrdp-rdpfile/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [[0.1.0](https://github.com/Devolutions/IronRDP/releases/tag/ironrdp-rdpfile-v0.1.0)] - 2026-07-10 + +Initial release. diff --git a/crates/ironrdp-rdpfile/Cargo.toml b/crates/ironrdp-rdpfile/Cargo.toml index f250a9df94..6e664a0142 100644 --- a/crates/ironrdp-rdpfile/Cargo.toml +++ b/crates/ironrdp-rdpfile/Cargo.toml @@ -3,8 +3,8 @@ name = "ironrdp-rdpfile" version = "0.1.0" readme = "README.md" description = "Parser and writer for .RDP file format" -publish = false # TODO: publish edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true diff --git a/crates/ironrdp-rdpfile/src/lib.rs b/crates/ironrdp-rdpfile/src/lib.rs index 375192596f..e3446fcd04 100644 --- a/crates/ironrdp-rdpfile/src/lib.rs +++ b/crates/ironrdp-rdpfile/src/lib.rs @@ -13,9 +13,9 @@ use ironrdp_propertyset::{PropertySet, Value}; #[derive(Debug, Clone)] pub enum ErrorKind { - UnknownType { ty: String }, - InvalidValue { ty: String, value: String }, - MalformedLine { line: String }, + UnknownType { key: String, ty: String }, + InvalidValue { key: String, ty: String }, + MalformedLine, } #[derive(Debug, Clone)] @@ -31,11 +31,13 @@ impl fmt::Display for Error { let line_number = self.line; match &self.kind { - ErrorKind::UnknownType { ty } => write!(f, "unknown type at line {line_number} ({ty})"), - ErrorKind::InvalidValue { ty, value } => { - write!(f, "invalid value at line {line_number} for type {ty} ({value})") + ErrorKind::UnknownType { key, ty } => { + write!(f, "unknown type at line {line_number} for key '{key}' ({ty})") } - ErrorKind::MalformedLine { line } => write!(f, "malformed line at line {line_number} ({line})"), + ErrorKind::InvalidValue { key, ty } => { + write!(f, "invalid value at line {line_number} for key '{key}' (type {ty})") + } + ErrorKind::MalformedLine => write!(f, "malformed line at line {line_number}"), } } } @@ -44,6 +46,7 @@ pub fn load(properties: &mut PropertySet, input: &str) -> Result<(), Vec> let mut errors = Vec::new(); for (idx, line) in input.lines().enumerate() { + let line_number = idx + 1; let mut split = line.splitn(3, ':'); if let (Some(key), Some(ty), Some(value)) = (split.next(), split.next(), split.next()) { @@ -54,10 +57,10 @@ pub fn load(properties: &mut PropertySet, input: &str) -> Result<(), Vec> } else { errors.push(Error { kind: ErrorKind::InvalidValue { + key: key.to_owned(), ty: ty.to_owned(), - value: value.to_owned(), }, - line: idx, + line: line_number, }); } } @@ -66,24 +69,23 @@ pub fn load(properties: &mut PropertySet, input: &str) -> Result<(), Vec> } _ => { errors.push(Error { - kind: ErrorKind::UnknownType { ty: ty.to_owned() }, - line: idx, + kind: ErrorKind::UnknownType { + key: key.to_owned(), + ty: ty.to_owned(), + }, + line: line_number, }); } } } else { errors.push(Error { - kind: ErrorKind::MalformedLine { line: line.to_owned() }, - line: idx, + kind: ErrorKind::MalformedLine, + line: line_number, }) } } - if errors.is_empty() { - Ok(()) - } else { - Err(errors) - } + if errors.is_empty() { Ok(()) } else { Err(errors) } } pub struct ParseResult { diff --git a/crates/ironrdp-rdpsnd-native/CHANGELOG.md b/crates/ironrdp-rdpsnd-native/CHANGELOG.md index 25bbb0a830..88bab83e23 100644 --- a/crates/ironrdp-rdpsnd-native/CHANGELOG.md +++ b/crates/ironrdp-rdpsnd-native/CHANGELOG.md @@ -6,6 +6,53 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-native-v0.6.0...ironrdp-rdpsnd-native-v0.7.0)] - 2026-07-10 + +### Bug Fixes + +- Lower verbosity of routine logs in library crates ([c36032f91b](https://github.com/Devolutions/IronRDP/commit/c36032f91b27390a2cd34bfb300cfbe099d847a9)) + + Library crates should not emit info! for routine, repeating operations; + that floods the default logs of the final consumer, which owns the + verbosity decision. Reserve info! for rare connection/session lifecycle + milestones, debug! for significant one-off events, and trace! for the + fine-grained detail only needed when nothing else explains a problem. + +- [**breaking**] Replace anyhow with typed RdpsndNativeError ([#1277](https://github.com/Devolutions/IronRDP/issues/1277)) ([37483ebd9b](https://github.com/Devolutions/IronRDP/commit/37483ebd9b7628325666f434e1679e7f885fb289)) + + + +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-native-v0.5.0...ironrdp-rdpsnd-native-v0.6.0)] - 2026-05-27 + +### Bug Fixes + +- Allocate Opus PCM buffer as Vec to avoid alignment panic ([#1256](https://github.com/Devolutions/IronRDP/issues/1256)) ([905a148604](https://github.com/Devolutions/IronRDP/commit/905a148604e7bac67cdcb2e915e3cacd29693f57)) + +### Build + +- Bump cpal from 0.16.0 to 0.17.1 ([#1071](https://github.com/Devolutions/IronRDP/issues/1071)) ([71245d58cc](https://github.com/Devolutions/IronRDP/commit/71245d58ccfb35dcc403628000ac2649b4bf9697)) + +- Bump opus2 from 0.3.3 to 0.4.0 ([#1204](https://github.com/Devolutions/IronRDP/issues/1204)) ([1eaf333057](https://github.com/Devolutions/IronRDP/commit/1eaf333057bec13778d78be2b2e71ca429733ee9)) + + +## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-native-v0.4.0...ironrdp-rdpsnd-native-v0.4.1)] - 2025-09-24 + +### Build + +- Replace `opus` by `opus2` (#985) ([e5042a7d81](https://github.com/Devolutions/IronRDP/commit/e5042a7d81b864e78ccf19d6b358d94458f951d0)) + + `opus` is unmaintained and points to a 4-year-old commit of the opus C + library. This does not compile anymore on our CI, because their + CMakeList.txt requires an older version of CMake that is not available + in the runners we use. `opus2` is a fork that points to a more recent + version of it. + +## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-native-v0.3.1...ironrdp-rdpsnd-native-v0.4.0)] - 2025-08-29 + +### Build + +- Bump cpal to 0.16 ([eeac1fee1f](https://github.com/Devolutions/IronRDP/commit/eeac1fee1fed4858f4776d86072790bc074e34eb)) + ## [[0.3.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-native-v0.3.0...ironrdp-rdpsnd-native-v0.3.1)] - 2025-06-27 ### Build @@ -18,7 +65,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update dependencies (#695) ([c21fa44fd6](https://github.com/Devolutions/IronRDP/commit/c21fa44fd6f3c6a6b74788ff68e83133c1314caa)) - ## [[0.1.3](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-native-v0.1.2...ironrdp-rdpsnd-native-v0.1.3)] - 2025-02-05 ### Features @@ -26,15 +72,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add Opus audio client decoding (#661) ([ccf6348270](https://github.com/Devolutions/IronRDP/commit/ccf63482706ecfbbdc6038028ea2ee086d0e3640)) - ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-native-v0.1.1...ironrdp-rdpsnd-native-v0.1.2)] - 2025-01-28 ### Documentation - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-native-v0.1.0...ironrdp-rdpsnd-native-v0.1.1)] - 2024-12-15 ### Other diff --git a/crates/ironrdp-rdpsnd-native/Cargo.toml b/crates/ironrdp-rdpsnd-native/Cargo.toml index fc32a12b68..84688e870a 100644 --- a/crates/ironrdp-rdpsnd-native/Cargo.toml +++ b/crates/ironrdp-rdpsnd-native/Cargo.toml @@ -1,8 +1,9 @@ [package] name = "ironrdp-rdpsnd-native" -version = "0.3.1" +version = "0.7.0" description = "Native RDPSND static channel backend implementations for IronRDP" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -16,17 +17,18 @@ test = false [features] default = ["opus"] -opus = ["dep:opus", "dep:bytemuck"] +opus = ["dep:opus2", "dep:bytemuck"] [dependencies] -anyhow = "1" -bytemuck = { version = "1.23", optional = true } -cpal = "0.16" -ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.5" } # public -opus = { version = "0.3", optional = true } +bytemuck = { version = "1.24", optional = true } +cpal = "0.17" +ironrdp-error = { path = "../ironrdp-error", version = "0.2", features = ["std"] } # public +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.9" } # public +opus2 = { version = "0.4", optional = true, features = ["bundled"] } tracing = { version = "0.1", features = ["log"] } [dev-dependencies] +anyhow = "1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } [lints] diff --git a/crates/ironrdp-rdpsnd-native/examples/cpal.rs b/crates/ironrdp-rdpsnd-native/examples/cpal.rs index e7d2f47960..3da942a56a 100644 --- a/crates/ironrdp-rdpsnd-native/examples/cpal.rs +++ b/crates/ironrdp-rdpsnd-native/examples/cpal.rs @@ -12,8 +12,8 @@ use tracing::debug; fn setup_logging() -> anyhow::Result<()> { use tracing::metadata::LevelFilter; - use tracing_subscriber::prelude::*; use tracing_subscriber::EnvFilter; + use tracing_subscriber::prelude::*; let fmt_layer = tracing_subscriber::fmt::layer().compact(); @@ -43,7 +43,7 @@ fn main() -> anyhow::Result<()> { data: None, }; let (tx, rx) = mpsc::channel(); - let stream = DecodeStream::new(&rx_format, rx).unwrap(); + let stream = DecodeStream::new(&rx_format, rx)?; let producer = thread::spawn(move || { let data_chunks = vec![vec![1u8, 2, 3], vec![4, 5, 6], vec![7, 8, 9]]; @@ -54,7 +54,7 @@ fn main() -> anyhow::Result<()> { } }); - stream.stream.play()?; + stream.stream().play()?; thread::sleep(Duration::from_secs(3)); let _ = producer.join(); diff --git a/crates/ironrdp-rdpsnd-native/src/cpal.rs b/crates/ironrdp-rdpsnd-native/src/cpal.rs index 831b6aa51e..0dae2acb4e 100644 --- a/crates/ironrdp-rdpsnd-native/src/cpal.rs +++ b/crates/ironrdp-rdpsnd-native/src/cpal.rs @@ -1,16 +1,18 @@ use core::sync::atomic::{AtomicBool, Ordering}; use core::time::Duration; use std::borrow::Cow; -use std::sync::mpsc::{self, Receiver, Sender}; use std::sync::Arc; +use std::sync::mpsc::{self, Receiver, Sender}; use std::thread::{self, JoinHandle}; -use anyhow::{bail, Context as _}; use cpal::traits::{DeviceTrait as _, HostTrait as _}; use cpal::{SampleFormat, Stream, StreamConfig}; +use ironrdp_error::bail; use ironrdp_rdpsnd::client::RdpsndClientHandler; use ironrdp_rdpsnd::pdu::{AudioFormat, PitchPdu, VolumePdu, WaveFormat}; -use tracing::{debug, error, info, warn}; +use tracing::{debug, error, trace, warn}; + +use crate::error::{RdpsndNativeError, RdpsndNativeErrorKind, RdpsndNativeResult}; #[derive(Debug)] pub struct RdpsndBackend { @@ -91,7 +93,7 @@ impl RdpsndClientHandler for RdpsndBackend { let stream = match DecodeStream::new(&format, rx) { Ok(stream) => stream, Err(e) => { - error!(error = format!("{e:#}")); + error!(error = %e.report()); return; } }; @@ -124,7 +126,9 @@ impl RdpsndClientHandler for RdpsndBackend { if let Some(stream) = self.stream_handle.take() { self.stream_ended.store(true, Ordering::Relaxed); stream.thread().unpark(); - stream.join().unwrap(); + if let Err(err) = stream.join() { + error!(?err, "Failed to join a stream thread"); + } } } } @@ -132,57 +136,97 @@ impl RdpsndClientHandler for RdpsndBackend { #[doc(hidden)] pub struct DecodeStream { _dec_thread: Option>, - pub stream: Stream, + stream: Stream, } impl DecodeStream { - pub fn new(rx_format: &AudioFormat, mut rx: Receiver>) -> anyhow::Result { + pub fn new(rx_format: &AudioFormat, mut rx: Receiver>) -> RdpsndNativeResult { let mut dec_thread = None; match rx_format.format { #[cfg(feature = "opus")] WaveFormat::OPUS => { let chan = match rx_format.n_channels { - 1 => opus::Channels::Mono, - 2 => opus::Channels::Stereo, - _ => bail!("unsupported #channels for Opus"), + 1 => opus2::Channels::Mono, + 2 => opus2::Channels::Stereo, + _ => bail!( + "unsupported channel count for Opus", + RdpsndNativeErrorKind::UnsupportedFormat, + ), }; let (dec_tx, dec_rx) = mpsc::channel(); - let mut dec = opus::Decoder::new(rx_format.n_samples_per_sec, chan)?; + let mut dec = opus2::Decoder::new(rx_format.n_samples_per_sec, chan).map_err(|e| { + RdpsndNativeError::new("creating Opus decoder", RdpsndNativeErrorKind::OpusInit).with_source(e) + })?; dec_thread = Some(thread::spawn(move || { while let Ok(pkt) = rx.recv() { - let nb_samples = dec.get_nb_samples(&pkt).unwrap(); - let mut pcm = vec![0u8; nb_samples * chan as usize * size_of::()]; - dec.decode(&pkt, bytemuck::cast_slice_mut(pcm.as_mut_slice()), false) - .unwrap(); - dec_tx.send(pcm).unwrap(); + let nb_samples = match dec.get_nb_samples(&pkt) { + Ok(nb_samples) => nb_samples, + Err(error) => { + error!(?error, "Failed to get the number of samples of an Opus packet"); + continue; + } + }; + + #[expect( + clippy::as_conversions, + reason = "opus::Channels has no conversions to usize implemented" + )] + let mut pcm_i16 = vec![0i16; nb_samples * chan as usize]; + if let Err(error) = dec.decode(&pkt, &mut pcm_i16, false) { + error!(?error, "Failed to decode an Opus packet"); + continue; + } + // Vec is what the channel carries downstream. Reinterpreting + // Vec -> Vec via cast_slice is safe (smaller alignment). + // Allocating as Vec in the first place avoids the alignment + // hazard of `bytemuck::cast_slice_mut::` panicking when + // the allocator hands back a u8 buffer that is not 2-byte aligned + // (which manifested as a hard crash in #1202 under the burst of + // malformed Opus packets generated by a server reboot). + let pcm = bytemuck::cast_slice(&pcm_i16).to_vec(); + + if dec_tx.send(pcm).is_err() { + error!("Failed to send the decoded Opus packet over the channel"); + // If send has failed, it means that the receiver has been dropped. + // There is no point in continuing the loop in this case. + break; + } } })); rx = dec_rx; } WaveFormat::PCM => {} - _ => bail!("audio format not supported"), + _ => bail!( + "matching server-requested wave format", + RdpsndNativeErrorKind::UnsupportedFormat, + ), } let sample_format = match rx_format.bits_per_sample { 8 => SampleFormat::U8, 16 => SampleFormat::I16, - _ => { - bail!("only PCM 8/16 bits formats supported"); - } + _ => bail!( + "only PCM 8/16 bit formats supported", + RdpsndNativeErrorKind::UnsupportedFormat, + ), }; let host = cpal::default_host(); - let device = host.default_output_device().context("no default output device")?; - let _supported_configs_range = device - .supported_output_configs() - .context("no supported output config")?; - let default_config = device.default_output_config()?; + let device = host + .default_output_device() + .ok_or_else(|| RdpsndNativeError::new("no default output device", RdpsndNativeErrorKind::AudioDevice))?; + let _supported_configs_range = device.supported_output_configs().map_err(|e| { + RdpsndNativeError::new("no supported output configs", RdpsndNativeErrorKind::AudioDevice).with_source(e) + })?; + let default_config = device.default_output_config().map_err(|e| { + RdpsndNativeError::new("default output config", RdpsndNativeErrorKind::AudioDevice).with_source(e) + })?; debug!(?default_config); let mut rx = RxBuffer::new(rx); let config = StreamConfig { channels: rx_format.n_channels, - sample_rate: cpal::SampleRate(rx_format.n_samples_per_sec), + sample_rate: rx_format.n_samples_per_sec, buffer_size: cpal::BufferSize::Default, }; debug!(?config); @@ -198,13 +242,19 @@ impl DecodeStream { |error| error!(%error), None, ) - .context("failed to setup output stream")?; + .map_err(|e| { + RdpsndNativeError::new("building cpal output stream", RdpsndNativeErrorKind::StreamBuild).with_source(e) + })?; Ok(Self { _dec_thread: dec_thread, stream, }) } + + pub fn stream(&self) -> &Stream { + &self.stream + } } struct RxBuffer { @@ -239,7 +289,7 @@ impl RxBuffer { } let Some(ref last) = self.last else { - info!("Playback rx underrun"); + trace!("Playback rx underrun"); return; }; diff --git a/crates/ironrdp-rdpsnd-native/src/error.rs b/crates/ironrdp-rdpsnd-native/src/error.rs new file mode 100644 index 0000000000..1c84db69cc --- /dev/null +++ b/crates/ironrdp-rdpsnd-native/src/error.rs @@ -0,0 +1,39 @@ +//! Typed error types for `ironrdp-rdpsnd-native`. + +/// Categorises failures in `ironrdp-rdpsnd-native` operations. +/// +/// Bug-shaped conditions are intentionally absent: misuse of this crate's +/// public API should panic or trip `debug_assert!`, not return `Err`. +#[derive(Debug)] +#[non_exhaustive] +pub enum RdpsndNativeErrorKind { + /// Server requested an audio format outside the supported set (wave + /// format, channel count, or bit depth). + UnsupportedFormat, + /// The Opus decoder failed to initialise. Source carries the underlying + /// `opus2::Error` when available. + OpusInit, + /// No usable audio output device or no supported output configuration + /// for the requested format. Source carries the underlying `cpal` error + /// when available. + AudioDevice, + /// The `cpal` output stream could not be built. Source carries the + /// underlying `cpal::BuildStreamError`. + StreamBuild, +} + +impl core::fmt::Display for RdpsndNativeErrorKind { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::UnsupportedFormat => write!(f, "unsupported audio format"), + Self::OpusInit => write!(f, "Opus decoder initialisation"), + Self::AudioDevice => write!(f, "audio output device"), + Self::StreamBuild => write!(f, "output audio stream build"), + } + } +} + +impl core::error::Error for RdpsndNativeErrorKind {} + +pub type RdpsndNativeError = ironrdp_error::Error; +pub type RdpsndNativeResult = Result; diff --git a/crates/ironrdp-rdpsnd-native/src/lib.rs b/crates/ironrdp-rdpsnd-native/src/lib.rs index 04a3b3cfa7..5d2c3d3c88 100644 --- a/crates/ironrdp-rdpsnd-native/src/lib.rs +++ b/crates/ironrdp-rdpsnd-native/src/lib.rs @@ -1,7 +1,12 @@ #![cfg_attr(doc, doc = include_str!("../README.md"))] #![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] - -#[cfg(test)] -use tracing_subscriber as _; +// `anyhow` and `tracing-subscriber` are dev-deps used only by the `cpal` +// example binary, but `unused_crate_dependencies` still flags them on the +// lib target. The `[lib] test = false` setting makes a `#[cfg(test)]` +// workaround dead code, so the suppression has to apply unconditionally. +#![allow(unused_crate_dependencies)] pub mod cpal; +pub mod error; + +pub use error::{RdpsndNativeError, RdpsndNativeErrorKind, RdpsndNativeResult}; diff --git a/crates/ironrdp-rdpsnd/CHANGELOG.md b/crates/ironrdp-rdpsnd/CHANGELOG.md index d8a6796a1f..0c3cb06b0d 100644 --- a/crates/ironrdp-rdpsnd/CHANGELOG.md +++ b/crates/ironrdp-rdpsnd/CHANGELOG.md @@ -6,6 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-v0.8.1...ironrdp-rdpsnd-v0.9.0)] - 2026-07-10 + +### Features + +- [**breaking**] Misuse-resistant format negotiation for RdpsndServerHandler ([#1359](https://github.com/Devolutions/IronRDP/issues/1359)) ([2d3bdef1a7](https://github.com/Devolutions/IronRDP/commit/2d3bdef1a7167d2acdc478a92917cbb2f018960b)) + + Move the negotiation into the crate and split selection from lifecycle: + + ```rust + fn choose_format<'a>(&mut self, common: &'a [NegotiatedFormat]) -> Option<&'a NegotiatedFormat>; + fn start(&mut self, format: &NegotiatedFormat); + ``` + + + +## [[0.8.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-v0.8.0...ironrdp-rdpsnd-v0.8.1)] - 2026-06-05 + +### Documentation + +- Document RdpsndServerHandler::start wFormatNo contract ([#1343](https://github.com/Devolutions/IronRDP/issues/1343)) ([7894d9f093](https://github.com/Devolutions/IronRDP/commit/7894d9f093db3c80f7358af8e0d8beb18964ce45)) + + Adds Rustdoc documentation to `RdpsndServerHandler`, focusing on the contract for `start()`’s `Option` return value so implementers correctly compute `wFormatNo` for Wave/Wave2 PDUs. + + + +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-v0.7.0...ironrdp-rdpsnd-v0.8.0)] - 2026-05-27 + +### Bug Fixes + +- Replace all from_bits_truncate with from_bits_retain ([#1144](https://github.com/Devolutions/IronRDP/issues/1144)) ([353e30ddfd](https://github.com/Devolutions/IronRDP/commit/353e30ddfdaafc897db10b8663e364ef7775a7fd)) + + from_bits_truncate silently discards unknown bits, which breaks the + encode/decode round-trip property. This matters for fuzzing because a + PDU that decodes and re-encodes should produce identical bytes. + from_bits_retain preserves all bits, including those not yet defined in + our bitflags types, so the round-trip property holds. + +- Handle AudioFormat renegotiation in Ready state ([#1164](https://github.com/Devolutions/IronRDP/issues/1164)) ([2fe6fd0424](https://github.com/Devolutions/IronRDP/commit/2fe6fd04244a7031a19af5a321bdf44308f6df2d)) + + Sometimes Windows Server re-sends `SNDC_FORMATS` during Ready state + (e.g., after mute/unmute in remote browser). Previously this hit the + wildcard branch, entering Stop and permanently killing audio. + + Add an `AudioFormat` arm in Ready state to close the current stream and + restart negotiation. + +### Build + +- Bump the patch group across 1 directory with 2 updates ([#1222](https://github.com/Devolutions/IronRDP/issues/1222)) ([3fe6d157e0](https://github.com/Devolutions/IronRDP/commit/3fe6d157e0b55bddfdac20af290a6cfa6e550576)) + + ## [[0.5.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-v0.4.0...ironrdp-rdpsnd-v0.5.0)] - 2025-05-27 ### Features @@ -50,7 +101,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New required method `get_formats` for the `RdpsndClientHandler` trait (#661) ([ccf6348270](https://github.com/Devolutions/IronRDP/commit/ccf63482706ecfbbdc6038028ea2ee086d0e3640)) - ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-v0.1.1...ironrdp-rdpsnd-v0.2.0)] - 2025-01-28 ### Features @@ -64,7 +114,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-rdpsnd-v0.1.0...ironrdp-rdpsnd-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-rdpsnd/Cargo.toml b/crates/ironrdp-rdpsnd/Cargo.toml index 93e74fce96..84d1fd15b5 100644 --- a/crates/ironrdp-rdpsnd/Cargo.toml +++ b/crates/ironrdp-rdpsnd/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-rdpsnd" -version = "0.5.0" +version = "0.9.0" readme = "README.md" description = "RDPSND static channel for audio output implemented as described in MS-RDPEA" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -18,13 +19,19 @@ test = false [features] default = [] std = [] +# Internal (PRIVATE!) feature used to aid testing. +# Don't rely on this whatsoever. It may disappear at any time. +# It uses `visibility` to expose otherwise-private negotiation helpers to the +# integration testsuite (the lib has no inline test harness — `test = false`). +__test = ["dep:visibility"] [dependencies] -bitflags = "2.9" +bitflags = "2.11" tracing = { version = "0.1", features = ["log"] } -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public -ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5", features = ["alloc"] } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["alloc"] } # public +visibility = { version = "0.1", optional = true } [lints] workspace = true diff --git a/crates/ironrdp-rdpsnd/src/client.rs b/crates/ironrdp-rdpsnd/src/client.rs index b90e7dc937..ca640dbb24 100644 --- a/crates/ironrdp-rdpsnd/src/client.rs +++ b/crates/ironrdp-rdpsnd/src/client.rs @@ -1,9 +1,9 @@ use std::borrow::Cow; use std::collections::HashSet; -use ironrdp_core::{cast_length, impl_as_any, Decode as _, EncodeResult, ReadCursor}; +use ironrdp_core::{Decode as _, EncodeResult, ReadCursor, cast_length, impl_as_any}; use ironrdp_pdu::gcc::ChannelName; -use ironrdp_pdu::{decode_err, encode_err, pdu_other_err, PduResult}; +use ironrdp_pdu::{PduResult, decode_err, encode_err, pdu_other_err}; use ironrdp_svc::{CompressionCondition, SvcClientProcessor, SvcMessage, SvcProcessor}; use tracing::{debug, error}; @@ -80,7 +80,7 @@ impl Rdpsnd { server_format .formats - .get(format_no as usize) + .get(usize::from(format_no)) .ok_or_else(|| pdu_other_err!("invalid format")) } @@ -115,20 +115,18 @@ impl Rdpsnd { pitch: 0x00010000, dgram_port: 0, }; - Ok(RdpsndSvcMessages::new(vec![pdu::ClientAudioOutputPdu::AudioFormat( - pdu, - ) - .into()])) + Ok(RdpsndSvcMessages::new(vec![ + pdu::ClientAudioOutputPdu::AudioFormat(pdu).into(), + ])) } pub fn quality_mode(&mut self) -> PduResult { let pdu = pdu::QualityModePdu { quality_mode: pdu::QualityMode::High, }; - Ok(RdpsndSvcMessages::new(vec![pdu::ClientAudioOutputPdu::QualityMode( - pdu, - ) - .into()])) + Ok(RdpsndSvcMessages::new(vec![ + pdu::ClientAudioOutputPdu::QualityMode(pdu).into(), + ])) } pub fn training_confirm(&mut self, pdu: &TrainingPdu) -> PduResult { @@ -145,10 +143,9 @@ impl Rdpsnd { pub fn wave_confirm(&mut self, timestamp: u16, block_no: u8) -> PduResult { let pdu = pdu::WaveConfirmPdu { timestamp, block_no }; - Ok(RdpsndSvcMessages::new(vec![pdu::ClientAudioOutputPdu::WaveConfirm( - pdu, - ) - .into()])) + Ok(RdpsndSvcMessages::new(vec![ + pdu::ClientAudioOutputPdu::WaveConfirm(pdu).into(), + ])) } } @@ -196,7 +193,7 @@ impl SvcProcessor for Rdpsnd { match pdu { // TODO: handle WaveInfo for < v8 pdu::ServerAudioOutputPdu::Wave2(pdu) => { - let format_no = pdu.format_no as usize; + let format_no = usize::from(pdu.format_no); let ts = pdu.audio_timestamp; self.handler.wave(format_no, ts, pdu.data); return Ok(self.wave_confirm(pdu.timestamp, pdu.block_no)?.into()); @@ -211,6 +208,17 @@ impl SvcProcessor for Rdpsnd { self.handler.close(); } pdu::ServerAudioOutputPdu::Training(pdu) => return Ok(self.training_confirm(&pdu)?.into()), + pdu::ServerAudioOutputPdu::AudioFormat(af) => { + self.handler.close(); + self.server_format = Some(af); + self.state = RdpsndState::WaitingForTraining; + let mut msgs: Vec = self.client_formats()?.into(); + if self.version()? >= pdu::Version::V6 { + let mut m = self.quality_mode()?.into(); + msgs.append(&mut m); + } + return Ok(msgs); + } _ => { error!("Invalid PDU"); self.state = RdpsndState::Stop; diff --git a/crates/ironrdp-rdpsnd/src/pdu/mod.rs b/crates/ironrdp-rdpsnd/src/pdu/mod.rs index d5f3893dbd..e26fa59692 100644 --- a/crates/ironrdp-rdpsnd/src/pdu/mod.rs +++ b/crates/ironrdp-rdpsnd/src/pdu/mod.rs @@ -7,8 +7,8 @@ use std::fmt; use bitflags::bitflags; use ironrdp_core::{ - cast_length, ensure_fixed_part_size, ensure_size, invalid_field_err, other_err, Decode, DecodeError, DecodeResult, - Encode, EncodeResult, ReadCursor, WriteCursor, + Decode, DecodeError, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, + ensure_fixed_part_size, ensure_size, invalid_field_err, other_err, }; use ironrdp_pdu::{read_padding, write_padding}; use ironrdp_svc::SvcEncode; @@ -51,6 +51,10 @@ impl TryFrom for Version { } impl From for u16 { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] fn from(version: Version) -> Self { version as u16 } @@ -351,12 +355,12 @@ impl<'de> Decode<'de> for ServerAudioFormatPdu { read_padding!(src, 4); /* volume */ read_padding!(src, 4); /* pitch */ read_padding!(src, 2); /* DGramPort */ - let n_formats = src.read_u16(); + let n_formats = usize::from(src.read_u16()); read_padding!(src, 1); /* blockNo */ let version = Version::try_from(src.read_u16())?; read_padding!(src, 1); - let formats = (0..n_formats) - .map(|_| AudioFormat::decode(src)) + let formats = core::iter::repeat_with(|| AudioFormat::decode(src)) + .take(n_formats) .collect::>()?; Ok(Self { version, formats }) @@ -441,18 +445,17 @@ impl<'de> Decode<'de> for ClientAudioFormatPdu { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let flags = AudioFormatFlags::from_bits_truncate(src.read_u32()); - let volume = src.read_u32(); - let volume_left = (volume & 0xFFFF) as u16; - let volume_right = (volume >> 16) as u16; + let flags = AudioFormatFlags::from_bits_retain(src.read_u32()); + let volume_left = src.read_u16(); + let volume_right = src.read_u16(); let pitch = src.read_u32(); let dgram_port = src.read_u16_be(); - let n_formats = src.read_u16(); + let n_formats = usize::from(src.read_u16()); let _block_no = src.read_u8(); let version = Version::try_from(src.read_u16())?; read_padding!(src, 1); - let formats = (0..n_formats) - .map(|_| AudioFormat::decode(src)) + let formats = core::iter::repeat_with(|| AudioFormat::decode(src)) + .take(n_formats) .collect::>()?; Ok(Self { @@ -489,6 +492,10 @@ impl TryFrom for QualityMode { } impl From for u16 { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] fn from(mode: QualityMode) -> Self { mode as u16 } @@ -626,7 +633,7 @@ impl<'de> Decode<'de> for TrainingPdu { ensure_fixed_part_size!(in: src); let timestamp = src.read_u16(); - let len = src.read_u16() as usize; + let len = usize::from(src.read_u16()); let data = if len != 0 { if len < Self::FIXED_PART_SIZE + ServerAudioOutputPdu::FIXED_PART_SIZE { return Err(invalid_field_err!("TrainingPdu::wPackSize", "too small")); @@ -755,6 +762,16 @@ impl SndWavePdu { const NAME: &'static str = "SNDWAVE"; const FIXED_PART_SIZE: usize = 4 /* bPad */; + + fn decode(src: &mut ReadCursor<'_>, data_len: usize) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + read_padding!(src, 4); + ensure_size!(in: src, size: data_len); + let data = src.read_slice(data_len).into(); + + Ok(Self { data }) + } } impl Encode for SndWavePdu { @@ -778,18 +795,6 @@ impl Encode for SndWavePdu { } } -impl SndWavePdu { - fn decode(src: &mut ReadCursor<'_>, data_len: usize) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - read_padding!(src, 4); - ensure_size!(in: src, size: data_len); - let data = src.read_slice(data_len).into(); - - Ok(Self { data }) - } -} - // combines WaveInfoPdu + WavePdu #[derive(Debug, Clone, PartialEq, Eq)] pub struct WavePdu<'a> { @@ -841,7 +846,7 @@ impl Encode for WavePdu<'_> { impl WavePdu<'_> { fn decode(src: &mut ReadCursor<'_>, body_size: u16) -> DecodeResult { let info = WaveInfoPdu::decode(src)?; - let body_size = body_size as usize; + let body_size = usize::from(body_size); let data_len = body_size .checked_sub(info.size()) .ok_or_else(|| invalid_field_err!("Length", "WaveInfo body_size is too small"))?; @@ -925,6 +930,30 @@ impl WaveEncryptPdu { + 2 /* wFormatNo */ + 1 /* cBlockNo */ + 3 /* bPad */; + + fn decode(src: &mut ReadCursor<'_>, version: Version) -> DecodeResult { + ensure_fixed_part_size!(in: src); + + let timestamp = src.read_u16(); + let format_no = src.read_u16(); + let block_no = src.read_u8(); + read_padding!(src, 3); + let signature = if version >= Version::V5 { + ensure_size!(in: src, size: 8); + Some(src.read_array()) + } else { + None + }; + let data = src.read_remaining().into(); + + Ok(Self { + timestamp, + format_no, + block_no, + signature, + data, + }) + } } impl Encode for WaveEncryptPdu { @@ -950,38 +979,12 @@ impl Encode for WaveEncryptPdu { fn size(&self) -> usize { Self::FIXED_PART_SIZE .checked_add(self.signature.map_or(0, |_| 8)) - .unwrap() + .expect("never overflow") .checked_add(self.data.len()) .expect("never overflow") } } -impl WaveEncryptPdu { - fn decode(src: &mut ReadCursor<'_>, version: Version) -> DecodeResult { - ensure_fixed_part_size!(in: src); - - let timestamp = src.read_u16(); - let format_no = src.read_u16(); - let block_no = src.read_u8(); - read_padding!(src, 3); - let signature = if version >= Version::V5 { - ensure_size!(in: src, size: 8); - Some(src.read_array()) - } else { - None - }; - let data = src.read_remaining().into(); - - Ok(Self { - timestamp, - format_no, - block_no, - signature, - data, - }) - } -} - #[derive(Clone, PartialEq, Eq)] pub struct Wave2Pdu<'a> { pub timestamp: u16, @@ -1094,9 +1097,8 @@ impl<'de> Decode<'de> for VolumePdu { fn decode(src: &mut ReadCursor<'de>) -> DecodeResult { ensure_fixed_part_size!(in: src); - let volume = src.read_u32(); - let volume_left = (volume & 0xFFFF) as u16; - let volume_right = (volume >> 16) as u16; + let volume_left = src.read_u16(); + let volume_right = src.read_u16(); Ok(Self { volume_left, diff --git a/crates/ironrdp-rdpsnd/src/server.rs b/crates/ironrdp-rdpsnd/src/server.rs index 435b4c5769..6b72bdb5f3 100644 --- a/crates/ironrdp-rdpsnd/src/server.rs +++ b/crates/ironrdp-rdpsnd/src/server.rs @@ -1,6 +1,6 @@ -use ironrdp_core::{impl_as_any, Decode as _, ReadCursor}; +use ironrdp_core::{Decode as _, ReadCursor, impl_as_any}; use ironrdp_pdu::gcc::ChannelName; -use ironrdp_pdu::{decode_err, pdu_other_err, PduResult}; +use ironrdp_pdu::{PduResult, decode_err, pdu_other_err}; use ironrdp_svc::{CompressionCondition, SvcMessage, SvcProcessor, SvcProcessorMessages, SvcServerProcessor}; use tracing::{debug, error}; @@ -28,11 +28,94 @@ pub enum RdpsndServerMessage { Error(Box), } +/// A server-offered audio format that the client also advertised support for, +/// paired with the `wFormatNo` the client expects for it on the wire. +/// +/// The crate computes the set of these — the intersection of the server's +/// [`get_formats`] and the client's accepted formats — and hands it to +/// [`RdpsndServerHandler::choose_format`], which returns the one to stream. +/// +/// `wformat_no` is intentionally private and there is no public constructor: +/// a handler can neither build nor mutate a `NegotiatedFormat`, so the index +/// stamped onto every Wave/Wave2 PDU is always a valid position in the +/// client's own format list. This makes it impossible to emit an out-of-range +/// `wFormatNo` (which a compliant client rejects, silently dropping all audio +/// — the classic footgun of the old index-returning API). +/// +/// [`get_formats`]: RdpsndServerHandler::get_formats +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NegotiatedFormat { + /// The negotiated audio format (common to server and client). + format: pdu::AudioFormat, + /// Position of `format` in the client's Client Audio Formats list — the + /// `wFormatNo` the client resolves each wave against. Crate-owned. + wformat_no: u16, +} + +impl NegotiatedFormat { + /// The negotiated audio format — common to both server and client, and the + /// one the returned wave data should match. + pub fn format(&self) -> &pdu::AudioFormat { + &self.format + } + + /// Test-only accessor for the crate-private `wformat_no`, exposed for the + /// integration testsuite behind the private `__test` feature. Not a stable API. + #[cfg(feature = "__test")] + #[doc(hidden)] + pub fn wformat_no(&self) -> u16 { + self.wformat_no + } +} + +/// Handler for the server side of the Audio Output Virtual Channel (`RDPSND`). +/// +/// Implementations supply the list of audio formats the server offers, choose +/// which negotiated format to use once the client replies, and produce the +/// audio waves to stream (via [`RdpsndServer::wave`]). pub trait RdpsndServerHandler: Send + core::fmt::Debug { + /// The audio formats the server advertises in the Server Audio Formats and + /// Version PDU (MS-RDPEA 2.2.2.1). fn get_formats(&self) -> &[pdu::AudioFormat]; - fn start(&mut self, client_format: &ClientAudioFormatPdu) -> Option; + /// Select which format to stream, once the client has replied with the + /// formats it accepts. + /// + /// `common` is the set of formats from [`get_formats`] that the client also + /// advertised, in the server's preference order; each carries the + /// `wFormatNo` the client expects, so the crate — not the handler — owns + /// the index arithmetic and the MS-RDPEA rule that `wFormatNo` addresses + /// the *client's* list. `common` is never empty: when server and client + /// share no format, this method is not called and no audio is streamed. + /// + /// Return the [`NegotiatedFormat`] to stream (a reference borrowed from + /// `common`), or [`None`] to decline. Returning a borrow from `common` + /// — rather than an index or a constructed value — makes it impossible to + /// pick a format the client did not accept or to produce an invalid + /// `wFormatNo`. This is a pure selection step: any encoder/producer setup + /// belongs in [`start`], which the crate calls next with the chosen format. + /// + /// [`get_formats`]: RdpsndServerHandler::get_formats + /// [`start`]: RdpsndServerHandler::start + fn choose_format<'a>(&mut self, common: &'a [NegotiatedFormat]) -> Option<&'a NegotiatedFormat>; + + /// Begin streaming with the `format` just selected by [`choose_format`]. + /// + /// Called once per session, immediately after a successful + /// [`choose_format`]. This is the lifecycle hook: initialize encoder state, + /// spawn the producer, etc. Waves are then emitted via [`RdpsndServer::wave`]. + /// + /// Return `Err` if initialization fails (e.g. the encoder can't be created). + /// The crate then **declines the negotiated format** — exactly as if + /// [`choose_format`] had returned [`None`] — rather than leaving the channel + /// "negotiated" but silently producing no audio. The error is logged by the + /// crate. + /// + /// [`choose_format`]: RdpsndServerHandler::choose_format + fn start(&mut self, format: &NegotiatedFormat) -> Result<(), Box>; + /// Called when the audio stream is torn down (e.g. the client closed the + /// channel or the session ended). fn stop(&mut self); } @@ -94,7 +177,7 @@ impl RdpsndServer { data: vec![], }; Ok(RdpsndSvcMessages::new(vec![ - pdu::ServerAudioOutputPdu::Training(pdu).into() + pdu::ServerAudioOutputPdu::Training(pdu).into(), ])) } @@ -138,7 +221,7 @@ impl RdpsndServer { volume_right, }; Ok(RdpsndSvcMessages::new(vec![ - pdu::ServerAudioOutputPdu::Volume(pdu).into() + pdu::ServerAudioOutputPdu::Volume(pdu).into(), ])) } @@ -147,6 +230,52 @@ impl RdpsndServer { } } +/// Build the set of formats common to the server (`server_formats`, kept in the +/// server's preference order) and the client (`client_formats`), each tagged +/// with its `wFormatNo` — its index in the *client's* list, which is what the +/// client resolves waves against (MS-RDPEA). The result mirrors the server's +/// ordering so the handler can express preference simply by `get_formats` +/// order, while the `wFormatNo` always points into the client list. +#[cfg_attr(feature = "__test", visibility::make(pub))] +fn negotiate_formats( + server_formats: &[pdu::AudioFormat], + client_formats: &[pdu::AudioFormat], +) -> Vec { + server_formats + .iter() + .filter_map(|server_format| { + client_formats + .iter() + .position(|client_fmt| audio_format_eq(client_fmt, server_format)) + .and_then(|idx| u16::try_from(idx).ok()) + .map(|wformat_no| NegotiatedFormat { + format: server_format.clone(), + wformat_no, + }) + }) + .collect() +} + +/// Compare two audio formats for negotiation. The WAVEFORMATEX identity fields +/// — wave format tag, channel count, sample rate, bit depth — must match, and so +/// must the codec-specific extra-data blob (`data`). +/// +/// The two derived fields (`n_avg_bytes_per_sec`, `n_block_align`) are +/// deliberately ignored: they are computable from the others and a client may +/// legitimately not echo them back byte-for-byte. The `data` blob is a different +/// category, though — for codecs whose extra-format bytes carry real +/// configuration (AAC's HEAACWAVEINFO extra data is the clear case, MS-RDPEA +/// 2.2.2.1.1's `cbSize` + extra data), ignoring it could match two genuinely +/// incompatible formats, so it IS compared. +#[cfg_attr(feature = "__test", visibility::make(pub))] +fn audio_format_eq(a: &pdu::AudioFormat, b: &pdu::AudioFormat) -> bool { + a.format == b.format + && a.n_channels == b.n_channels + && a.n_samples_per_sec == b.n_samples_per_sec + && a.bits_per_sample == b.bits_per_sample + && a.data == b.data +} + impl_as_any!(RdpsndServer); impl SvcProcessor for RdpsndServer { @@ -194,8 +323,35 @@ impl SvcProcessor for RdpsndServer { return Ok(vec![]); }; let client_format = self.client_format.as_ref().expect("available in this state"); + // Formats common to server and client, in the server's + // preference order, each tagged with its wFormatNo (its + // position in the *client's* list). Keeping this in the crate + // means the handler never does index arithmetic and can't emit + // an out-of-range wFormatNo. + let common = negotiate_formats(self.handler.get_formats(), &client_format.formats); self.state = RdpsndState::Ready; - self.format_no = self.handler.start(client_format); + if common.is_empty() { + debug!("No audio format in common with the client; audio disabled"); + } else if let Some(chosen) = self.handler.choose_format(&common) { + // `chosen` borrows `common` (a local), not `self`, so the + // handler is free to borrow `&mut self` again for `start`. + let wformat_no = chosen.wformat_no; + // Commit the index BEFORE the `start` lifecycle hook: if `start` + // spawns a producer that emits a wave immediately, `wave()` must + // already see a valid `format_no` rather than racing an unset one. + self.format_no = Some(wformat_no); + if let Err(e) = self.handler.start(chosen) { + // Initialization failed (e.g. the encoder couldn't be + // created). Roll back to a cleanly *declined* state — the + // same outcome as `choose_format` returning `None` — instead + // of leaving the channel "negotiated" but silently producing + // no audio. + error!(error = %e, "rdpsnd handler failed to start; declining the negotiated format"); + self.format_no = None; + } + } else { + debug!("Handler declined every common audio format; audio disabled"); + } vec![] } RdpsndState::Ready => { diff --git a/crates/ironrdp-replay-client/Cargo.toml b/crates/ironrdp-replay-client/Cargo.toml index 3ba5472805..c079afe999 100644 --- a/crates/ironrdp-replay-client/Cargo.toml +++ b/crates/ironrdp-replay-client/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" readme = "README.md" description = "Utility tool to replay RDP graphics pipeline for debugging purposes" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true diff --git a/crates/ironrdp-server/CHANGELOG.md b/crates/ironrdp-server/CHANGELOG.md index 2c57beee72..6bc9421f45 100644 --- a/crates/ironrdp-server/CHANGELOG.md +++ b/crates/ironrdp-server/CHANGELOG.md @@ -6,6 +6,215 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.13.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.12.0...ironrdp-server-v0.13.0)] - 2026-07-10 + +### Security + +- [**breaking**] Send NetworkAutoDetect over the MCS message channel ([#1348](https://github.com/Devolutions/IronRDP/issues/1348)) ([8a1fd0118e](https://github.com/Devolutions/IronRDP/commit/8a1fd0118e0bac214c9050b6ca6b36a040046dd3)) + + Corrects Network Auto-Detect framing and routing to match MS-RDPBCGR by + moving it off the I/O channel slow-path Share Data PDUs and onto the MCS + message channel with the required Basic Security Header + (SEC_AUTODETECT_REQ / SEC_AUTODETECT_RSP). This aligns IronRDP with + mstsc/xfreerdp behavior and enables both connect-time and continuous + auto-detection to actually function. + +### Features + +- Expose NetworkAutoDetect RTT via a shared handle ([#1346](https://github.com/Devolutions/IronRDP/issues/1346)) ([481ea5d161](https://github.com/Devolutions/IronRDP/commit/481ea5d161964b06a08f0b1ace0a1efd11773b4a)) + + Exposes the server’s NetworkAutoDetect RTT measurement via a shared Arc handle so display backends can read a fresh RTT value even after run() takes ownership of the server. + +- Dispatch initiate_file_copy via ClipboardMessage ([#1388](https://github.com/Devolutions/IronRDP/issues/1388)) ([b6325f9ea6](https://github.com/Devolutions/IronRDP/commit/b6325f9ea6900a84643b4415f9ebc7b1010cf3cd)) + + Extends the CLIPRDR backend-facing API to properly support offering clipboard file lists (so later FileContentsRequests can be serviced) by introducing ClipboardMessage::SendInitiateFileCopy(Vec) and wiring it through the in-tree ClipboardMessage dispatchers. + +- Honor the client-requested desktop size ([#1373](https://github.com/Devolutions/IronRDP/issues/1373)) ([d471bd066f](https://github.com/Devolutions/IronRDP/commit/d471bd066f303df22f4767801fd97ecdbf527869)) + + Adds an opt-in server/acceptor knob to negotiate the RDP session desktop size using the client’s originally requested resolution (from GCC Client Core Data) so the server can start at the client’s native size without a Deactivation–Reactivation resize round trip. + +- Accept connections with TLS terminated at a lower layer ([#1281](https://github.com/Devolutions/IronRDP/issues/1281)) ([18bf75c7b3](https://github.com/Devolutions/IronRDP/commit/18bf75c7b3442881b42ee79b5f530ca97ab391ed)) + + Adds a way to run a single RDP connection over a byte stream whose + confidentiality is already provided by the embedder's transport, rather + than having ironrdp-server perform the inner TLS handshake itself when + X.224 selects PROTOCOL_SSL. + + + +## [[0.12.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.11.0...ironrdp-server-v0.12.0)] - 2026-06-05 + +### Features + +- Opt-in support for NSCodec via feature flag ([#1332](https://github.com/Devolutions/IronRDP/issues/1332)) ([54af8f677f](https://github.com/Devolutions/IronRDP/commit/54af8f677fde726e2734f7bb1b451f3099d63532)) + + Adds an opt-in implementation of the legacy RDP NSCodec encoder as a standalone crate, and wires it into `ironrdp-server` behind a feature flag so servers can serve NSCodec-only clients (notably macOS Microsoft Remote Desktop / Windows App) without default-build behavior changes. + +- Add CredentialValidator trait for server-side auth ([#1172](https://github.com/Devolutions/IronRDP/issues/1172)) ([8a3b126396](https://github.com/Devolutions/IronRDP/commit/8a3b12639632f58291442a292a89fc6e22f82985)) + +### Bug Fixes + +- Emit RGB-channel QOI for opaque captures so ironrdp-session can decode ([#1335](https://github.com/Devolutions/IronRDP/issues/1335)) ([8a9ee6268c](https://github.com/Devolutions/IronRDP/commit/8a9ee6268ccdb5704c2bb60bed6d2adf57761427)) + +### Build + +- [**breaking**] Update `ironrdp-displaycontrol`, `ironrdp-dvc`, and `ironrdp-echo` public dependencies + + + +## [[0.11.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.10.0...ironrdp-server-v0.11.0)] - 2026-06-01 + +### Features + +- Add clipboard data locking methods ([#1064](https://github.com/Devolutions/IronRDP/issues/1064)) ([58c3df84bb](https://github.com/Devolutions/IronRDP/commit/58c3df84bb9cafc8669315834cead35a71483c34)) + + Per MS-RDPECLIP sections 2.2.4.6 and 2.2.4.7, the Local + Clipboard Owner may lock the Shared Clipboard Owner's clipboard data before + requesting file contents to ensure data stability during multi-request transfers. + + This enables server implementations to safely request file data from + clients when handling clipboard paste operations. + +- Add request_file_contents method ([#1065](https://github.com/Devolutions/IronRDP/issues/1065)) ([c30fc35a28](https://github.com/Devolutions/IronRDP/commit/c30fc35a28d6218603c1662e98e8b3053bea3aa5)) + + Per MS-RDPECLIP section 2.2.5.3, the Local Clipboard Owner + sends File Contents Request PDU to retrieve file data from the Shared + Clipboard Owner during paste operations. + + This enables server implementations to request file contents from + clients, completing the bidirectional file transfer capability. + +- Add SendFileContentsResponse message variant ([#1066](https://github.com/Devolutions/IronRDP/issues/1066)) ([25f81337aa](https://github.com/Devolutions/IronRDP/commit/25f81337aa494af9a21f55f12ec27fd946465cbe)) + + Adds `SendFileContentsResponse` to `ClipboardMessage` enum, enabling + clipboard backends to signal when file data is ready to send via + `submit_file_contents()`. + + This provides the message-based interface pattern used consistently by + server implementations for clipboard operations. + +- Expose client display size to RdpServerDisplay ([#1083](https://github.com/Devolutions/IronRDP/issues/1083)) ([3cf570788d](https://github.com/Devolutions/IronRDP/commit/3cf570788d418ef0d83670c8581ddb61582237fe)) + + This allows the server implementation to handle the requested initial + client display size. The default implementation simply returns + `self.size()` so there's no change to existing behavior. + + Note that this method is also called during reactivations. + +- Add EGFX server integration with DVC bridge ([#1099](https://github.com/Devolutions/IronRDP/issues/1099)) ([4ba696c266](https://github.com/Devolutions/IronRDP/commit/4ba696c266c7065c93a691b9f818644fd471429b)) + +- Implement ECHO virtual channel ([#1109](https://github.com/Devolutions/IronRDP/issues/1109)) ([6f6496ad29](https://github.com/Devolutions/IronRDP/commit/6f6496ad29395099563d50417d6dfff623914ee6)) + +- Make run_connection generic over stream type ([#1181](https://github.com/Devolutions/IronRDP/issues/1181)) ([c30d853fa3](https://github.com/Devolutions/IronRDP/commit/c30d853fa34c2da02047b1dcb626f1009de2b61c)) + + Generalizes `RdpServer::run_connection` to accept arbitrary Tokio `AsyncRead + AsyncWrite` streams instead of a concrete `TcpStream`, enabling non-TCP transports (e.g., Unix sockets, VSOCK, in-process streams) to reuse the same server connection logic. + +- Add auto-detect RTT measurement ([#1177](https://github.com/Devolutions/IronRDP/issues/1177)) ([2515470fdb](https://github.com/Devolutions/IronRDP/commit/2515470fdb7187d20ee3fba8244b839efa4cbce4)) + + Adds server-side RTT measurement using the protocol-standard auto-detect + mechanism (MS-RDPBCGR 2.2.14). + +- IPv6 dual-stack and SO_REUSEADDR for run() ([#1187](https://github.com/Devolutions/IronRDP/issues/1187)) ([f10625cc80](https://github.com/Devolutions/IronRDP/commit/f10625cc806cc0ea9128c711df0dfd3ba8456b4f)) + +- Add ConnectionHandler trait for connection lifecycle hooks ([#1194](https://github.com/Devolutions/IronRDP/issues/1194)) ([5c08c7fe3d](https://github.com/Devolutions/IronRDP/commit/5c08c7fe3ded6f645cbddc53cdc0a02e8c45a037)) + +- Implement clipboard file transfer support ([#1166](https://github.com/Devolutions/IronRDP/issues/1166)) ([c98a8fb774](https://github.com/Devolutions/IronRDP/commit/c98a8fb7741986e9afef00cb5615250c963a7fa9)) + + Add end-to-end clipboard file transfer (upload and download) across the + CLIPRDR channel per MS-RDPECLIP. + +- Handle SuppressOutput / RefreshRectangle and expose state ([#1319](https://github.com/Devolutions/IronRDP/issues/1319)) ([aa7ff679b9](https://github.com/Devolutions/IronRDP/commit/aa7ff679b914dbbc9bfe137d7f4f26bea30d6323)) + +- Add pointer caching support to ironrdp-server ([1a6b4206d5](https://github.com/Devolutions/IronRDP/commit/1a6b4206d5f0fe3333da721adeaea3f7d2aa65cf)) + +### Bug Fixes + +- Make MultifragmentUpdate max_request_size configurable ([#1100](https://github.com/Devolutions/IronRDP/issues/1100)) ([d437b7e0b9](https://github.com/Devolutions/IronRDP/commit/d437b7e0b9a47f5b9246e24c76554df82f47670e)) + + The hardcoded `max_request_size` of 16,777,215 in the server's + MultifragmentUpdate capability causes mstsc to reject the connection (it + likely tries to allocate that buffer upfront). FreeRDP hit the same + problem and adjusted their value in FreeRDP/FreeRDP#1313. + + This adds a configurable `max_request_size` field to `RdpServerOptions` + with a default of 8 MB (matching what `ironrdp-connector` already uses + on the client side) and exposes it through the builder via + `with_max_request_size()`. + +- Tile bitmaps that exceed `MultifragmentUpdate` limit ([#1133](https://github.com/Devolutions/IronRDP/issues/1133)) ([db2f40b5b0](https://github.com/Devolutions/IronRDP/commit/db2f40b5b0af66a4c83e0e075e2814467c060b1d)) + + Split oversized dirty rects into horizontal strips that fit within `max_request_size` + before handing them to the bitmap encoder. + +- Skip bitmap updates that exceed bounds ([#1146](https://github.com/Devolutions/IronRDP/issues/1146)) ([2b97a95e6d](https://github.com/Devolutions/IronRDP/commit/2b97a95e6da8833e8a84e9f42960da91eee87cd6)) + + After a desktop resize, an RDP server can send a burst of bitmap updates + for the old resolution before its rendering pipeline has fully + transitioned to the new one. These updates reference coordinates beyond + the current image buffer in `DecodedImage`, causing index-out-of-bounds + panics in the `apply_*` methods. On the server side, the same stale + bitmaps can reach the encoder with dimensions exceeding the negotiated + desktop size, panicking in `NoneHandler::handle()`. + + This commit adds bounds checks at two levels: + - `DecodedImage::rect_fits()` guard at the entry of each `apply_*` + method, returning an empty rectangle when the update doesn't fit + - Encoder-level guard in `EncoderIter::next()` that drops + `BitmapUpdate`s exceeding the current desktop size + +- Replace all from_bits_truncate with from_bits_retain ([#1144](https://github.com/Devolutions/IronRDP/issues/1144)) ([353e30ddfd](https://github.com/Devolutions/IronRDP/commit/353e30ddfdaafc897db10b8663e364ef7775a7fd)) + + from_bits_truncate silently discards unknown bits, which breaks the + encode/decode round-trip property. This matters for fuzzing because a + PDU that decodes and re-encodes should produce identical bytes. + from_bits_retain preserves all bits, including those not yet defined in + our bitflags types, so the round-trip property holds. + +- Keep newest queued waves on per-batch overflow ([#1276](https://github.com/Devolutions/IronRDP/issues/1276)) ([6e8479763f](https://github.com/Devolutions/IronRDP/commit/6e8479763f2bcf0938bd4091e35fd5a322a787dd)) + +- Drop raw user_data dump from McsMessage::SendDataRequest debug log ([#1295](https://github.com/Devolutions/IronRDP/issues/1295)) ([424590ac76](https://github.com/Devolutions/IronRDP/commit/424590ac76f3f82de19b3d6d1aa7a0119f616fab)) + +### Build + +- Bump rayon from 1.11.0 to 1.12.0 ([#1235](https://github.com/Devolutions/IronRDP/issues/1235)) ([a5dab356e5](https://github.com/Devolutions/IronRDP/commit/a5dab356e5bc29cde2fdcd71b6d11fdf38a96a9f)) + + +## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.9.0...ironrdp-server-v0.10.0)] - 2025-12-18 + +### Bug Fixes + +- Send TLS close_notify during graceful RDP disconnect ([#1032](https://github.com/Devolutions/IronRDP/issues/1032)) ([a70e01d9c5](https://github.com/Devolutions/IronRDP/commit/a70e01d9c5675a7dffd65eda7428537c8ad6a857)) + + Add support for sending a proper TLS close_notify message when the RDP + client initiates a graceful disconnect PDU. + +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.8.0...ironrdp-server-v0.9.0)] - 2025-09-24 + +### Bug Fixes + +- [**breaking**] RdpServerDisplayUpdates::next_update now returns a Result + +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.7.0...ironrdp-server-v0.8.0)] - 2025-08-29 + +### Features + +- [**breaking**] Add server_codecs_capabilities() ([d3aaa43c23](https://github.com/Devolutions/IronRDP/commit/d3aaa43c23b252077b8720bb8ecfeceaaf7b7a7f)) + + Teach the server to support customizable codecs set. Use the same + logic/parsing as the client codecs configuration. + + Replace "with_remote_fx" with "codecs". + +- Add QOI image codec ([613fd51f26](https://github.com/Devolutions/IronRDP/commit/613fd51f26315d8212662c46f8e625c541e4bb59)) + + The Quite OK Image format ([1]) losslessly compresses images to a similar size + of PNG, while offering 20x-50x faster encoding and 3x-4x faster decoding. + +- Add QOIZ image codec ([87df67fdc7](https://github.com/Devolutions/IronRDP/commit/87df67fdc76ff4f39d4b83521e34bf3b5e2e73bb)) + + Add a new QOIZ codec for SetSurface command. The PDU data contains the same + data as the QOI codec, with zstd compression. + ## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.6.1...ironrdp-server-v0.7.0)] - 2025-07-08 ### Build @@ -119,7 +328,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.4.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-server-v0.3.1...ironrdp-server-v0.4.0)] - 2024-12-17 ### Features diff --git a/crates/ironrdp-server/Cargo.toml b/crates/ironrdp-server/Cargo.toml index 2c1ca031c2..ad7b14c122 100644 --- a/crates/ironrdp-server/Cargo.toml +++ b/crates/ironrdp-server/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-server" -version = "0.7.0" +version = "0.13.0" readme = "README.md" description = "Extendable skeleton for implementing custom RDP servers" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -21,6 +22,11 @@ helper = ["dep:x509-cert", "dep:rustls-pemfile"] rayon = ["dep:rayon"] qoi = ["dep:qoicoubeh", "ironrdp-pdu/qoi"] qoiz = ["dep:zstd-safe", "qoi", "ironrdp-pdu/qoiz"] +egfx = ["dep:ironrdp-egfx"] +# Opt-in NSCodec encoder. Off by default so consumers that don't need a legacy +# bitmap codec fallback (e.g., RemoteFX-only or H.264-capable clients) don't +# pay the extra build cost. +nscodec = ["dep:ironrdp-nscodec"] # Internal (PRIVATE!) features used to aid testing. # Don't rely on these whatsoever. They may disappear at any time. @@ -31,22 +37,25 @@ anyhow = "1.0" tokio = { version = "1", features = ["net", "macros", "sync", "rt"] } # public tokio-rustls = "0.26" # public async-trait = "0.1" -ironrdp-async = { path = "../ironrdp-async", version = "0.6" } -ironrdp-ainput = { path = "../ironrdp-ainput", version = "0.3" } -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5" } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public -ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.3" } # public -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.3" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.3" } # public -ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.6" } -ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.6" } # public -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.4" } # public -ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.5" } # public +ironrdp-async = { path = "../ironrdp-async", version = "0.10" } +ironrdp-ainput = { path = "../ironrdp-ainput", version = "0.8" } +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } +ironrdp-egfx = { path = "../ironrdp-egfx", version = "0.3", optional = true } +ironrdp-nscodec = { path = "../ironrdp-nscodec", version = "0.2", optional = true, features = ["encoder"] } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.7" } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.8" } # public +ironrdp-echo = { path = "../ironrdp-echo", version = "0.4" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public +ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.10", features = ["reqwest"] } +ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.10" } # public +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.9" } # public +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.9" } # public tracing = { version = "0.1", features = ["log"] } -x509-cert = { version = "0.2.5", optional = true } -rustls-pemfile = { version = "2.2.0", optional = true } -rayon = { version = "1.10.0", optional = true } +x509-cert = { version = "0.2", optional = true } +rustls-pemfile = { version = "2.2", optional = true } +rayon = { version = "1.12", optional = true } bytes = "1" visibility = { version = "0.1", optional = true } qoicoubeh = { version = "0.5", optional = true } diff --git a/crates/ironrdp-server/README.md b/crates/ironrdp-server/README.md index 82a817b193..8a82054c6b 100644 --- a/crates/ironrdp-server/README.md +++ b/crates/ironrdp-server/README.md @@ -26,4 +26,41 @@ Custom logic for your RDP server can be added by implementing these traits: This crate is part of the [IronRDP] project. +## Echo RTT probes (feature `echo`) + +Enable the `echo` feature to use the ECHO dynamic virtual channel (`MS-RDPEECO`) and measure round-trip time. + +```rust +use ironrdp_server::RdpServer; + +# async fn demo(mut server: RdpServer) -> anyhow::Result<()> { +// Grab and clone the shared handle before moving the server into a task. +let echo = server.echo_handle().clone(); + +let local = tokio::task::LocalSet::new(); +local + .run_until(async move { + let server_task = tokio::task::spawn_local(async move { server.run().await }); + + { + echo.send_request(b"ping".to_vec())?; + + for measurement in echo.take_measurements() { + println!( + "echo payload size={} rtt={:?}", + measurement.payload.len(), + measurement.round_trip_time + ); + } + } + + server_task.await??; + Ok::<(), anyhow::Error>(()) + }) + .await?; +# Ok(()) } +``` + +`send_request` queues a probe via the server event loop. If no client has opened the ECHO channel yet, the request is dropped. + [IronRDP]: https://github.com/Devolutions/IronRDP diff --git a/crates/ironrdp-server/src/autodetect.rs b/crates/ironrdp-server/src/autodetect.rs new file mode 100644 index 0000000000..4b75b445c4 --- /dev/null +++ b/crates/ironrdp-server/src/autodetect.rs @@ -0,0 +1,214 @@ +//! Server-side auto-detect (RTT measurement) per [MS-RDPBCGR 2.2.14]. +//! +//! The server periodically sends RTT Measure Request PDUs and records the +//! round-trip time from the client's response. Results are exposed via +//! [`AutoDetectManager::snapshot()`]. +//! +//! [MS-RDPBCGR 2.2.14]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dc672839-4f4e-40b1-a71c-cd6a959baa38 + +use std::collections::VecDeque; +use std::time::Instant; + +use ironrdp_pdu::rdp::autodetect::{AutoDetectRequest, AutoDetectResponse}; + +/// Number of RTT samples to retain for averaging. +const RTT_WINDOW_SIZE: usize = 8; + +/// Probes older than this are discarded as unresponsive. +pub(crate) const RTT_PROBE_MAX_AGE: core::time::Duration = core::time::Duration::from_secs(30); + +/// Server-side auto-detect state machine. +/// +/// Tracks outstanding RTT probes and computes round-trip statistics from +/// client responses. Call [`send_rtt_request()`](Self::send_rtt_request) to +/// generate a probe, then [`handle_response()`](Self::handle_response) when +/// the client replies. +pub struct AutoDetectManager { + next_sequence: u16, + pending_probes: Vec<(u16, Instant)>, + rtt_samples: VecDeque, +} + +impl AutoDetectManager { + pub fn new() -> Self { + Self { + next_sequence: 0, + pending_probes: Vec::new(), + rtt_samples: VecDeque::with_capacity(RTT_WINDOW_SIZE), + } + } + + /// Generate an RTT Measure Request PDU for continuous detection. + /// + /// The caller must encode and send the returned [`AutoDetectRequest`] as + /// a Share Data PDU on the IO channel. Timing information is tracked + /// internally by [`AutoDetectManager`]. + pub fn send_rtt_request(&mut self) -> AutoDetectRequest { + let seq = self.next_sequence; + self.next_sequence = seq.wrapping_add(1); + self.pending_probes.push((seq, Instant::now())); + AutoDetectRequest::rtt_continuous(seq) + } + + /// Process an RTT Measure Response from the client. + /// + /// Returns the measured RTT in milliseconds if the sequence number + /// matches an outstanding probe, or `None` if it was unexpected. + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "RTT in ms fits in u32 for any plausible network latency" + )] + pub fn handle_response(&mut self, response: &AutoDetectResponse) -> Option { + let AutoDetectResponse::RttResponse { sequence_number } = response else { + return None; + }; + + let idx = self.pending_probes.iter().position(|(s, _)| *s == *sequence_number)?; + let (_, sent_at) = self.pending_probes.remove(idx); + + let rtt_ms = sent_at.elapsed().as_millis() as u32; + + if self.rtt_samples.len() >= RTT_WINDOW_SIZE { + self.rtt_samples.pop_front(); + } + self.rtt_samples.push_back(rtt_ms); + + Some(rtt_ms) + } + + /// Get current RTT statistics, or `None` if no measurements yet. + pub fn snapshot(&self) -> Option { + if self.rtt_samples.is_empty() { + return None; + } + + let min = *self.rtt_samples.iter().min().unwrap_or(&0); + let max = *self.rtt_samples.iter().max().unwrap_or(&0); + let sum: u64 = self.rtt_samples.iter().map(|&v| u64::from(v)).sum(); + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "average of u32 samples fits in u32" + )] + let avg = (sum / self.rtt_samples.len() as u64) as u32; + + Some(RttSnapshot { + min_ms: min, + max_ms: max, + avg_ms: avg, + sample_count: self.rtt_samples.len(), + }) + } + + /// Number of outstanding probes awaiting response. + pub fn pending_count(&self) -> usize { + self.pending_probes.len() + } + + /// Discard probes older than the given threshold to prevent unbounded growth. + pub fn expire_stale_probes(&mut self, max_age: core::time::Duration) { + self.pending_probes.retain(|(_, sent_at)| sent_at.elapsed() < max_age); + } +} + +impl Default for AutoDetectManager { + fn default() -> Self { + Self::new() + } +} + +/// Snapshot of RTT measurement results. +#[derive(Debug, Clone, Copy)] +pub struct RttSnapshot { + /// Minimum observed RTT in milliseconds. + pub min_ms: u32, + /// Maximum observed RTT in milliseconds. + pub max_ms: u32, + /// Average RTT in milliseconds over the sample window. + pub avg_ms: u32, + /// Number of samples in the current window. + pub sample_count: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rtt_request_increments_sequence() { + let mut mgr = AutoDetectManager::new(); + let req1 = mgr.send_rtt_request(); + let req2 = mgr.send_rtt_request(); + assert_eq!(req1.sequence_number(), 0); + assert_eq!(req2.sequence_number(), 1); + assert_eq!(mgr.pending_count(), 2); + } + + #[test] + fn rtt_response_computes_latency() { + let mut mgr = AutoDetectManager::new(); + let req = mgr.send_rtt_request(); + + let response = AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }; + let rtt = mgr.handle_response(&response); + assert!(rtt.is_some(), "should match the outstanding probe"); + assert_eq!(mgr.pending_count(), 0); + } + + #[test] + fn unknown_sequence_returns_none() { + let mut mgr = AutoDetectManager::new(); + let _ = mgr.send_rtt_request(); + + let response = AutoDetectResponse::RttResponse { sequence_number: 999 }; + assert!(mgr.handle_response(&response).is_none()); + assert_eq!(mgr.pending_count(), 1, "original probe should remain"); + } + + #[test] + fn snapshot_returns_none_without_data() { + let mgr = AutoDetectManager::new(); + assert!(mgr.snapshot().is_none()); + } + + #[test] + fn snapshot_reflects_measurements() { + let mut mgr = AutoDetectManager::new(); + + for _ in 0..3 { + let req = mgr.send_rtt_request(); + let response = AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }; + let _ = mgr.handle_response(&response); + } + + let snap = mgr.snapshot().expect("should have data after 3 measurements"); + assert_eq!(snap.sample_count, 3); + assert!(snap.avg_ms < 100); + } + + #[test] + fn sequence_number_wraps() { + let mut mgr = AutoDetectManager::new(); + mgr.next_sequence = u16::MAX; + let req = mgr.send_rtt_request(); + assert_eq!(req.sequence_number(), u16::MAX); + + let req2 = mgr.send_rtt_request(); + assert_eq!(req2.sequence_number(), 0, "should wrap around"); + } + + #[test] + fn stale_probe_expiry() { + let mut mgr = AutoDetectManager::new(); + let _ = mgr.send_rtt_request(); + assert_eq!(mgr.pending_count(), 1); + + mgr.expire_stale_probes(core::time::Duration::ZERO); + assert_eq!(mgr.pending_count(), 0); + } +} diff --git a/crates/ironrdp-server/src/builder.rs b/crates/ironrdp-server/src/builder.rs index 4d43736785..0795e6001f 100644 --- a/crates/ironrdp-server/src/builder.rs +++ b/crates/ironrdp-server/src/builder.rs @@ -1,13 +1,17 @@ use core::net::SocketAddr; +use core::sync::atomic::{AtomicBool, AtomicU32}; +use std::sync::Arc; use anyhow::Result; -use ironrdp_pdu::rdp::capability_sets::{server_codecs_capabilities, BitmapCodecs}; +use ironrdp_pdu::rdp::capability_sets::{BitmapCodecs, server_codecs_capabilities}; use tokio_rustls::TlsAcceptor; use super::clipboard::CliprdrServerFactory; use super::display::{DesktopSize, RdpServerDisplay}; +#[cfg(feature = "egfx")] +use super::gfx::GfxServerFactory; use super::handler::{KeyboardEvent, MouseEvent, RdpServerInputHandler}; -use super::server::{RdpServer, RdpServerOptions, RdpServerSecurity}; +use super::server::{ConnectionHandler, CredentialValidator, RdpServer, RdpServerOptions, RdpServerSecurity}; use crate::{DisplayUpdate, RdpServerDisplayUpdates, SoundServerFactory}; pub struct WantsAddr {} @@ -27,10 +31,18 @@ pub struct BuilderDone { addr: SocketAddr, security: RdpServerSecurity, codecs: BitmapCodecs, + max_request_size: u32, handler: Box, display: Box, cliprdr_factory: Option>, sound_factory: Option>, + connection_handler: Option>, + credential_validator: Option>, + #[cfg(feature = "egfx")] + gfx_factory: Option>, + display_suppressed: Option>, + autodetect_rtt: Option>, + honor_client_desktop_size: bool, } pub struct RdpServerBuilder { @@ -41,15 +53,7 @@ impl RdpServerBuilder { pub fn new() -> Self { Self { state: WantsAddr {} } } -} -impl Default for RdpServerBuilder { - fn default() -> Self { - Self::new() - } -} - -impl RdpServerBuilder { #[expect(clippy::unused_self)] // ensuring state transition from WantsAddr pub fn with_addr(self, addr: impl Into) -> RdpServerBuilder { RdpServerBuilder { @@ -58,6 +62,12 @@ impl RdpServerBuilder { } } +impl Default for RdpServerBuilder { + fn default() -> Self { + Self::new() + } +} + impl RdpServerBuilder { pub fn with_no_security(self) -> RdpServerBuilder { RdpServerBuilder { @@ -125,7 +135,15 @@ impl RdpServerBuilder { display: Box::new(display), sound_factory: None, cliprdr_factory: None, - codecs: server_codecs_capabilities(&[]).unwrap(), + connection_handler: None, + credential_validator: None, + codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"), + max_request_size: RdpServerOptions::DEFAULT_MAX_REQUEST_SIZE, + #[cfg(feature = "egfx")] + gfx_factory: None, + display_suppressed: None, + autodetect_rtt: None, + honor_client_desktop_size: false, }, } } @@ -139,7 +157,15 @@ impl RdpServerBuilder { display: Box::new(NoopDisplay), sound_factory: None, cliprdr_factory: None, - codecs: server_codecs_capabilities(&[]).unwrap(), + connection_handler: None, + credential_validator: None, + codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"), + max_request_size: RdpServerOptions::DEFAULT_MAX_REQUEST_SIZE, + #[cfg(feature = "egfx")] + gfx_factory: None, + display_suppressed: None, + autodetect_rtt: None, + honor_client_desktop_size: false, }, } } @@ -156,23 +182,134 @@ impl RdpServerBuilder { self } + /// Configure EGFX (Graphics Pipeline Extension) for H.264 video streaming. + #[cfg(feature = "egfx")] + pub fn with_gfx_factory(mut self, gfx_factory: Option>) -> Self { + self.state.gfx_factory = gfx_factory; + self + } + pub fn with_bitmap_codecs(mut self, codecs: BitmapCodecs) -> Self { self.state.codecs = codecs; self } + /// Sets the [MultifragmentUpdate] maximum reassembly buffer size advertised + /// during capability exchange. + /// + /// Defaults to [`RdpServerOptions::DEFAULT_MAX_REQUEST_SIZE`] (8 MB). + /// + /// [MultifragmentUpdate]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/01717954-716a-424d-af35-28fb2b86df89 + pub fn with_max_request_size(mut self, max_request_size: u32) -> Self { + self.state.max_request_size = max_request_size; + self + } + + /// Set a handler for connection lifecycle events (accept filtering, + /// post-disconnect cleanup). + pub fn with_connection_handler(mut self, handler: Option>) -> Self { + self.state.connection_handler = handler; + self + } + + /// Share the server's "display suppressed" flag with the display + /// backend before construction. + /// + /// The flag is `true` while the connected client has sent + /// `SuppressOutput { desktop_rect: None }` (e.g., mstsc minimized). + /// Display backends that want to skip frame emission while the + /// client is minimized create one `Arc` in the + /// application, hand a clone to the display, and pass the same + /// `Arc` here so the server's per-connection PDU handler writes to + /// the same instance the backend reads. + /// + /// When this is not called, the server allocates its own internal + /// flag (still readable via [`RdpServer::display_suppressed_handle`]) + /// — useful when the backend can call `display_suppressed_handle()` + /// after construction to obtain a handle, rather than sharing one in. + pub fn with_display_suppressed_handle(mut self, handle: Arc) -> Self { + self.state.display_suppressed = Some(handle); + self + } + + /// Negotiate each session at the desktop size the client requests in its + /// Client Core Data, rather than the size reported by the display handler. + /// + /// The client's requested resolution is only carried in the GCC Client + /// Core Data of the connection handshake; the size echoed back in the + /// client's Confirm Active is the value it copied from the server's Demand + /// Active (per [MS-RDPBCGR] 2.2.1.13.2) and so cannot reveal what the + /// client asked for. With this enabled the acceptor adopts the requested + /// size (when within the protocol-legal range) before Demand Active is + /// sent, so the session starts at that size with no Deactivation- + /// Reactivation resize. The display handler observes the negotiated size + /// through [`RdpServerDisplay::request_initial_size`]. + /// + /// Defaults to `false`, enforcing the size reported by the display handler. + /// + /// # Precondition + /// + /// Only enable this with a [`RdpServerDisplay`] whose + /// [`request_initial_size`] actually adopts (or at least intersects) the + /// size it is given: the acceptor negotiates the client's size, but the + /// server still builds its framebuffer/encoder from the size the display + /// handler reports. A fixed-size handler that ignores the requested size + /// can produce a mismatch that drops the client. Leave this disabled when + /// the display handler serves a fixed framebuffer. + /// + /// [`request_initial_size`]: crate::RdpServerDisplay::request_initial_size + pub fn with_honor_client_desktop_size(mut self, honor: bool) -> Self { + self.state.honor_client_desktop_size = honor; + self + } + + /// Set a credential validator for TLS-mode connections. + /// + /// When set, credentials received from the client during + /// `SecureSettingsExchange` (`ClientInfoPdu`) are passed to this + /// validator before the session is established. Rejection or a backend + /// error closes the connection. Pass `None` (the default) to skip + /// validation entirely. + /// + /// Not used for CredSSP/Hybrid connections (those use pre-loaded + /// credentials for NTLM challenge-response). + pub fn with_credential_validator(mut self, validator: Option>) -> Self { + self.state.credential_validator = validator; + self + } + + /// Inject a shared NetworkAutoDetect RTT handle (milliseconds, `u32::MAX` + /// until the first measurement). The server writes the latest measured RTT + /// to the same instance the backend reads. When not called, the server + /// allocates its own (still readable via + /// [`RdpServer::autodetect_rtt_handle`]). The value stays `u32::MAX` unless + /// auto-detect is enabled via [`RdpServer::enable_autodetect`]. + pub fn with_autodetect_rtt_handle(mut self, handle: Arc) -> Self { + self.state.autodetect_rtt = Some(handle); + self + } + pub fn build(self) -> RdpServer { - RdpServer::new( + let mut server = RdpServer::new( RdpServerOptions { addr: self.state.addr, security: self.state.security, codecs: self.state.codecs, + max_request_size: self.state.max_request_size, + honor_client_desktop_size: self.state.honor_client_desktop_size, }, self.state.handler, self.state.display, self.state.sound_factory, self.state.cliprdr_factory, - ) + self.state.connection_handler, + #[cfg(feature = "egfx")] + self.state.gfx_factory, + self.state.display_suppressed, + self.state.autodetect_rtt, + ); + server.set_credential_validator(self.state.credential_validator); + server } } @@ -187,7 +324,7 @@ struct NoopDisplayUpdates; #[async_trait::async_trait] impl RdpServerDisplayUpdates for NoopDisplayUpdates { - async fn next_update(&mut self) -> Option { + async fn next_update(&mut self) -> Result> { let () = core::future::pending().await; unreachable!() } diff --git a/crates/ironrdp-server/src/capabilities.rs b/crates/ironrdp-server/src/capabilities.rs index 5a7cc8ea4b..2622c88b4d 100644 --- a/crates/ironrdp-server/src/capabilities.rs +++ b/crates/ironrdp-server/src/capabilities.rs @@ -11,7 +11,7 @@ pub(crate) fn capabilities(opts: &RdpServerOptions, size: DesktopSize) -> Vec Vec capability_sets::General { capability_sets::General { extra_flags: GeneralExtraFlags::FASTPATH_OUTPUT_SUPPORTED, + // Advertise that the server handles `SuppressOutput` and + // `RefreshRectangle` (per MS-RDPBCGR 2.2.7.1.1) — spec-compliant + // clients only send these PDUs when the server says it supports + // them. mstsc sends them regardless, but FreeRDP and others + // follow the spec; without both flags, those clients never + // benefit from the minimize→refocus backlog fix. + refresh_rect_support: true, + suppress_output_support: true, ..Default::default() } } @@ -80,10 +88,8 @@ fn virtual_channel_capabilities() -> capability_sets::VirtualChannel { } } -fn multifragment_update() -> capability_sets::MultifragmentUpdate { +fn multifragment_update(opts: &RdpServerOptions) -> capability_sets::MultifragmentUpdate { capability_sets::MultifragmentUpdate { - // FIXME(#318): use an acceptable value for msctc. - // What is the actual server max size? - max_request_size: 16_777_215, + max_request_size: opts.max_request_size, } } diff --git a/crates/ironrdp-server/src/display.rs b/crates/ironrdp-server/src/display.rs index c0b7c3b0dd..e4ebd267ff 100644 --- a/crates/ironrdp-server/src/display.rs +++ b/crates/ironrdp-server/src/display.rs @@ -25,10 +25,12 @@ pub enum DisplayUpdate { RGBAPointer(RGBAPointer), HidePointer, DefaultPointer, + CachedPointer(u16), } #[derive(Clone)] pub struct RGBAPointer { + pub cache_index: u16, pub width: u16, pub height: u16, pub hot_x: u16, @@ -50,6 +52,7 @@ impl core::fmt::Debug for RGBAPointer { #[derive(Debug, Clone)] pub struct ColorPointer { + pub cache_index: u16, pub width: u16, pub height: u16, pub hot_x: u16, @@ -243,7 +246,7 @@ pub trait RdpServerDisplayUpdates { /// This method MUST be cancellation safe because it is used in a /// `tokio::select!` statement. If some other branch completes first, it /// MUST be guaranteed that no data is lost. - async fn next_update(&mut self) -> Option; + async fn next_update(&mut self) -> Result>; } /// Display for an RDP server @@ -260,8 +263,8 @@ pub trait RdpServerDisplayUpdates { /// /// #[async_trait::async_trait] /// impl RdpServerDisplayUpdates for DisplayUpdates { -/// async fn next_update(&mut self) -> Option { -/// self.receiver.recv().await +/// async fn next_update(&mut self) -> anyhow::Result> { +/// Ok(self.receiver.recv().await) /// } /// } /// @@ -284,10 +287,16 @@ pub trait RdpServerDisplayUpdates { #[async_trait::async_trait] pub trait RdpServerDisplay: Send { /// This method should return the current size of the display. - /// Currently, there is no way for the client to negotiate resolution, - /// so the size returned by this method will be enforced. async fn size(&mut self) -> DesktopSize; + /// Request an initial size for the display. + /// + /// This method should return the negotiated display size. + async fn request_initial_size(&mut self, client_size: DesktopSize) -> DesktopSize { + debug!(?client_size, "Requesting initial size"); + self.size().await + } + /// Return a display updates receiver async fn updates(&mut self) -> Result>; diff --git a/crates/ironrdp-server/src/echo.rs b/crates/ironrdp-server/src/echo.rs new file mode 100644 index 0000000000..8caf69b3d1 --- /dev/null +++ b/crates/ironrdp-server/src/echo.rs @@ -0,0 +1,156 @@ +use core::time::Duration; +use std::collections::{BTreeMap, VecDeque}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Instant; + +use anyhow::{Context as _, Result, bail}; +use ironrdp_core::impl_as_any; +use ironrdp_dvc::{DvcMessage, DvcProcessor, DvcServerProcessor}; +use ironrdp_echo::server::EchoServer; +use ironrdp_pdu::PduResult; +use tokio::sync::mpsc; + +use crate::server::ServerEvent; + +#[derive(Debug, Clone)] +pub struct EchoRoundTripMeasurement { + pub payload: Vec, + pub round_trip_time: Duration, +} + +#[derive(Debug)] +pub enum EchoServerMessage { + SendRequest { payload: Vec }, +} + +impl core::fmt::Display for EchoServerMessage { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::SendRequest { payload } => write!(f, "SendRequest(size={})", payload.len()), + } + } +} + +#[derive(Debug, Default)] +struct EchoHandleState { + pending: BTreeMap, VecDeque>, + measurements: VecDeque, +} + +/// Shared handle for runtime ECHO requests and RTT measurements. +#[derive(Debug, Clone)] +pub struct EchoServerHandle { + sender: mpsc::UnboundedSender, + state: Arc>, +} + +impl EchoServerHandle { + pub(crate) fn new(sender: mpsc::UnboundedSender) -> Self { + Self { + sender, + state: Arc::new(Mutex::new(EchoHandleState::default())), + } + } + + /// Sends a runtime ECHO request. + /// + /// The payload must be at least one byte, as required by MS-RDPEECO section 3.1.5.1. + pub fn send_request(&self, payload: Vec) -> Result<()> { + if payload.is_empty() { + bail!("echoRequest payload must be at least one byte"); + } + + self.sender + .send(ServerEvent::Echo(EchoServerMessage::SendRequest { payload })) + .map_err(|_error| anyhow::anyhow!("send ECHO request event")) + } + + /// Drains collected RTT measurements. + pub fn take_measurements(&self) -> Vec { + let mut state = self.lock_state(); + state.measurements.drain(..).collect() + } + + pub(crate) fn on_request_sent(&self, payload: &[u8]) { + let mut state = self.lock_state(); + state + .pending + .entry(payload.to_vec()) + .or_default() + .push_back(Instant::now()); + } + + fn on_response(&self, payload: &[u8]) { + let mut state = self.lock_state(); + let Some(sent_at_queue) = state.pending.get_mut(payload) else { + return; + }; + + let Some(sent_at) = sent_at_queue.pop_front() else { + return; + }; + + if sent_at_queue.is_empty() { + state.pending.remove(payload); + } + + state.measurements.push_back(EchoRoundTripMeasurement { + payload: payload.to_vec(), + round_trip_time: sent_at.elapsed(), + }); + } + + fn lock_state(&self) -> MutexGuard<'_, EchoHandleState> { + match self.state.lock() { + Ok(state) => state, + Err(poisoned) => poisoned.into_inner(), + } + } +} + +/// DVC bridge for ECHO that tracks RTT on responses. +pub struct EchoDvcBridge { + inner: EchoServer, + handle: EchoServerHandle, +} + +impl EchoDvcBridge { + pub fn new(handle: EchoServerHandle) -> Self { + Self { + inner: EchoServer::new(), + handle, + } + } + + pub fn handle(&self) -> &EchoServerHandle { + &self.handle + } +} + +impl_as_any!(EchoDvcBridge); + +impl DvcProcessor for EchoDvcBridge { + fn channel_name(&self) -> &str { + self.inner.channel_name() + } + + fn start(&mut self, channel_id: u32) -> PduResult> { + self.inner.start(channel_id) + } + + fn process(&mut self, channel_id: u32, payload: &[u8]) -> PduResult> { + let messages = self.inner.process(channel_id, payload)?; + self.handle.on_response(payload); + Ok(messages) + } + + fn close(&mut self, channel_id: u32) { + self.inner.close(channel_id) + } +} + +impl DvcServerProcessor for EchoDvcBridge {} + +pub(crate) fn build_echo_request(payload: Vec) -> Result { + EchoServer::request_message(payload).context("build ECHO request message") +} diff --git a/crates/ironrdp-server/src/encoder/bitmap.rs b/crates/ironrdp-server/src/encoder/bitmap.rs index 9a1647d0b3..9ad364c87f 100644 --- a/crates/ironrdp-server/src/encoder/bitmap.rs +++ b/crates/ironrdp-server/src/encoder/bitmap.rs @@ -1,8 +1,10 @@ use core::num::NonZeroUsize; -use ironrdp_core::{invalid_field_err, Encode as _, EncodeResult, WriteCursor}; +use ironrdp_core::{Encode as _, WriteCursor, cast_int, cast_length, invalid_field_err}; use ironrdp_graphics::image_processing::PixelFormat; -use ironrdp_graphics::rdp6::{ABgrChannels, ARgbChannels, BgrAChannels, BitmapStreamEncoder, RgbAChannels}; +use ironrdp_graphics::rdp6::{ + ABgrChannels, ARgbChannels, BgrAChannels, BitmapEncodeError, BitmapStreamEncoder, RgbAChannels, +}; use ironrdp_pdu::bitmap::{self, BitmapData, BitmapUpdateData, Compression}; use ironrdp_pdu::geometry::InclusiveRectangle; @@ -17,88 +19,99 @@ pub(crate) struct BitmapEncoder { impl BitmapEncoder { pub(crate) fn new() -> Self { Self { - buffer: vec![0; u16::MAX as usize], + buffer: vec![0; usize::from(u16::MAX)], } } - pub(crate) fn encode(&mut self, bitmap: &BitmapUpdate, output: &mut [u8]) -> EncodeResult { + pub(crate) fn encode(&mut self, bitmap: &BitmapUpdate, output: &mut [u8]) -> Result { // FIXME: support non-multiple of 4 widths. // // It’s not clear how to achieve that yet, but generally, server uses multiple of 4-widths, // and client has surface capabilities, so this path is unlikely. - if bitmap.width.get() % 4 != 0 { - return Err(invalid_field_err!("bitmap", "Width must be a multiple of 4")); + if !bitmap.width.get().is_multiple_of(4) { + return Err(BitmapEncodeError::Encode(invalid_field_err!( + "bitmap", + "Width must be a multiple of 4" + ))); } - let bytes_per_pixel = usize::from(bitmap.format.bytes_per_pixel()); - let row_len = usize::from(bitmap.width.get()) * bytes_per_pixel; - let chunk_height = usize::from(u16::MAX) / row_len; + let bytes_per_pixel = u16::from(bitmap.format.bytes_per_pixel()); + let row_len = bitmap.width.get() * bytes_per_pixel; + let chunk_height = u16::MAX / row_len; let mut cursor = WriteCursor::new(output); let stride = bitmap.stride.get(); - let chunks = bitmap.data.chunks(stride * chunk_height); + let chunks = bitmap.data.chunks(stride * usize::from(chunk_height)); - let total = u16::try_from(chunks.size_hint().0).unwrap(); - BitmapUpdateData::encode_header(total, &mut cursor)?; + let total = cast_int!("chunks length lower bound", chunks.size_hint().0).map_err(BitmapEncodeError::Encode)?; + BitmapUpdateData::encode_header(total, &mut cursor).map_err(BitmapEncodeError::Encode)?; for (i, chunk) in chunks.enumerate() { - let height = chunk.len() / stride; - let top = usize::from(bitmap.y) + i * chunk_height; + let height = cast_int!("bitmap height", chunk.len() / stride).map_err(BitmapEncodeError::Encode)?; + let i: u16 = cast_int!("chunk idx", i).map_err(BitmapEncodeError::Encode)?; + let top = bitmap.y + i * chunk_height; - let encoder = BitmapStreamEncoder::new(NonZeroUsize::from(bitmap.width).get(), height); + let encoder = BitmapStreamEncoder::new(NonZeroUsize::from(bitmap.width).get(), usize::from(height)); let len = { let pixels = chunk .chunks(stride) - .map(|row| &row[..row_len]) + .map(|row| &row[..usize::from(row_len)]) .rev() - .flat_map(|row| row.chunks(bytes_per_pixel)); + .flat_map(|row| row.chunks(usize::from(bytes_per_pixel))); - Self::encode_iter(encoder, bitmap.format, pixels, self.buffer.as_mut_slice()) + Self::encode_iter(encoder, bitmap.format, pixels, self.buffer.as_mut_slice())? }; let data = BitmapData { rectangle: InclusiveRectangle { left: bitmap.x, - top: u16::try_from(top).unwrap(), + top, right: bitmap.x + bitmap.width.get() - 1, - bottom: u16::try_from(top + height - 1).unwrap(), + bottom: top + height - 1, }, width: u16::from(bitmap.width), - height: u16::try_from(height).unwrap(), + height, bits_per_pixel: u16::from(bitmap.format.bytes_per_pixel()) * 8, compression_flags: Compression::BITMAP_COMPRESSION, compressed_data_header: Some(bitmap::CompressedDataHeader { - main_body_size: u16::try_from(len).unwrap(), + main_body_size: cast_length!("main body size", len).map_err(BitmapEncodeError::Encode)?, scan_width: u16::from(bitmap.width), - uncompressed_size: u16::try_from(height * row_len).unwrap(), + uncompressed_size: height * row_len, }), bitmap_data: &self.buffer[..len], }; - data.encode(&mut cursor)?; + data.encode(&mut cursor).map_err(BitmapEncodeError::Encode)?; } Ok(cursor.pos()) } - fn encode_iter<'a, P>(mut encoder: BitmapStreamEncoder, format: PixelFormat, src: P, dst: &mut [u8]) -> usize + fn encode_iter<'a, P>( + mut encoder: BitmapStreamEncoder, + format: PixelFormat, + src: P, + dst: &mut [u8], + ) -> Result where P: Iterator + Clone, { - match format { + let written = match format { PixelFormat::ARgb32 | PixelFormat::XRgb32 => { - encoder.encode_pixels_stream::<_, ARgbChannels>(src, dst, true).unwrap() + encoder.encode_pixels_stream::<_, ARgbChannels>(src, dst, true)? } PixelFormat::RgbA32 | PixelFormat::RgbX32 => { - encoder.encode_pixels_stream::<_, RgbAChannels>(src, dst, true).unwrap() + encoder.encode_pixels_stream::<_, RgbAChannels>(src, dst, true)? } PixelFormat::ABgr32 | PixelFormat::XBgr32 => { - encoder.encode_pixels_stream::<_, ABgrChannels>(src, dst, true).unwrap() + encoder.encode_pixels_stream::<_, ABgrChannels>(src, dst, true)? } PixelFormat::BgrA32 | PixelFormat::BgrX32 => { - encoder.encode_pixels_stream::<_, BgrAChannels>(src, dst, true).unwrap() + encoder.encode_pixels_stream::<_, BgrAChannels>(src, dst, true)? } - } + }; + + Ok(written) } } diff --git a/crates/ironrdp-server/src/encoder/fast_path.rs b/crates/ironrdp-server/src/encoder/fast_path.rs index 90ed98e47f..e6e728fe41 100644 --- a/crates/ironrdp-server/src/encoder/fast_path.rs +++ b/crates/ironrdp-server/src/encoder/fast_path.rs @@ -13,6 +13,10 @@ const FASTPATH_HEADER_SIZE: usize = 6; reason = "Unfortunately, expect attribute doesn't work when above or after visibility::make attribute" )] #[allow(unreachable_pub)] +#[expect( + clippy::partial_pub_fields, + reason = "public field is not a part of the public API and is used by benchmarks" +)] #[cfg_attr(feature = "__bench", visibility::make(pub))] pub(crate) struct UpdateFragmenter { code: UpdateCode, @@ -105,7 +109,7 @@ impl UpdateFragmenter { #[cfg(test)] mod tests { - use ironrdp_core::{decode_cursor, ReadCursor}; + use ironrdp_core::{ReadCursor, decode_cursor}; use super::*; diff --git a/crates/ironrdp-server/src/encoder/mod.rs b/crates/ironrdp-server/src/encoder/mod.rs index 520cf97d81..d3f83c9e92 100644 --- a/crates/ironrdp-server/src/encoder/mod.rs +++ b/crates/ironrdp-server/src/encoder/mod.rs @@ -1,13 +1,15 @@ use core::fmt; use core::num::NonZeroU16; -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, anyhow}; use ironrdp_acceptor::DesktopSize; -use ironrdp_graphics::diff::{find_different_rects_sub, Rect}; +use ironrdp_graphics::diff::{Rect, find_different_rects_sub}; use ironrdp_pdu::encode_vec; use ironrdp_pdu::fast_path::UpdateCode; use ironrdp_pdu::geometry::ExclusiveRectangle; -use ironrdp_pdu::pointer::{ColorPointerAttribute, Point16, PointerAttribute, PointerPositionAttribute}; +use ironrdp_pdu::pointer::{ + CachedPointerAttribute, ColorPointerAttribute, Point16, PointerAttribute, PointerPositionAttribute, +}; use ironrdp_pdu::rdp::capability_sets::{CmdFlags, EntropyBits}; use ironrdp_pdu::surface_commands::{ExtendedBitmapDataPdu, SurfaceBitsPdu, SurfaceCommand}; use tracing::{debug, warn}; @@ -23,6 +25,7 @@ mod fast_path; pub(crate) mod rfx; pub(crate) use fast_path::*; +use ironrdp_graphics::rdp6::BitmapEncodeError; #[derive(Debug, Copy, Clone, PartialEq, Eq)] #[repr(u8)] @@ -30,6 +33,16 @@ enum CodecId { None = 0x0, } +impl CodecId { + #[expect( + clippy::as_conversions, + reason = "guarantees discriminant layout, and as is the only way to cast enum -> primitive" + )] + fn as_u8(self) -> u8 { + self as u8 + } +} + #[cfg_attr(feature = "__bench", visibility::make(pub))] #[derive(Debug)] pub(crate) struct UpdateEncoderCodecs { @@ -38,6 +51,9 @@ pub(crate) struct UpdateEncoderCodecs { qoi: Option, #[cfg(feature = "qoiz")] qoiz: Option, + /// `(codec_id, color_loss_level)` from the negotiated NsCodec capability. + #[cfg(feature = "nscodec")] + nscodec: Option<(u8, u8)>, } impl UpdateEncoderCodecs { @@ -49,6 +65,8 @@ impl UpdateEncoderCodecs { qoi: None, #[cfg(feature = "qoiz")] qoiz: None, + #[cfg(feature = "nscodec")] + nscodec: None, } } @@ -68,6 +86,14 @@ impl UpdateEncoderCodecs { pub(crate) fn set_qoiz(&mut self, qoiz: Option) { self.qoiz = qoiz } + + /// Record the negotiated NsCodec codec id and color-loss level so the + /// encoder selection path can build an `NsCodecHandler` for this session. + #[cfg(feature = "nscodec")] + #[cfg_attr(feature = "__bench", visibility::make(pub))] + pub(crate) fn set_nscodec(&mut self, nscodec: Option<(u8, u8)>) { + self.nscodec = nscodec + } } impl Default for UpdateEncoderCodecs { @@ -81,6 +107,10 @@ pub(crate) struct UpdateEncoder { desktop_size: DesktopSize, framebuffer: Option, bitmap_updater: Option, + /// Negotiated MultifragmentUpdate reassembly buffer size. Used to split + /// oversized bitmaps into strips that fit within the limit when sent as + /// uncompressed surface commands. + max_request_size: usize, } impl fmt::Debug for UpdateEncoder { @@ -93,33 +123,47 @@ impl fmt::Debug for UpdateEncoder { impl UpdateEncoder { #[cfg_attr(feature = "__bench", visibility::make(pub))] - pub(crate) fn new(desktop_size: DesktopSize, surface_flags: CmdFlags, codecs: UpdateEncoderCodecs) -> Self { + pub(crate) fn new( + desktop_size: DesktopSize, + surface_flags: CmdFlags, + codecs: UpdateEncoderCodecs, + max_request_size: u32, + ) -> Result { let bitmap_updater = if surface_flags.contains(CmdFlags::SET_SURFACE_BITS) { - let mut bitmap = BitmapUpdater::None(NoneHandler); - - if let Some((algo, id)) = codecs.remotefx { - bitmap = BitmapUpdater::RemoteFx(RemoteFxHandler::new(algo, id, desktop_size)); - } - - #[cfg(feature = "qoi")] - if let Some(id) = codecs.qoi { - bitmap = BitmapUpdater::Qoi(QoiHandler::new(id)); - } - #[cfg(feature = "qoiz")] - if let Some(id) = codecs.qoiz { - bitmap = BitmapUpdater::Qoiz(QoizHandler::new(id)); + match codecs { + #[cfg(feature = "qoiz")] + UpdateEncoderCodecs { qoiz: Some(id), .. } => { + BitmapUpdater::Qoiz(QoizHandler::new(id).context("failed to initialize qoiz handler")?) + } + #[cfg(feature = "qoi")] + UpdateEncoderCodecs { qoi: Some(id), .. } => BitmapUpdater::Qoi(QoiHandler::new(id)), + UpdateEncoderCodecs { + remotefx: Some((algo, id)), + .. + } => BitmapUpdater::RemoteFx(RemoteFxHandler::new(algo, id, desktop_size)), + // NSCodec is the lowest-priority codec because it predates + // RemoteFX and produces larger output. It's relevant mainly + // for clients (notably macOS Microsoft Remote Desktop / + // Windows App) whose legacy bitmap-codec list advertises + // only NSCodec — those clients would otherwise fall through + // to raw/RLE BitmapUpdate at much higher bandwidth. + #[cfg(feature = "nscodec")] + UpdateEncoderCodecs { + nscodec: Some((id, cll)), + .. + } => BitmapUpdater::NsCodec(NsCodecHandler::new(id, cll)), + _ => BitmapUpdater::None(NoneHandler), } - - bitmap } else { BitmapUpdater::Bitmap(BitmapHandler::new()) }; - Self { + Ok(Self { desktop_size, framebuffer: None, bitmap_updater: Some(bitmap_updater), - } + max_request_size: usize::try_from(max_request_size).context("max_request_size")?, + }) } #[cfg_attr(feature = "__bench", visibility::make(pub))] @@ -146,7 +190,7 @@ impl UpdateEncoder { y: ptr.hot_y, }; let color_pointer = ColorPointerAttribute { - cache_index: 0, + cache_index: ptr.cache_index, hot_spot, width: ptr.width, height: ptr.height, @@ -166,7 +210,7 @@ impl UpdateEncoder { y: ptr.hot_y, }; let ptr = ColorPointerAttribute { - cache_index: 0, + cache_index: ptr.cache_index, hot_spot, width: ptr.width, height: ptr.height, @@ -176,6 +220,11 @@ impl UpdateEncoder { Ok(UpdateFragmenter::new(UpdateCode::ColorPointer, encode_vec(&ptr)?)) } + fn cached_pointer(cache_index: u16) -> Result { + let ptr = CachedPointerAttribute { cache_index }; + Ok(UpdateFragmenter::new(UpdateCode::CachedPointer, encode_vec(&ptr)?)) + } + fn default_pointer() -> Result { Ok(UpdateFragmenter::new(UpdateCode::DefaultPointer, vec![])) } @@ -192,7 +241,7 @@ impl UpdateEncoder { // TODO: we may want to make it optional for servers that already provide damaged regions const USE_DIFFS: bool = true; - if let Some(Framebuffer { + let diffs = if let Some(Framebuffer { data, stride, width, @@ -219,7 +268,51 @@ impl UpdateEncoder { width: bitmap.width.get().into(), height: bitmap.height.get().into(), }] + }; + + // Subdivide diff rects whose uncompressed size would exceed the + // MultifragmentUpdate reassembly buffer. + let mut tiled = Vec::with_capacity(diffs.len()); + for rect in diffs { + if rect.width * rect.height * 4 <= self.max_request_size { + tiled.push(rect); + } else { + let rects = self.split_diff(rect); + tiled.extend(rects); + } + } + tiled + } + + /// Split a rect into tiles that fit within `max_request_size`. + /// Splits by height first, then by width within each horizontal strip. + fn split_diff(&self, rect: Rect) -> Vec { + let mut rects = Vec::new(); + + let max_height = (self.max_request_size / (rect.width * 4)).max(1); + let mut y = rect.y; + let y_end = rect.y + rect.height; + while y < y_end { + let h = (y_end - y).min(max_height); + // Width splitting is unlikely in practice (would require + // max_request_size < ~256 KB), but ensures correctness. + let max_width = (self.max_request_size / (h * 4)).max(1); + let mut x = rect.x; + let x_end = rect.x + rect.width; + while x < x_end { + let w = (x_end - x).min(max_width); + rects.push(Rect { + x, + y, + width: w, + height: h, + }); + x += max_width; + } + y += max_height; } + + rects } fn bitmap_update_framebuffer(&mut self, bitmap: BitmapUpdate, diffs: &[Rect]) { @@ -246,8 +339,7 @@ impl UpdateEncoder { let result = time_warn!("Encoding bitmap", 10, updater.handle(&bitmap)); (result, updater) }) - .await - .unwrap(); + .await?; self.bitmap_updater = Some(updater); @@ -283,6 +375,15 @@ impl EncoderIter<'_> { let res = match state { State::Start(update) => match update { DisplayUpdate::Bitmap(bitmap) => { + let ds = encoder.desktop_size; + if bitmap.x + bitmap.width.get() > ds.width || bitmap.y + bitmap.height.get() > ds.height { + debug!( + "Dropping bitmap update that exceeds desktop size: \ + bitmap ({}, {}) {}x{} vs desktop {}x{}", + bitmap.x, bitmap.y, bitmap.width, bitmap.height, ds.width, ds.height, + ); + continue; + } let diffs = encoder.bitmap_diffs(&bitmap); self.state = State::BitmapDiffs { diffs, bitmap, pos: 0 }; continue; @@ -292,6 +393,7 @@ impl EncoderIter<'_> { DisplayUpdate::ColorPointer(ptr) => UpdateEncoder::color_pointer(ptr), DisplayUpdate::HidePointer => UpdateEncoder::hide_pointer(), DisplayUpdate::DefaultPointer => UpdateEncoder::default_pointer(), + DisplayUpdate::CachedPointer(idx) => UpdateEncoder::cached_pointer(idx), DisplayUpdate::Resize(_) => return None, }, State::BitmapDiffs { diffs, bitmap, pos } => { @@ -301,12 +403,31 @@ impl EncoderIter<'_> { return None; }; let Rect { x, y, width, height } = *rect; - let Some(sub) = bitmap.sub( - u16::try_from(x).unwrap(), - u16::try_from(y).unwrap(), - NonZeroU16::new(u16::try_from(width).unwrap()).unwrap(), - NonZeroU16::new(u16::try_from(height).unwrap()).unwrap(), - ) else { + + let x = match u16::try_from(x) { + Ok(x) => x, + Err(_) => return Some(Err(anyhow!("invalid `x`: out of range integral conversion"))), + }; + let y = match u16::try_from(y) { + Ok(y) => y, + Err(_) => return Some(Err(anyhow!("invalid `y`: out of range integral conversion"))), + }; + let width = match u16::try_from(width) { + Ok(width) => match NonZeroU16::new(width) { + Some(width) => width, + None => return Some(Err(anyhow!("rectangle width cannot be zero"))), + }, + Err(_) => return Some(Err(anyhow!("invalid `width`: out of range integral conversion"))), + }; + let height = match u16::try_from(height) { + Ok(height) => match NonZeroU16::new(height) { + Some(height) => height, + None => return Some(Err(anyhow!("rectangle height cannot be zero"))), + }, + Err(_) => return Some(Err(anyhow!("invalid `height`: out of range integral conversion"))), + }; + + let Some(sub) = bitmap.sub(x, y, width, height) else { warn!("Failed to extract bitmap subregion"); return None; }; @@ -334,6 +455,8 @@ enum BitmapUpdater { Qoi(QoiHandler), #[cfg(feature = "qoiz")] Qoiz(QoizHandler), + #[cfg(feature = "nscodec")] + NsCodec(NsCodecHandler), } impl BitmapUpdater { @@ -346,6 +469,8 @@ impl BitmapUpdater { Self::Qoi(up) => up.handle(bitmap), #[cfg(feature = "qoiz")] Self::Qoiz(up) => up.handle(bitmap), + #[cfg(feature = "nscodec")] + Self::NsCodec(up) => up.handle(bitmap), } } @@ -370,7 +495,7 @@ impl BitmapUpdateHandler for NoneHandler { for row in bitmap.data.chunks(bitmap.stride.get()).rev() { data.extend_from_slice(&row[..stride]); } - set_surface(bitmap, CodecId::None as u8, &data) + set_surface(bitmap, CodecId::None.as_u8(), &data) } } @@ -398,13 +523,15 @@ impl BitmapUpdateHandler for BitmapHandler { let mut buffer = vec![0; bitmap.data.len() * 2]; // TODO: estimate bitmap encoded size let len = loop { match self.bitmap.encode(bitmap, buffer.as_mut_slice()) { - Err(e) => match e.kind() { - ironrdp_core::EncodeErrorKind::NotEnoughBytes { .. } => { - buffer.resize(buffer.len() * 2, 0); - debug!("encoder buffer resized to: {}", buffer.len() * 2); - } - - _ => Err(e).context("bitmap encode error")?, + Err(err) => match err { + BitmapEncodeError::Encode(e) => match e.kind() { + ironrdp_core::EncodeErrorKind::NotEnoughBytes { .. } => { + buffer.resize(buffer.len() * 2, 0); + debug!("encoder buffer resized to: {}", buffer.len() * 2); + } + _ => Err(e).context("bitmap encode error")?, + }, + BitmapEncodeError::Rle(e) => Err(e).context("bitmap RLE encode error")?, }, Ok(len) => break len, } @@ -495,15 +622,27 @@ impl fmt::Debug for QoizHandler { #[cfg(feature = "qoiz")] impl QoizHandler { - fn new(codec_id: u8) -> Self { + fn new(codec_id: u8) -> Result { let mut zctxt = zstd_safe::CCtx::default(); - zctxt.set_parameter(zstd_safe::CParameter::CompressionLevel(3)).unwrap(); + zctxt + .set_parameter(zstd_safe::CParameter::CompressionLevel(3)) + .map_err(|code| { + anyhow!( + "failed to set zstd compression level: {}", + zstd_safe::get_error_name(code) + ) + })?; zctxt .set_parameter(zstd_safe::CParameter::EnableLongDistanceMatching(true)) - .unwrap(); + .map_err(|code| { + anyhow!( + "failed to set zstd enable long distance matching: {}", + zstd_safe::get_error_name(code) + ) + })?; - Self { codec_id, zctxt } + Ok(Self { codec_id, zctxt }) } } @@ -525,7 +664,7 @@ impl BitmapUpdateHandler for QoizHandler { &mut inb, zstd_safe::zstd_sys::ZSTD_EndDirective::ZSTD_e_flush, ) - .map_err(|code| anyhow::anyhow!("failed to zstd compress: {}", zstd_safe::get_error_name(code)))?; + .map_err(|code| anyhow!("failed to Zstd compress: {}", zstd_safe::get_error_name(code)))?; if res == 0 { break; } @@ -537,18 +676,61 @@ impl BitmapUpdateHandler for QoizHandler { } } +#[cfg(feature = "nscodec")] +#[derive(Clone, Debug)] +struct NsCodecHandler { + codec_id: u8, + color_loss_level: u8, +} + +#[cfg(feature = "nscodec")] +impl NsCodecHandler { + fn new(codec_id: u8, color_loss_level: u8) -> Self { + Self { + codec_id, + color_loss_level, + } + } +} + +#[cfg(feature = "nscodec")] +impl BitmapUpdateHandler for NsCodecHandler { + fn handle(&mut self, bitmap: &BitmapUpdate) -> Result { + let data = ironrdp_nscodec::encoder::encode( + &bitmap.data, + bitmap.width.get(), + bitmap.height.get(), + bitmap.stride.get(), + bitmap.format, + self.color_loss_level, + ); + set_surface(bitmap, self.codec_id, &data) + } +} + #[cfg(feature = "qoi")] fn qoi_encode(bitmap: &BitmapUpdate) -> Result> { use ironrdp_graphics::image_processing::PixelFormat::*; + // Map every 4-byte input — whether it nominally has an alpha byte or + // an "X" filler — to the 3-channel-output `*x` variant of + // `RawChannels`. The qoi crate selects `Channels::Rgb` vs + // `Channels::Rgba` for the QOI header from this enum: `*x` and `*r/g/b` + // produce `Rgb`; `*a` produces `Rgba`. The `ironrdp-session` NSCodec- + // free decode path in `fast_path.rs::qoi_apply` only supports + // `Channels::Rgb` and explicitly drops `Channels::Rgba` frames with + // `WARN: Unsupported RGBA QOI data`, so the previous "honest" mapping + // (`BgrA32 -> Bgra`, etc.) produced output that no IronRDP client + // could decode — every QOI session rendered a blank screen. + // + // Server-side bitmap captures are functionally opaque (the alpha byte + // is either always 0xFF or treated as filler), so discarding it is + // safe and matches what every successful legacy bitmap path + // already does. let raw_channels = match bitmap.format { - ARgb32 => qoi::RawChannels::Argb, - XRgb32 => qoi::RawChannels::Xrgb, - ABgr32 => qoi::RawChannels::Abgr, - XBgr32 => qoi::RawChannels::Xbgr, - BgrA32 => qoi::RawChannels::Bgra, - BgrX32 => qoi::RawChannels::Bgrx, - RgbA32 => qoi::RawChannels::Rgba, - RgbX32 => qoi::RawChannels::Rgbx, + ARgb32 | XRgb32 => qoi::RawChannels::Xrgb, + ABgr32 | XBgr32 => qoi::RawChannels::Xbgr, + BgrA32 | BgrX32 => qoi::RawChannels::Bgrx, + RgbA32 | RgbX32 => qoi::RawChannels::Rgbx, }; let enc = qoi::EncoderBuilder::new(&bitmap.data, bitmap.width.get().into(), bitmap.height.get().into()) .stride(bitmap.stride.get()) diff --git a/crates/ironrdp-server/src/encoder/rfx.rs b/crates/ironrdp-server/src/encoder/rfx.rs index 4f9cf2894f..973c859858 100644 --- a/crates/ironrdp-server/src/encoder/rfx.rs +++ b/crates/ironrdp-server/src/encoder/rfx.rs @@ -1,14 +1,16 @@ +use std::io; + use ironrdp_acceptor::DesktopSize; -use ironrdp_core::{cast_length, other_err, Encode as _, EncodeResult}; +use ironrdp_core::{Encode as _, EncodeResult, cast_int, cast_length, other_err}; use ironrdp_graphics::color_conversion::to_64x64_ycbcr_tile; use ironrdp_graphics::rfx_encode_component; use ironrdp_graphics::rlgr::RlgrError; +use ironrdp_pdu::WriteCursor; use ironrdp_pdu::codecs::rfx::{ self, Block, ChannelsPdu, CodecChannel, CodecVersionsPdu, FrameBeginPdu, FrameEndPdu, OperatingMode, Quant, RegionPdu, RfxChannel, SyncPdu, TileSetPdu, }; use ironrdp_pdu::rdp::capability_sets::EntropyBits; -use ironrdp_pdu::WriteCursor; use crate::BitmapUpdate; @@ -164,8 +166,8 @@ impl<'a> UpdateEncoder<'a> { y_quant_index: 0, cb_quant_index: 0, cr_quant_index: 0, - x: u16::try_from(tile_x).unwrap(), - y: u16::try_from(tile_y).unwrap(), + x: cast_int!("tile_x", tile_x)?, + y: cast_int!("tile_y", tile_y)?, y_data, cb_data, cr_data, @@ -186,15 +188,18 @@ impl<'a> UpdateEncoder<'a> { let x = tile_x * 64; let y = tile_y * 64; - let tile_width = core::cmp::min(width - x, 64); - let tile_height = core::cmp::min(height - y, 64); + let tile_width = u32::try_from(core::cmp::min(width - x, 64)).expect("can always fit in u32"); + let tile_height = u32::try_from(core::cmp::min(height - y, 64)).expect("can always fit in u32"); let stride = self.bitmap.stride.get(); let input = &self.bitmap.data[y * stride + x * bpp..]; + let stride = u32::try_from(stride).map_err(io::Error::other)?; let y = &mut [0i16; 4096]; let cb = &mut [0i16; 4096]; let cr = &mut [0i16; 4096]; - to_64x64_ycbcr_tile(input, tile_width, tile_height, stride, self.bitmap.format, y, cb, cr); + + to_64x64_ycbcr_tile(input, tile_width, tile_height, stride, self.bitmap.format, y, cb, cr) + .map_err(RlgrError::Yuv)?; let (y_data, buf) = buf.split_at_mut(4096); let (cb_data, cr_data) = buf.split_at_mut(4096); @@ -215,6 +220,7 @@ impl<'a> UpdateEncoder<'a> { } #[cfg(feature = "__bench")] +#[expect(clippy::missing_panics_doc, reason = "panics in benches are allowed")] pub(crate) mod bench { use super::*; @@ -227,12 +233,13 @@ pub(crate) mod bench { ) { let (enc, mut data) = UpdateEncoder::new(bitmap, quant.clone(), algo); - enc.encode_tile(tile_x, tile_y, &mut data.0).unwrap(); + enc.encode_tile(tile_x, tile_y, &mut data.0) + .expect("cannot propagate error in benchmark"); } pub fn rfx_enc(bitmap: &BitmapUpdate, quant: &Quant, algo: rfx::EntropyAlgorithm) { let (enc, mut data) = UpdateEncoder::new(bitmap, quant.clone(), algo); - enc.encode(&mut data).unwrap(); + enc.encode(&mut data).expect("cannot propagate error in benchmark"); } } diff --git a/crates/ironrdp-server/src/gfx.rs b/crates/ironrdp-server/src/gfx.rs new file mode 100644 index 0000000000..5663768d4e --- /dev/null +++ b/crates/ironrdp-server/src/gfx.rs @@ -0,0 +1,108 @@ +//! EGFX (Graphics Pipeline Extension) server integration. +//! +//! Provides the bridge between `ironrdp-egfx`'s `GraphicsPipelineServer` and +//! `ironrdp-server`'s `RdpServer`, enabling H.264 video streaming via DVC. +//! +//! The bridge pattern (`GfxDvcBridge`) wraps an `Arc>` +//! so the display handler can call `send_avc420_frame()` proactively while the +//! DVC infrastructure handles client messages (capability negotiation, frame acks). + +use std::sync::{Arc, Mutex}; + +use ironrdp_core::impl_as_any; +use ironrdp_dvc::{DvcMessage, DvcProcessor, DvcServerProcessor}; +use ironrdp_egfx::server::{GraphicsPipelineHandler, GraphicsPipelineServer}; +use ironrdp_pdu::PduResult; +use ironrdp_svc::SvcMessage; + +use crate::server::ServerEventSender; + +/// Shared handle to a `GraphicsPipelineServer`. +/// +/// Uses `std::sync::Mutex` (not tokio) because `DvcProcessor` trait methods +/// are synchronous and cannot hold async locks. +pub type GfxServerHandle = Arc>; + +/// Factory for creating EGFX graphics pipeline handlers. +/// +/// Implements `ServerEventSender` so the factory can signal the server event loop +/// when EGFX frames are ready to be drained and sent. +pub trait GfxServerFactory: ServerEventSender + Send { + /// Create a handler for EGFX callbacks (caps negotiation, frame acks). + fn build_gfx_handler(&self) -> Box; + + /// Create a bridge and shared server handle for proactive frame sending. + /// + /// When returning `Some`, the bridge is registered with DrdynvcServer for + /// client messages, and the handle is available for direct frame submission. + /// Returns `None` by default, falling back to `build_gfx_handler()`. + fn build_server_with_handle(&self) -> Option<(GfxDvcBridge, GfxServerHandle)> { + None + } +} + +/// DVC bridge wrapping a shared `GraphicsPipelineServer`. +/// +/// Delegates all `DvcProcessor` methods to the inner server through a mutex, +/// enabling shared access from both the DVC layer and the display handler. +pub struct GfxDvcBridge { + inner: GfxServerHandle, +} + +impl GfxDvcBridge { + pub fn new(server: GfxServerHandle) -> Self { + Self { inner: server } + } + + pub fn server(&self) -> &GfxServerHandle { + &self.inner + } +} + +impl_as_any!(GfxDvcBridge); + +impl DvcProcessor for GfxDvcBridge { + fn channel_name(&self) -> &str { + ironrdp_egfx::CHANNEL_NAME + } + + fn start(&mut self, channel_id: u32) -> PduResult> { + self.inner + .lock() + .expect("GfxServerHandle mutex poisoned") + .start(channel_id) + } + + fn process(&mut self, channel_id: u32, payload: &[u8]) -> PduResult> { + self.inner + .lock() + .expect("GfxServerHandle mutex poisoned") + .process(channel_id, payload) + } + + fn close(&mut self, channel_id: u32) { + self.inner + .lock() + .expect("GfxServerHandle mutex poisoned") + .close(channel_id) + } +} + +impl DvcServerProcessor for GfxDvcBridge {} + +/// Message for routing EGFX PDUs to the wire via `ServerEvent`. +#[derive(Debug)] +pub enum EgfxServerMessage { + /// Pre-encoded DVC messages from `GraphicsPipelineServer::drain_output()`. + SendMessages { messages: Vec }, +} + +impl core::fmt::Display for EgfxServerMessage { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::SendMessages { messages } => { + write!(f, "SendMessages(count={})", messages.len()) + } + } + } +} diff --git a/crates/ironrdp-server/src/handler.rs b/crates/ironrdp-server/src/handler.rs index a1497167d1..eae6e3300f 100644 --- a/crates/ironrdp-server/src/handler.rs +++ b/crates/ironrdp-server/src/handler.rs @@ -4,7 +4,7 @@ use ironrdp_pdu::input::mouse::PointerFlags; use ironrdp_pdu::input::mouse_rel::PointerRelFlags; use ironrdp_pdu::input::mouse_x::PointerXFlags; use ironrdp_pdu::input::sync::SyncToggleFlags; -use ironrdp_pdu::input::{scan_code, unicode, MousePdu, MouseRelPdu, MouseXPdu}; +use ironrdp_pdu::input::{MousePdu, MouseRelPdu, MouseXPdu, scan_code, unicode}; /// Keyboard Event /// @@ -97,9 +97,14 @@ impl From<(u16, fast_path::KeyboardFlags)> for KeyboardEvent { } impl From<(u16, scan_code::KeyboardFlags)> for KeyboardEvent { - #[expect(clippy::cast_possible_truncation)] // we are actually truncating the value + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "we are truncating the value on purpose" + )] fn from((key, flags): (u16, scan_code::KeyboardFlags)) -> Self { let extended = flags.contains(scan_code::KeyboardFlags::EXTENDED); + if flags.contains(scan_code::KeyboardFlags::RELEASE) { KeyboardEvent::Released { code: key as u8, @@ -131,9 +136,13 @@ impl From for KeyboardEvent { } impl From for KeyboardEvent { - #[expect(clippy::cast_possible_truncation)] // we are actually truncating the value + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "we are truncating the value on purpose" + )] fn from(value: SyncToggleFlags) -> Self { - KeyboardEvent::Synchronize(SynchronizeFlags::from_bits_truncate(value.bits() as u8)) + KeyboardEvent::Synchronize(SynchronizeFlags::from_bits_retain(value.bits() as u8)) } } diff --git a/crates/ironrdp-server/src/helper.rs b/crates/ironrdp-server/src/helper.rs index 38b76fa312..6d4055715a 100644 --- a/crates/ironrdp-server/src/helper.rs +++ b/crates/ironrdp-server/src/helper.rs @@ -7,8 +7,7 @@ use anyhow::Context as _; use rustls_pemfile::{certs, pkcs8_private_keys}; use tokio_rustls::rustls::pki_types::pem::PemObject as _; use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; -use tokio_rustls::rustls::{self}; -use tokio_rustls::TlsAcceptor; +use tokio_rustls::{TlsAcceptor, rustls}; pub struct TlsIdentityCtx { pub certs: Vec>, diff --git a/crates/ironrdp-server/src/lib.rs b/crates/ironrdp-server/src/lib.rs index bddb9a1a50..505d07a2f7 100644 --- a/crates/ironrdp-server/src/lib.rs +++ b/crates/ironrdp-server/src/lib.rs @@ -6,24 +6,38 @@ pub use {tokio, tokio_rustls}; mod macros; +pub mod autodetect; mod builder; mod capabilities; mod clipboard; mod display; +mod echo; mod encoder; +#[cfg(feature = "egfx")] +mod gfx; mod handler; #[cfg(feature = "helper")] mod helper; mod server; mod sound; -pub use clipboard::*; -pub use display::*; -pub use handler::*; +pub use clipboard::CliprdrServerFactory; +pub use display::{ + BitmapUpdate, ColorPointer, DesktopSize, DisplayUpdate, Framebuffer, PixelFormat, RGBAPointer, RdpServerDisplay, + RdpServerDisplayUpdates, +}; +pub use echo::{EchoDvcBridge, EchoRoundTripMeasurement, EchoServerHandle, EchoServerMessage}; +#[cfg(feature = "egfx")] +pub use gfx::{EgfxServerMessage, GfxDvcBridge, GfxServerFactory, GfxServerHandle}; +pub use handler::{KeyboardEvent, MouseEvent, RdpServerInputHandler}; #[cfg(feature = "helper")] -pub use helper::*; -pub use server::*; -pub use sound::*; +pub use helper::TlsIdentityCtx; +pub use server::{ + ConnectionHandler, CredentialDecision, CredentialValidationError, CredentialValidator, Credentials, + ExactMatchCredentialValidator, PostConnectionAction, RdpServer, RdpServerOptions, RdpServerSecurity, ServerEvent, + ServerEventSender, TransportTls, +}; +pub use sound::{RdpsndServerHandler, RdpsndServerMessage, SoundServerFactory}; #[cfg(feature = "__bench")] pub mod bench { diff --git a/crates/ironrdp-server/src/server.rs b/crates/ironrdp-server/src/server.rs index 190d6a816f..612a629f3d 100644 --- a/crates/ironrdp-server/src/server.rs +++ b/crates/ironrdp-server/src/server.rs @@ -1,48 +1,248 @@ +use core::fmt; use core::net::SocketAddr; +use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use core::time::Duration; use std::rc::Rc; use std::sync::Arc; -use anyhow::{anyhow, bail, Context as _, Result}; +use anyhow::{Context as _, Result, bail}; use ironrdp_acceptor::{Acceptor, AcceptorResult, BeginResult, DesktopSize}; use ironrdp_async::Framed; -use ironrdp_cliprdr::backend::ClipboardMessage; use ironrdp_cliprdr::CliprdrServer; +use ironrdp_cliprdr::backend::ClipboardMessage; use ironrdp_core::{decode, encode_vec, impl_as_any}; use ironrdp_displaycontrol::pdu::DisplayControlMonitorLayout; use ironrdp_displaycontrol::server::{DisplayControlHandler, DisplayControlServer}; -use ironrdp_pdu::input::fast_path::{FastPathInput, FastPathInputEvent}; +use ironrdp_dvc as dvc; use ironrdp_pdu::input::InputEventPdu; +use ironrdp_pdu::input::fast_path::{FastPathInput, FastPathInputEvent}; use ironrdp_pdu::mcs::{SendDataIndication, SendDataRequest}; use ironrdp_pdu::rdp::capability_sets::{BitmapCodecs, CapabilitySet, CmdFlags, CodecProperty, GeneralExtraFlags}; pub use ironrdp_pdu::rdp::client_info::Credentials; use ironrdp_pdu::rdp::headers::{ServerDeactivateAll, ShareControlPdu}; +use ironrdp_pdu::rdp::server_error_info::{ErrorInfo, ProtocolIndependentCode, ServerSetErrorInfoPdu}; use ironrdp_pdu::x224::X224; -use ironrdp_pdu::{decode_err, mcs, nego, rdp, Action, PduResult}; -use ironrdp_svc::{server_encode_svc_messages, StaticChannelId, StaticChannelSet, SvcProcessor}; -use ironrdp_tokio::{split_tokio_framed, unsplit_tokio_framed, FramedRead, FramedWrite, TokioFramed}; +use ironrdp_pdu::{Action, PduResult, decode_err, mcs, nego, rdp}; +use ironrdp_rdpsnd as rdpsnd; +use ironrdp_svc::{ChannelFlags, StaticChannelId, StaticChannelSet, SvcProcessor, server_encode_svc_messages}; +use ironrdp_tokio::{FramedRead, FramedWrite, TokioFramed, split_tokio_framed, unsplit_tokio_framed}; use rdpsnd::server::{RdpsndServer, RdpsndServerMessage}; -use tokio::io::{AsyncRead, AsyncWrite}; -use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _}; +use tokio::net::TcpSocket; +use tokio::sync::{Mutex, mpsc, oneshot}; use tokio::task; use tokio_rustls::TlsAcceptor; use tracing::{debug, error, trace, warn}; -use {ironrdp_dvc as dvc, ironrdp_rdpsnd as rdpsnd}; +use crate::autodetect::{AutoDetectManager, RttSnapshot}; use crate::clipboard::CliprdrServerFactory; use crate::display::{DisplayUpdate, RdpServerDisplay}; +use crate::echo::{EchoDvcBridge, EchoServerHandle, EchoServerMessage, build_echo_request}; use crate::encoder::{UpdateEncoder, UpdateEncoderCodecs}; +#[cfg(feature = "egfx")] +use crate::gfx::{EgfxServerMessage, GfxServerFactory}; use crate::handler::RdpServerInputHandler; -use crate::{builder, capabilities, SoundServerFactory}; +use crate::{SoundServerFactory, builder, capabilities}; + +/// TCP listen backlog size for the RDP server socket. +const LISTENER_BACKLOG: u32 = 1024; + +/// Action to take after a client disconnects. +/// +/// Returned by [`ConnectionHandler::on_disconnected`] to control whether +/// the server continues accepting new connections or shuts down. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PostConnectionAction { + /// Continue accepting new connections. + Continue, + /// Stop the accept loop and return from [`RdpServer::run`]. + Stop, +} + +/// Hooks for connection lifecycle events in [`RdpServer::run`]. +/// +/// Implement this trait to add pre-accept filtering (rate limiting, +/// IP allowlists) and post-disconnect logic (cleanup, session validity +/// checks, metrics). +/// +/// All methods have default implementations that accept all connections +/// and continue unconditionally. +pub trait ConnectionHandler: Send { + /// Called after `accept()` returns but before `run_connection()`. + /// + /// Return `false` to reject the connection (the TCP stream is dropped). + fn on_accept(&mut self, peer: SocketAddr) -> bool { + let _ = peer; + true + } + + /// Called after `run_connection()` completes (successfully or with error). + /// + /// `duration` is the wall-clock time the connection was active. + /// `error` is `Some` if the connection ended with an error. + fn on_disconnected( + &mut self, + peer: SocketAddr, + duration: Duration, + error: Option<&anyhow::Error>, + ) -> PostConnectionAction { + let _ = (peer, duration, error); + PostConnectionAction::Continue + } +} + +/// Outcome of a successful [`CredentialValidator::validate`] call. +/// +/// A rejection from a working validator is not an error: the validator did +/// its job and decided the credentials do not authenticate. Backend failures +/// (LDAP unreachable, PAM transport broken, database connection lost) are +/// reported via [`CredentialValidationError`] instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CredentialDecision { + /// Credentials accepted; the connection proceeds. + Accept, + /// Credentials rejected; the connection is closed. + Reject, +} + +/// Error returned by a [`CredentialValidator`] when the validator backend +/// itself fails (rather than the credentials being invalid). +/// +/// Wraps any [`core::error::Error`] from the backend (LDAP/PAM/DB/etc.) so +/// the trait does not require a particular error library in implementors or +/// consumers. +#[derive(Debug)] +pub struct CredentialValidationError { + source: Box, +} + +impl CredentialValidationError { + /// Wrap a backend error as a credential-validation failure. + pub fn new(source: E) -> Self + where + E: core::error::Error + Send + Sync + 'static, + { + Self { + source: Box::new(source), + } + } +} + +impl fmt::Display for CredentialValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("credential validator backend failure") + } +} + +impl core::error::Error for CredentialValidationError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + Some(&*self.source) + } +} + +/// Server-side credential validator for TLS-mode connections. +/// +/// Called during connection setup when the server receives client credentials +/// via `ClientInfoPdu`. Not used for CredSSP/Hybrid connections (those use +/// pre-loaded credentials for NTLM challenge-response). +/// +/// Implement this trait to validate credentials against external systems +/// (PAM, LDAP, database, etc.). For blocking backends, wrap the call in +/// `tokio::task::spawn_blocking` to avoid stalling the async runtime. +/// +/// # Example +/// +/// ```ignore +/// use ironrdp_server::{CredentialDecision, CredentialValidationError, CredentialValidator, Credentials}; +/// +/// struct StaticValidator { +/// expected_user: String, +/// expected_password: String, +/// } +/// +/// #[async_trait::async_trait] +/// impl CredentialValidator for StaticValidator { +/// async fn validate( +/// &self, +/// creds: &Credentials, +/// ) -> Result { +/// if creds.username == self.expected_user && creds.password == self.expected_password { +/// Ok(CredentialDecision::Accept) +/// } else { +/// Ok(CredentialDecision::Reject) +/// } +/// } +/// } +/// ``` +#[async_trait::async_trait] +pub trait CredentialValidator: Send + Sync { + /// Validate credentials received from the client. + /// + /// Return `Ok(CredentialDecision::Accept)` to permit the connection, + /// `Ok(CredentialDecision::Reject)` to refuse it. Return + /// `Err(CredentialValidationError::new(_))` only when the validator + /// itself could not produce a decision (backend system error). + /// + /// Implementors backed by blocking systems (PAM, libldap, a synchronous + /// database driver) should offload the work, for example with + /// `tokio::task::spawn_blocking`, so the returned future does not stall the + /// caller's executor. Native-async backends can simply `.await`. + async fn validate(&self, credentials: &Credentials) -> Result; +} + +/// A built-in [`CredentialValidator`] that accepts exactly one fixed set of credentials. +/// +/// This is the validation-policy equivalent of the acceptor's pre-loaded +/// exact-match: it keeps the common "one known account" case a one-liner while +/// going through the same hook as PAM, LDAP, or database-backed validators. +pub struct ExactMatchCredentialValidator { + expected: Credentials, +} + +impl ExactMatchCredentialValidator { + /// Build a validator that accepts only `expected` and rejects everything else. + pub fn new(expected: Credentials) -> Self { + Self { expected } + } +} + +#[async_trait::async_trait] +impl CredentialValidator for ExactMatchCredentialValidator { + async fn validate(&self, credentials: &Credentials) -> Result { + if credentials == &self.expected { + Ok(CredentialDecision::Accept) + } else { + Ok(CredentialDecision::Reject) + } + } +} #[derive(Clone)] +#[non_exhaustive] pub struct RdpServerOptions { pub addr: SocketAddr, pub security: RdpServerSecurity, pub codecs: BitmapCodecs, + pub max_request_size: u32, + /// When `true`, each connection's acceptor adopts the desktop size the + /// client requests in its Client Core Data (instead of the size reported + /// by the display handler), negotiating that size from the start without a + /// Deactivation-Reactivation resize. Defaults to `false`. Set via + /// [`RdpServerBuilder::with_honor_client_desktop_size`](crate::RdpServerBuilder::with_honor_client_desktop_size). + pub honor_client_desktop_size: bool, } impl RdpServerOptions { + /// Default [MultifragmentUpdate] max reassembly buffer size (8 MB). + /// + /// Advertised to the client during capability exchange as the largest + /// reassembled Fast-Path Update the server can accept. + /// Values that are too large cause certain clients (notably mstsc) + /// to reject the connection. + /// + /// [MultifragmentUpdate]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/01717954-716a-424d-af35-28fb2b86df89 + pub(crate) const DEFAULT_MAX_REQUEST_SIZE: u32 = 8 * 1024 * 1024; + fn has_image_remote_fx(&self) -> bool { self.codecs .0 @@ -72,6 +272,14 @@ impl RdpServerOptions { .iter() .any(|codec| matches!(codec.property, CodecProperty::QoiZ)) } + + #[cfg(feature = "nscodec")] + fn has_nscodec(&self) -> bool { + self.codecs + .0 + .iter() + .any(|codec| matches!(codec.property, CodecProperty::NsCodec(_))) + } } #[derive(Clone)] @@ -148,6 +356,20 @@ impl DisplayControlHandler for DisplayControlBackend { } } +/// Selects who performs the TLS handshake for a connection accepted via +/// [`RdpServer::run_connection_with`]. +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub enum TransportTls { + /// IronRDP performs the TLS accept on the stream (standard TCP+TLS). + Managed, + /// The stream is already past TLS, terminated by a lower layer (e.g. a WSS + /// terminator). IronRDP skips the TLS handshake. The caller MUST guarantee + /// the transport is already encrypted; see the preconditions on + /// [`RdpServer::run_connection_with`]. + AlreadyDone, +} + /// RDP Server /// /// A server is created to listen for connections. @@ -178,7 +400,7 @@ impl DisplayControlHandler for DisplayControlBackend { ///# todo!() ///# } ///# } -///# async fn stub() { +///# async fn stub() -> Result<()> { /// fn make_tls_acceptor() -> TlsAcceptor { /// /* snip */ ///# todo!() @@ -206,6 +428,7 @@ impl DisplayControlHandler for DisplayControlBackend { /// .build(); /// /// server.run().await; +/// Ok(()) ///# } /// ``` pub struct RdpServer { @@ -216,10 +439,37 @@ pub struct RdpServer { static_channels: StaticChannelSet, sound_factory: Option>, cliprdr_factory: Option>, + echo_handle: EchoServerHandle, + #[cfg(feature = "egfx")] + gfx_factory: Option>, + #[cfg(feature = "egfx")] + gfx_handle: Option, ev_sender: mpsc::UnboundedSender, ev_receiver: Arc>>, creds: Option, + credential_validator: Option>, local_addr: Option, + autodetect: Option, + connection_handler: Option>, + /// True while the client has sent `SuppressOutput { desktop_rect: None }` + /// — the standard RDP "I don't need display updates right now" signal + /// (mstsc raises it on window minimize). Cleared on + /// `SuppressOutput { Some(rect) }` or `RefreshRectangle` (sent on + /// refocus). Exposed via [`Self::display_suppressed_handle`] so display + /// backends can hold a clone and skip frame emission while it's set — + /// without this, a server keeps streaming high-bitrate + /// EGFX/H.264 frames into a minimized client, which accumulates them + /// and locks up its input dispatch for seconds on refocus while it + /// chews through the backlog. + display_suppressed: Arc, + + /// Latest NetworkAutoDetect round-trip time in milliseconds, or `u32::MAX` + /// until the first measurement (and while auto-detect is disabled). Updated + /// on each RTT Measure Response when auto-detect is enabled (see + /// [`Self::enable_autodetect`]). Exposed via [`Self::autodetect_rtt_handle`] + /// so display backends can read a fresh, frame-traffic-independent network + /// RTT for flow control. + autodetect_rtt: Arc, } #[derive(Debug)] @@ -227,8 +477,13 @@ pub enum ServerEvent { Quit(String), Clipboard(ClipboardMessage), Rdpsnd(RdpsndServerMessage), + Echo(EchoServerMessage), SetCredentials(Credentials), GetLocalAddr(oneshot::Sender>), + #[cfg(feature = "egfx")] + Egfx(EgfxServerMessage), + /// Trigger an RTT measurement probe (requires auto-detect enabled). + AutoDetectRttRequest, } pub trait ServerEventSender { @@ -249,12 +504,20 @@ enum RunState { } impl RdpServer { - pub fn new( + #[expect( + clippy::too_many_arguments, + reason = "called via the builder; positional parameters are an internal detail" + )] + pub(crate) fn new( opts: RdpServerOptions, handler: Box, display: Box, mut sound_factory: Option>, mut cliprdr_factory: Option>, + connection_handler: Option>, + #[cfg(feature = "egfx")] mut gfx_factory: Option>, + display_suppressed: Option>, + autodetect_rtt: Option>, ) -> Self { let (ev_sender, ev_receiver) = ServerEvent::create_channel(); if let Some(cliprdr) = cliprdr_factory.as_mut() { @@ -263,6 +526,10 @@ impl RdpServer { if let Some(snd) = sound_factory.as_mut() { snd.set_sender(ev_sender.clone()); } + #[cfg(feature = "egfx")] + if let Some(gfx) = gfx_factory.as_mut() { + gfx.set_sender(ev_sender.clone()); + } Self { opts, handler: Arc::new(Mutex::new(handler)), @@ -270,10 +537,25 @@ impl RdpServer { static_channels: StaticChannelSet::new(), sound_factory, cliprdr_factory, + echo_handle: EchoServerHandle::new(ev_sender.clone()), + #[cfg(feature = "egfx")] + gfx_factory, + #[cfg(feature = "egfx")] + gfx_handle: None, ev_sender, ev_receiver: Arc::new(Mutex::new(ev_receiver)), creds: None, + credential_validator: None, local_addr: None, + autodetect: None, + connection_handler, + display_suppressed: display_suppressed.unwrap_or_else(|| Arc::new(AtomicBool::new(false))), + autodetect_rtt: { + // Reset to the sentinel: an injected handle must not expose a stale value before the first measurement. + let handle = autodetect_rtt.unwrap_or_else(|| Arc::new(AtomicU32::new(u32::MAX))); + handle.store(u32::MAX, Ordering::Relaxed); + handle + }, } } @@ -281,10 +563,108 @@ impl RdpServer { builder::RdpServerBuilder::new() } + /// Set or clear the credential validator for TLS-mode connections. + /// + /// When set, credentials received from the client during + /// `SecureSettingsExchange` are validated through this callback before + /// the session is established. If the validator returns + /// [`CredentialDecision::Reject`] (or a [`CredentialValidationError`]), + /// the connection is rejected. Passing `None` clears any previously + /// configured validator. + /// + /// Most callers should configure the validator at construction time via + /// the builder's `with_credential_validator` method + /// ([`RdpServer::builder`]); this setter exists for dynamic + /// post-construction reconfiguration. + /// + /// Not used for CredSSP/Hybrid connections (those use pre-loaded credentials). + pub fn set_credential_validator(&mut self, validator: Option>) { + self.credential_validator = validator; + } + pub fn event_sender(&self) -> &mpsc::UnboundedSender { &self.ev_sender } + /// Returns the shared "display suppressed" flag — `true` while the + /// connected client has sent `SuppressOutput { desktop_rect: None }` + /// (e.g., mstsc minimized). + /// + /// Display backends should hold a clone of this `Arc` and skip frame + /// emission while it's set, so the client doesn't accumulate a backlog + /// of frames it can't present until refocus. Cleared by the per- + /// connection PDU handler on `SuppressOutput { Some(rect) }` or + /// `RefreshRectangle`. + /// + /// **Caveat:** some clients (notably mstsc) send + /// `SuppressOutput { desktop_rect: None }` during their connect + /// handshake *before* their display surface is fully initialized; a + /// backend that honors the flag blindly will block that first frame + /// and leave the client with a half-initialized surface that doesn't + /// recover on un-suppress (visible as a frozen desktop on first + /// connect). Backends are advised to defer acting on the flag until + /// after the first frame has been delivered to the client, and to + /// debounce transient flaps (some clients pulse this PDU under wire + /// pressure on heavy CPU/IO loads) — e.g., only engage the gate once + /// the flag has been steady-`true` for ~1 s. + /// + /// The display backend typically needs to share this flag with the + /// server before any client connects (so the same `Arc` is read by + /// the backend's polling thread and written by the per-connection + /// PDU handler). To inject the shared instance at construction time, + /// use [`RdpServerBuilder::with_display_suppressed_handle`](crate::RdpServerBuilder::with_display_suppressed_handle). + /// + /// [crate::RdpServerBuilder]: crate::RdpServerBuilder + pub fn display_suppressed_handle(&self) -> Arc { + Arc::clone(&self.display_suppressed) + } + + /// Returns a handle to the latest NetworkAutoDetect RTT in milliseconds + /// (`u32::MAX` until the first measurement, and while auto-detect is + /// disabled). The server updates it on each RTT Measure Response; backends + /// clone the handle to read a fresh network RTT for flow control. Inject a + /// shared instance at construction with + /// [`RdpServerBuilder::with_autodetect_rtt_handle`](crate::RdpServerBuilder::with_autodetect_rtt_handle). + pub fn autodetect_rtt_handle(&self) -> Arc { + Arc::clone(&self.autodetect_rtt) + } + + /// Returns the shared ECHO server handle for runtime probe requests and RTT measurements. + pub fn echo_handle(&self) -> &EchoServerHandle { + &self.echo_handle + } + + /// Enable protocol-level auto-detect ([MS-RDPBCGR 2.2.14]). + /// + /// Auto-detect uses lightweight Share Data PDUs on the IO channel, + /// separate from the ECHO DVC. It supports bandwidth measurement + /// in addition to RTT and works even when DVC is unavailable. + /// + /// Send probes via [`ServerEvent::AutoDetectRttRequest`] and + /// query results with [`rtt_snapshot()`](Self::rtt_snapshot). + pub fn enable_autodetect(&mut self) { + self.autodetect = Some(AutoDetectManager::new()); + } + + /// Get the latest auto-detect RTT snapshot. + /// + /// Returns `None` if auto-detect is not enabled or no measurements + /// have been received yet. + pub fn rtt_snapshot(&self) -> Option { + self.autodetect.as_ref().and_then(|ad| ad.snapshot()) + } + + /// Returns the shared EGFX server handle for proactive frame submission. + /// + /// Available after `build_server_with_handle()` returns `Some` during + /// channel setup. Display handlers use this to call + /// `send_avc420_frame()` / `send_avc444_frame()` and then signal the + /// event loop via `ServerEvent::Egfx`. + #[cfg(feature = "egfx")] + pub fn gfx_handle(&self) -> Option<&crate::gfx::GfxServerHandle> { + self.gfx_handle.as_ref() + } + fn attach_channels(&mut self, acceptor: &mut Acceptor) { if let Some(cliprdr_factory) = self.cliprdr_factory.as_deref() { let backend = cliprdr_factory.build_cliprdr_backend(); @@ -306,15 +686,134 @@ impl RdpServer { handler: Arc::clone(&self.handler), }) .with_dynamic_channel(DisplayControlServer::new(Box::new(dcs_backend))); + + let dvc = { + let echo_handle = self.echo_handle.clone(); + dvc.with_dynamic_channel(EchoDvcBridge::new(echo_handle)) + }; + + #[cfg(feature = "egfx")] + let dvc = { + let mut dvc = dvc; + if let Some(gfx_factory) = self.gfx_factory.as_deref() { + if let Some((bridge, handle)) = gfx_factory.build_server_with_handle() { + self.gfx_handle = Some(handle); + dvc = dvc.with_dynamic_channel(bridge); + } else { + let handler = gfx_factory.build_gfx_handler(); + let gfx_server = ironrdp_egfx::server::GraphicsPipelineServer::new(handler); + dvc = dvc.with_dynamic_channel(gfx_server); + } + } + dvc + }; + acceptor.attach_static_channel(dvc); } - pub async fn run_connection(&mut self, stream: TcpStream) -> Result<()> { + /// Run a single RDP connection over `stream`, performing the + /// IronRDP-managed TLS handshake on `ShouldUpgrade` (standard TCP+TLS). + /// + /// Equivalent to [`run_connection_with`](Self::run_connection_with) with + /// [`TransportTls::Managed`]. + pub async fn run_connection(&mut self, stream: S) -> Result<()> + where + S: AsyncRead + AsyncWrite + Send + Sync + Unpin, + { + self.run_connection_with(stream, TransportTls::Managed).await + } + + /// Run a single RDP connection over `stream`, choosing who performs the TLS + /// handshake with `tls`. + /// + /// With [`TransportTls::Managed`], IronRDP performs the TLS accept on + /// `ShouldUpgrade`, exactly as [`run_connection`](Self::run_connection). + /// + /// With [`TransportTls::AlreadyDone`], the caller's `stream` has ALREADY + /// been transport-encrypted at a lower layer that the embedder owns + /// (typically a WSS terminator in the same process, or a TLS stream the + /// embedder accepted up front), so IronRDP skips the TLS handshake and + /// advances the state machine via [`Acceptor::mark_security_upgrade_as_done`]. + /// Everything past the handshake, including the optional Hybrid CredSSP + /// exchange and finalization, is identical to the managed path. + /// + /// # Use case for [`TransportTls::AlreadyDone`] + /// + /// This mode decouples transport encryption from the RDP security-upgrade + /// step. It is for ironrdp-server endpoints that terminate transport + /// encryption themselves before the RDP state machine runs — for example a + /// server that accepts WSS directly, or one fronted by an in-process TLS + /// terminator — and therefore must not perform a second, inner TLS + /// handshake when the X.224 negotiation selects `PROTOCOL_SSL`. + /// + /// This is distinct from a [RDCleanPath] proxy deployment (e.g. + /// Devolutions Gateway), where the proxy performs a real TLS handshake with + /// a *separate* backend RDP server and relays that server's certificate + /// chain to the client. In that topology the backend server owns its own + /// TLS and uses [`TransportTls::Managed`]; this mode does not apply to it. + /// RDCleanPath is relevant here only as one client-side mechanism (see + /// precondition 2) for telling a client not to expect an inner handshake. + /// + /// # Preconditions for [`TransportTls::AlreadyDone`] (caller MUST guarantee) + /// + /// 1. The `stream` is already transport-encrypted by another layer + /// (WSS, in-process, etc.). Passing a plain TCP stream here exposes + /// RDP traffic in plaintext on the wire. + /// + /// 2. The connecting client must not expect an inner TLS handshake on this + /// stream. Vanilla RDP clients (mstsc, xfreerdp) negotiate TLS from the + /// X.224 `selectedProtocol` and have no concept of "TLS already done at a + /// lower layer": they will hang or fail, and must use + /// [`TransportTls::Managed`]. Arranging for a client to skip the inner + /// handshake is the embedder's responsibility; RDCleanPath is one such + /// mechanism, but this method does not depend on it. + /// + /// 3. If `self.opts.security` is [`RdpServerSecurity::Hybrid`], two things + /// must hold. First, the client must support CredSSP over this + /// transport; the SPNEGO exchange itself is transport-independent + /// (CredSSP carries its own crypto via TSRequest), so it runs the same + /// as on the managed path. Second, and less obvious: the CredSSP + /// server-public-key confirmation (`pubKeyAuth`, per MS-CSSP) binds to + /// the certificate the client validated at the lower transport layer, + /// not to anything IronRDP does here. So the public key configured in + /// [`RdpServerSecurity::Hybrid`] MUST be the public key of the + /// certificate that lower layer (e.g. the WSS terminator) presented to + /// the client, otherwise the client's `pubKeyAuth` check fails and + /// Hybrid is rejected. This is the embedder's responsibility; it does + /// not hold automatically. In practice it means terminating transport + /// TLS with the same certificate configured for Hybrid. + /// + /// [RDCleanPath]: https://docs.rs/ironrdp-rdcleanpath + /// + /// # Wire-level invariant + /// + /// This method does NOT alter the X.224 negotiation. The acceptor still + /// advertises whatever `SecurityProtocol` it was constructed with, and the + /// connecting client still negotiates as normal. The only behaviour change + /// under [`TransportTls::AlreadyDone`] is that after the negotiation reaches + /// the security-upgrade gate, no TLS handshake is performed on the byte + /// stream, because the caller's stream is already past TLS at a lower layer. + pub async fn run_connection_with(&mut self, stream: S, tls: TransportTls) -> Result<()> + where + S: AsyncRead + AsyncWrite + Send + Sync + Unpin, + { + // Per-connection state must start fresh: if the previous client + // disconnected while it had sent `SuppressOutput { None }` (e.g., + // closed the mstsc window while minimized so the matching resume + // PDU never arrived), the flag would still read `true` here and the + // display backend would silently drop frames for the entire new + // session until/unless the new client happens to send a + // `RefreshRectangle` or `SuppressOutput { Some(rect) }`. Resetting + // here also covers backends that share an externally-created Arc via + // `set_display_suppressed_handle()`. + self.display_suppressed.store(false, Ordering::Relaxed); + let framed = TokioFramed::new(stream); let size = self.display.lock().await.size().await; let capabilities = capabilities::capabilities(&self.opts, size); let mut acceptor = Acceptor::new(self.opts.security.flag(), size, capabilities, self.creds.clone()); + acceptor.set_honor_client_desktop_size(self.opts.honor_client_desktop_size); self.attach_channels(&mut acceptor); @@ -323,41 +822,33 @@ impl RdpServer { .context("accept_begin failed")?; match res { - BeginResult::ShouldUpgrade(stream) => { - let tls_acceptor = match &self.opts.security { - RdpServerSecurity::Tls(acceptor) => acceptor, - RdpServerSecurity::Hybrid((acceptor, _)) => acceptor, - RdpServerSecurity::None => unreachable!(), - }; - let accept = match tls_acceptor.accept(stream).await { - Ok(accept) => accept, - Err(e) => { - warn!("Failed to TLS accept: {}", e); - return Ok(()); - } - }; - let mut framed = TokioFramed::new(accept); - - acceptor.mark_security_upgrade_as_done(); - - if let RdpServerSecurity::Hybrid((_, pub_key)) = &self.opts.security { - // how to get the client name? - // doesn't seem to matter yet - let client_name = framed.get_inner().0.get_ref().0.peer_addr()?.to_string(); - - ironrdp_acceptor::accept_credssp( - &mut framed, - &mut acceptor, - client_name.into(), - pub_key.clone(), - None, - None, - ) - .await?; + // The only thing that varies between the two modes is who performs + // the TLS handshake; everything past it is `finalize_after_upgrade`. + BeginResult::ShouldUpgrade(stream) => match tls { + TransportTls::Managed => { + let tls_acceptor = match &self.opts.security { + RdpServerSecurity::Tls(acceptor) => acceptor, + RdpServerSecurity::Hybrid((acceptor, _)) => acceptor, + RdpServerSecurity::None => unreachable!(), + }; + let accept = match tls_acceptor.accept(stream).await { + Ok(accept) => accept, + Err(e) => { + warn!("Failed to TLS accept: {}", e); + return Ok(()); + } + }; + self.finalize_after_upgrade(TokioFramed::new(accept), acceptor, "TLS connection") + .await?; } - - self.accept_finalize(framed, acceptor).await?; - } + TransportTls::AlreadyDone => { + // The stream is already past TLS (terminated at a lower + // layer, e.g. a WSS terminator); do NOT call + // tls_acceptor.accept on it. + self.finalize_after_upgrade(TokioFramed::new(stream), acceptor, "TLS-offloaded stream") + .await?; + } + }, BeginResult::Continue(framed) => { self.accept_finalize(framed, acceptor).await?; @@ -367,8 +858,76 @@ impl RdpServer { Ok(()) } + /// Shared post-handshake tail for both [`TransportTls`] modes: mark the + /// security upgrade complete, run the optional Hybrid CredSSP exchange, + /// finalize, and shut the stream down. Single-sourcing this is what keeps + /// the managed and TLS-offloaded paths structurally identical past the + /// handshake, so per-connection state handling cannot drift between them. + async fn finalize_after_upgrade( + &mut self, + mut framed: TokioFramed, + mut acceptor: Acceptor, + shutdown_label: &str, + ) -> Result<()> + where + S: AsyncRead + AsyncWrite + Sync + Send + Unpin, + { + acceptor.mark_security_upgrade_as_done(); + + if let RdpServerSecurity::Hybrid((_, pub_key)) = &self.opts.security { + // Generic streams don't expose peer address. Use a neutral + // placeholder; it's unclear whether CredSSP/NTLM actually + // uses this value in practice. + let client_name = "rdp-client".to_owned(); + + ironrdp_acceptor::accept_credssp( + &mut framed, + &mut acceptor, + &mut ironrdp_tokio::reqwest::ReqwestNetworkClient::new(), + client_name.into(), + pub_key.clone(), + None, + ) + .await?; + } + + let framed = self.accept_finalize(framed, acceptor).await?; + debug!("Shutting down {}", shutdown_label); + let (mut inner, _) = framed.into_inner(); + if let Err(e) = inner.shutdown().await { + debug!(?e, "{} shutdown error", shutdown_label); + } + + Ok(()) + } + pub async fn run(&mut self) -> Result<()> { - let listener = TcpListener::bind(self.opts.addr).await?; + // Create socket with control over options before binding. + // Using TcpSocket instead of TcpListener::bind() allows setting + // SO_REUSEADDR and IPv6 dual-stack mode. + let socket = match self.opts.addr { + SocketAddr::V4(_) => TcpSocket::new_v4().context("create IPv4 socket")?, + SocketAddr::V6(_) => { + // IPv6 socket: on Linux, dual-stack is the default + // (net.ipv6.bindv6only=0), so IPv4 clients connect as + // IPv4-mapped addresses (::ffff:x.x.x.x). On platforms + // where IPV6_V6ONLY defaults to 1 (Windows, some BSDs), + // only IPv6 clients will be accepted and a separate IPv4 + // listener would be needed. + TcpSocket::new_v6().context("create IPv6 socket")? + } + }; + + // SO_REUSEADDR prevents EADDRINUSE when restarting the server while + // the previous socket is still in TIME_WAIT. Only set on Unix; + // on Windows SO_REUSEADDR has different semantics that allow a + // second process to bind the same port, which is a security risk. + #[cfg(unix)] + socket.set_reuseaddr(true).context("set SO_REUSEADDR")?; + + socket.bind(self.opts.addr).context("bind listen address")?; + + let listener = socket.listen(LISTENER_BACKLOG).context("start listener")?; let local_addr = listener.local_addr()?; debug!("Listening for connections on {local_addr}"); @@ -398,10 +957,37 @@ impl RdpServer { Ok((stream, peer)) = listener.accept() => { debug!(?peer, "Received connection"); drop(ev_receiver); - if let Err(error) = self.run_connection(stream).await { - error!(?error, "Connection error"); + + let accepted = self.connection_handler + .as_mut() + .is_none_or(|h| h.on_accept(peer)); + + if !accepted { + debug!(?peer, "Connection rejected by handler"); + drop(stream); + } else { + let started = tokio::time::Instant::now(); + let result = self.run_connection(stream).await; + let duration = started.elapsed(); + + if let Err(ref error) = result { + error!(?error, "Connection error"); + } + + self.static_channels = StaticChannelSet::new(); + + if let Some(ref mut handler) = self.connection_handler { + let action = handler.on_disconnected( + peer, + duration, + result.as_ref().err(), + ); + if action == PostConnectionAction::Stop { + debug!(?peer, "Handler requested stop after disconnect"); + break; + } + } } - self.static_channels = StaticChannelSet::new(); } else => break, } @@ -427,6 +1013,7 @@ impl RdpServer { writer: &mut impl FramedWrite, io_channel_id: u16, user_channel_id: u16, + message_channel_id: Option, ) -> Result { match action { Action::FastPath => { @@ -436,7 +1023,7 @@ impl RdpServer { Action::X224 => { if self - .handle_x224(writer, io_channel_id, user_channel_id, &bytes) + .handle_x224(writer, io_channel_id, user_channel_id, message_channel_id, &bytes) .await .context("X224 input error")? { @@ -491,11 +1078,24 @@ impl RdpServer { events: &mut Vec, writer: &mut impl FramedWrite, user_channel_id: u16, + message_channel_id: Option, ) -> Result { - // Avoid wave message queuing up and causing extra delays. - // This is a naive solution, better solutions should compute the actual delay, add IO priority, encode audio, use UDP etc. - // 4 frames should roughly corresponds to hundreds of ms in regular setups. - let mut wave_limit = 4; + // Avoid wave messages queuing up and causing extra delay. When a + // batch carries more than `WAVE_KEEP` waves, drop the OLDEST ones + // and keep the most recent — playing stale audio just bakes the + // latency in permanently, so a one-time dispatch stall (e.g. a video + // encode holding the server lock) would otherwise become a permanent + // audio offset. + // + // This is still a naive solution; better long-term: compute the + // actual delay, add IO priority, encode audio, use UDP, etc. 4 frames + // is roughly low hundreds of ms in regular setups. + const WAVE_KEEP: usize = 4; + let wave_total = events + .iter() + .filter(|e| matches!(e, ServerEvent::Rdpsnd(RdpsndServerMessage::Wave(..)))) + .count(); + let mut wave_skip = wave_total.saturating_sub(WAVE_KEEP); for event in events.drain(..) { trace!(?event, "Dispatching"); match event { @@ -516,11 +1116,11 @@ impl RdpServer { }; let msgs = match s { RdpsndServerMessage::Wave(data, ts) => { - if wave_limit == 0 { - debug!("Dropping wave"); + if wave_skip > 0 { + wave_skip -= 1; + debug!("Dropping stale wave"); continue; } - wave_limit -= 1; rdpsnd.wave(data, ts) } RdpsndServerMessage::SetVolume { left, right } => rdpsnd.set_volume(left, right), @@ -533,7 +1133,7 @@ impl RdpServer { .context("failed to send rdpsnd event")?; let channel_id = self .get_channel_id_by_type::() - .ok_or_else(|| anyhow!("SVC channel not found"))?; + .context("SVC channel not found")?; let data = server_encode_svc_messages(msgs.into(), channel_id, user_channel_id)?; writer.write_all(&data).await?; } @@ -544,8 +1144,11 @@ impl RdpServer { }; let msgs = match c { ClipboardMessage::SendInitiateCopy(formats) => cliprdr.initiate_copy(&formats), + ClipboardMessage::SendInitiateFileCopy(files) => cliprdr.initiate_file_copy(files), ClipboardMessage::SendFormatData(data) => cliprdr.submit_format_data(data), ClipboardMessage::SendInitiatePaste(format) => cliprdr.initiate_paste(format), + ClipboardMessage::SendFileContentsRequest(request) => cliprdr.request_file_contents(request), + ClipboardMessage::SendFileContentsResponse(response) => cliprdr.submit_file_contents(response), ClipboardMessage::Error(error) => { error!(?error, "Handling clipboard event"); continue; @@ -554,10 +1157,62 @@ impl RdpServer { .context("failed to send clipboard event")?; let channel_id = self .get_channel_id_by_type::() - .ok_or_else(|| anyhow!("SVC channel not found"))?; + .context("SVC channel not found")?; let data = server_encode_svc_messages(msgs.into(), channel_id, user_channel_id)?; writer.write_all(&data).await?; } + ServerEvent::Echo(msg) => match msg { + EchoServerMessage::SendRequest { payload } => { + let Some(drdynvc) = self.get_svc_processor::() else { + warn!("No drdynvc channel, dropping ECHO request"); + continue; + }; + + let Some(echo_channel_id) = drdynvc.get_channel_id_by_type::() else { + warn!("No ECHO dynamic channel, dropping ECHO request"); + continue; + }; + + if !drdynvc.is_channel_opened(echo_channel_id) { + warn!("ECHO dynamic channel not yet opened, dropping ECHO request"); + continue; + } + + self.echo_handle.on_request_sent(&payload); + + let request = build_echo_request(payload)?; + let messages = + dvc::encode_dvc_messages(echo_channel_id, vec![request], ChannelFlags::SHOW_PROTOCOL)?; + + let drdynvc_channel_id = self + .get_channel_id_by_type::() + .context("DRDYNVC channel not found")?; + + let data = server_encode_svc_messages(messages, drdynvc_channel_id, user_channel_id)?; + writer.write_all(&data).await?; + } + }, + #[cfg(feature = "egfx")] + ServerEvent::Egfx(msg) => match msg { + EgfxServerMessage::SendMessages { messages } => { + let drdynvc_channel_id = self + .get_channel_id_by_type::() + .context("DRDYNVC channel not found")?; + let data = server_encode_svc_messages(messages, drdynvc_channel_id, user_channel_id)?; + writer.write_all(&data).await?; + } + }, + ServerEvent::AutoDetectRttRequest => { + // Auto-detect requests ride the MCS message channel + // ([MS-RDPBCGR] 2.2.14.3). With none negotiated (the client + // did not request it), there is nowhere to send them. + if let (Some(ad), Some(message_channel_id)) = (self.autodetect.as_mut(), message_channel_id) { + ad.expire_stale_probes(crate::autodetect::RTT_PROBE_MAX_AGE); + let request = ad.send_rtt_request(); + let data = encode_autodetect_request(request, message_channel_id, user_channel_id)?; + writer.write_all(&data).await?; + } + } } } @@ -570,6 +1225,7 @@ impl RdpServer { writer: &mut Framed, io_channel_id: u16, user_channel_id: u16, + message_channel_id: Option, mut encoder: UpdateEncoder, ) -> Result where @@ -590,7 +1246,14 @@ impl RdpServer { let (action, bytes) = reader.read_pdu().await?; let mut this = this.lock().await; match this - .dispatch_pdu(action, bytes, &mut writer, io_channel_id, user_channel_id) + .dispatch_pdu( + action, + bytes, + &mut writer, + io_channel_id, + user_channel_id, + message_channel_id, + ) .await? { RunState::Continue => continue, @@ -601,28 +1264,35 @@ impl RdpServer { let dispatch_display = async move { let mut buffer = vec![0u8; 4096]; + loop { - if let Some(update) = display_updates.next_update().await { - match Self::dispatch_display_update( - update, - &mut display_writer, - user_channel_id, - io_channel_id, - &mut buffer, - encoder, - ) - .await? - { - (RunState::Continue, enc) => { - encoder = enc; - continue; - } - (state, _) => { - break Ok(state); + match display_updates.next_update().await { + Ok(Some(update)) => { + match Self::dispatch_display_update( + update, + &mut display_writer, + user_channel_id, + io_channel_id, + &mut buffer, + encoder, + ) + .await? + { + (RunState::Continue, enc) => { + encoder = enc; + continue; + } + (state, _) => { + break Ok(state); + } } } - } else { - break Ok(RunState::Disconnect); + Ok(None) => { + break Ok(RunState::Disconnect); + } + Err(error) => { + warn!(error = format!("{error:#}"), "next_updated failed"); + } } } }; @@ -642,7 +1312,7 @@ impl RdpServer { } let mut this = this.lock().await; match this - .dispatch_server_events(&mut events, &mut event_writer, user_channel_id) + .dispatch_server_events(&mut events, &mut event_writer, user_channel_id, message_channel_id) .await? { RunState::Continue => continue, @@ -673,12 +1343,39 @@ impl RdpServer { { debug!("Client accepted"); + // Validate credentials if a validator is configured. The validator runs here, in the + // async server layer, rather than in the sans-I/O acceptor, because real validators + // (PAM/LDAP/DB) are I/O-bound. On rejection, deny with a ServerSetErrorInfoPdu before + // closing, matching the acceptor's exact-match denial path. + if let Some(validator) = self.credential_validator.clone() { + if let Some(creds) = &result.credentials { + match validator.validate(creds).await { + Ok(CredentialDecision::Accept) => { + debug!("Credential validation accepted"); + } + Ok(CredentialDecision::Reject) => { + warn!("Credential validation rejected"); + send_access_denied(result.io_channel_id, result.user_channel_id, writer).await?; + bail!("credential validation rejected"); + } + Err(e) => { + error!(error = %e, "Credential validator backend error"); + send_access_denied(result.io_channel_id, result.user_channel_id, writer).await?; + bail!("credential validation backend error"); + } + } + } else { + debug!("Skipping credential validation (no credentials in AcceptorResult)"); + } + } + if !result.input_events.is_empty() { debug!("Handling input event backlog from acceptor sequence"); self.handle_input_backlog( writer, result.io_channel_id, result.user_channel_id, + result.message_channel_id, result.input_events, ) .await?; @@ -717,7 +1414,7 @@ impl RdpServer { width: b.desktop_width, height: b.desktop_height, }; - let display_size = self.display.lock().await.size().await; + let display_size = self.display.lock().await.request_initial_size(client_size).await; // It's problematic when the client didn't resize, as we send bitmap updates that don't fit. // The client will likely drop the connection. @@ -747,17 +1444,24 @@ impl RdpServer { CodecProperty::RemoteFx(rdp::capability_sets::RemoteFxContainer::ClientContainer(c)) if self.opts.has_remote_fx() => { - for caps in c.caps_data.0 .0 { + for caps in c.caps_data.0.0 { update_codecs.set_remotefx(Some((caps.entropy_bits, codec.id))); } } CodecProperty::ImageRemoteFx(rdp::capability_sets::RemoteFxContainer::ClientContainer( c, )) if self.opts.has_image_remote_fx() => { - for caps in c.caps_data.0 .0 { + for caps in c.caps_data.0.0 { update_codecs.set_remotefx(Some((caps.entropy_bits, codec.id))); } } + #[cfg(feature = "nscodec")] + CodecProperty::NsCodec(client_ns) if self.opts.has_nscodec() => { + // Re-use the client's confirmed color-loss + // level so the server encodes at the same + // shift the client decodes against. + update_codecs.set_nscodec(Some((codec.id, client_ns.color_loss_level))); + } CodecProperty::NsCodec(_) => (), #[cfg(feature = "qoi")] CodecProperty::Qoi if self.opts.has_qoi() => { @@ -776,10 +1480,18 @@ impl RdpServer { } let desktop_size = self.display.lock().await.size().await; - let encoder = UpdateEncoder::new(desktop_size, surface_flags, update_codecs); + let encoder = UpdateEncoder::new(desktop_size, surface_flags, update_codecs, self.opts.max_request_size) + .context("failed to initialize update encoder")?; let state = self - .client_loop(reader, writer, result.io_channel_id, result.user_channel_id, encoder) + .client_loop( + reader, + writer, + result.io_channel_id, + result.user_channel_id, + result.message_channel_id, + encoder, + ) .await .context("client loop failure")?; @@ -791,6 +1503,7 @@ impl RdpServer { writer: &mut impl FramedWrite, io_channel_id: u16, user_channel_id: u16, + message_channel_id: Option, frames: Vec>, ) -> Result<()> { for frame in frames { @@ -801,7 +1514,9 @@ impl RdpServer { } Ok(Action::X224) => { - let _ = self.handle_x224(writer, io_channel_id, user_channel_id, &frame).await; + let _ = self + .handle_x224(writer, io_channel_id, user_channel_id, message_channel_id, &frame) + .await; } // the frame here is always valid, because otherwise it would @@ -814,7 +1529,7 @@ impl RdpServer { } async fn handle_fastpath(&mut self, input: FastPathInput) { - for event in input.0 { + for event in input.input_events().iter().copied() { let mut handler = self.handler.lock().await; match event { FastPathInputEvent::KeyboardEvent(flags, key) => { @@ -861,6 +1576,35 @@ impl RdpServer { return Ok(true); } + // Client requests the server stop or resume sending display + // updates. mstsc sends `desktop_rect: None` on minimize and + // `desktop_rect: Some(rect)` on refocus. Without honoring + // this, the server keeps streaming high-bitrate EGFX/H.264 + // frames into a minimized client; on refocus the client + // must chew through the accumulated backlog before it can + // present the current frame, locking up its input dispatch + // for seconds. Flagging the shared `display_suppressed` + // lets the display backend skip frame emission while it's + // set. + rdp::headers::ShareDataPdu::SuppressOutput(pdu) => { + let suppress = pdu.desktop_rect.is_none(); + self.display_suppressed.store(suppress, Ordering::Relaxed); + debug!(suppress, "client suppress-output state changed"); + } + + // Client asks the server to redraw a rectangle — typical on + // refocus after a minimize. Clear the suppress flag so the + // backend resumes emission and treat this as "client wants + // updates again." (The flag would also be cleared by the + // `SuppressOutput { Some(rect) }` that usually accompanies + // this; clearing here is belt-and-braces against clients + // that send only one of the two.) + rdp::headers::ShareDataPdu::RefreshRectangle(_) => { + if self.display_suppressed.swap(false, Ordering::Relaxed) { + debug!("client RefreshRectangle cleared suppress-output state"); + } + } + unexpected => { warn!(?unexpected, "Unexpected share data pdu"); } @@ -874,21 +1618,53 @@ impl RdpServer { Ok(false) } + fn handle_message_channel_data(&mut self, data: SendDataRequest<'_>) { + // The MCS message channel currently carries only the auto-detect + // response. It is framed by a Basic Security Header (SEC_AUTODETECT_RSP), + // not a Share Control header. + match decode::(data.user_data.as_ref()) { + Ok(pdu) => { + if let Some(ref mut ad) = self.autodetect { + if let Some(rtt_ms) = ad.handle_response(&pdu.response) { + self.autodetect_rtt.store(rtt_ms, Ordering::Relaxed); + debug!(rtt_ms, seq = pdu.response.sequence_number(), "RTT measured"); + } else { + trace!(seq = pdu.response.sequence_number(), "Unmatched auto-detect response"); + } + } + } + Err(error) => { + warn!(error = format!("{error:#}"), "Unhandled MCS message channel PDU"); + } + } + } + async fn handle_x224( &mut self, writer: &mut impl FramedWrite, io_channel_id: u16, user_channel_id: u16, + message_channel_id: Option, frame: &[u8], ) -> Result { let message = decode::>>(frame)?; match message.0 { mcs::McsMessage::SendDataRequest(data) => { - debug!(?data, "McsMessage::SendDataRequest"); + debug!( + initiator_id = data.initiator_id, + channel_id = data.channel_id, + user_data_len = data.user_data.len(), + "McsMessage::SendDataRequest" + ); if data.channel_id == io_channel_id { return self.handle_io_channel_data(data).await; } + if message_channel_id == Some(data.channel_id) { + self.handle_message_channel_data(data); + return Ok(false); + } + if let Some(svc) = self.static_channels.get_by_channel_id_mut(data.channel_id) { let response_pdus = svc.process(&data.user_data)?; let response = server_encode_svc_messages(response_pdus, data.channel_id, user_channel_id)?; @@ -945,7 +1721,7 @@ impl RdpServer { } } - async fn accept_finalize(&mut self, mut framed: TokioFramed, mut acceptor: Acceptor) -> Result<()> + async fn accept_finalize(&mut self, mut framed: TokioFramed, mut acceptor: Acceptor) -> Result> where S: AsyncRead + AsyncWrite + Sync + Send + Unpin, { @@ -973,11 +1749,12 @@ impl RdpServer { framed = unsplit_tokio_framed(reader, writer); continue; } - RunState::Disconnect => break, + RunState::Disconnect => { + let final_framed = unsplit_tokio_framed(reader, writer); + return Ok(final_framed); + } } } - - Ok(()) } pub fn set_credentials(&mut self, creds: Option) { @@ -986,6 +1763,28 @@ impl RdpServer { } } +/// Encode a server-initiated Auto-Detect Request PDU for the MCS message channel. +/// +/// The request is framed by a Basic Security Header (SEC_AUTODETECT_REQ) per +/// [MS-RDPBCGR] 2.2.14.3 and carried in an MCS Send Data Indication on the +/// negotiated message channel, not as a Share Data PDU on the I/O channel. +fn encode_autodetect_request( + request: rdp::autodetect::AutoDetectRequest, + message_channel_id: u16, + user_channel_id: u16, +) -> Result> { + // Auto-detect rides the MCS message channel framed by a Basic Security + // Header (SEC_AUTODETECT_REQ), not a Share Control / Share Data header. + let pdu = rdp::autodetect::AutoDetectReqPdu::new(request); + let user_data = encode_vec(&pdu)?.into(); + let mcs_pdu = SendDataIndication { + initiator_id: user_channel_id, + channel_id: message_channel_id, + user_data, + }; + Ok(encode_vec(&X224(mcs_pdu))?) +} + async fn deactivate_all( io_channel_id: u16, user_channel_id: u16, @@ -1008,6 +1807,29 @@ async fn deactivate_all( Ok(()) } +/// Send a `ServerSetErrorInfoPdu(ServerDeniedConnection)` to the client, then return. +/// +/// Used to deny a connection after credential validation rejects it, mirroring the +/// acceptor's exact-match denial so both paths refuse the same spec-defined way. +async fn send_access_denied( + io_channel_id: u16, + user_channel_id: u16, + writer: &mut impl FramedWrite, +) -> Result<(), anyhow::Error> { + let info = ServerSetErrorInfoPdu(ErrorInfo::ProtocolIndependentCode( + ProtocolIndependentCode::ServerDeniedConnection, + )); + let user_data = encode_vec(&info)?.into(); + let pdu = SendDataIndication { + initiator_id: user_channel_id, + channel_id: io_channel_id, + user_data, + }; + let msg = encode_vec(&X224(pdu))?; + writer.write_all(&msg).await?; + Ok(()) +} + struct SharedWriter<'w, W: FramedWrite> { writer: Rc>, } @@ -1025,7 +1847,7 @@ where W: FramedWrite, { type WriteAllFut<'write> - = core::pin::Pin> + 'write>> + = core::pin::Pin> + 'write>> where Self: 'write; diff --git a/crates/ironrdp-session/CHANGELOG.md b/crates/ironrdp-session/CHANGELOG.md index da95cfd06a..240f41962d 100644 --- a/crates/ironrdp-session/CHANGELOG.md +++ b/crates/ironrdp-session/CHANGELOG.md @@ -6,6 +6,155 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.11.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.10.0...ironrdp-session-v0.11.0)] - 2026-07-10 + +### Security + +- [**breaking**] Send NetworkAutoDetect over the MCS message channel ([#1348](https://github.com/Devolutions/IronRDP/issues/1348)) ([8a1fd0118e](https://github.com/Devolutions/IronRDP/commit/8a1fd0118e0bac214c9050b6ca6b36a040046dd3)) + + Corrects Network Auto-Detect framing and routing to match MS-RDPBCGR by + moving it off the I/O channel slow-path Share Data PDUs and onto the MCS + message channel with the required Basic Security Header + (SEC_AUTODETECT_REQ / SEC_AUTODETECT_RSP). This aligns IronRDP with + mstsc/xfreerdp behavior and enables both connect-time and continuous + auto-detection to actually function. + +### Bug Fixes + +- Propagate caller location through error constructor helpers ([#1392](https://github.com/Devolutions/IronRDP/issues/1392)) ([d6990d81a1](https://github.com/Devolutions/IronRDP/commit/d6990d81a17e8349e52768ad8a82f673b1e1462d)) + + The error constructor helpers in several crates wrap the #[track_caller] + ironrdp_error::Error::new, but were not themselves marked + #[track_caller]. As a result, the captured location pointed at the + helper body instead of the real call site, giving misleading "@ + file:line" info in error reports. + +- Reduce dependency on ironrdp-connector ([#1419](https://github.com/Devolutions/IronRDP/issues/1419)) ([5c22f86a71](https://github.com/Devolutions/IronRDP/commit/5c22f86a7150bc10c26a3be39bfaebf84c67d781)) + + Drops session's reliance on ironrdp-connector legacy helpers, now sourced from ironrdp-pdu. + +- [**breaking**] Remove ironrdp-connector dependency ([#1435](https://github.com/Devolutions/IronRDP/issues/1435)) ([c6a0286dcb](https://github.com/Devolutions/IronRDP/commit/c6a0286dcb49d9ac54c65c4f9325b41e05d541b8)) + + Removes the last ironrdp-connector coupling from ironrdp-session by + turning Deactivate-All handling into a bare signal and shifting ownership + of the Deactivation-Reactivation activation sequence back to each consumer. + It introduces a ConnectionActivationFactory (fresh sequence per reactivation) + and an ActiveStageBuilder so session construction no longer depends on + ConnectionResult. + + + +## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.9.0...ironrdp-session-v0.10.0)] - 2026-06-05 + +### Bug Fixes + +- Decode RGBA QOI bitmaps instead of dropping the frame ([#1341](https://github.com/Devolutions/IronRDP/issues/1341)) ([ef20ea4e90](https://github.com/Devolutions/IronRDP/commit/ef20ea4e90455d6c6db0d3521f6522d1e960c0bb)) + + Fixes the client-side QOI decode path in ironrdp-session so RGBA-channel QOI frames are decoded and applied to the framebuffer instead of being dropped, improving interoperability with third-party RDP servers and older ironrdp-server builds that emit RGBA QOI. + +### Build + +- [**breaking**] Update `ironrdp-dvc` public dependency + + + +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.8.0...ironrdp-session-v0.9.0)] - 2026-05-27 + +### Features + +- Dispatch multitransport PDUs on IO channel ([#1096](https://github.com/Devolutions/IronRDP/issues/1096)) ([7853e3cc6f](https://github.com/Devolutions/IronRDP/commit/7853e3cc6f26acaf3da000c6177ca3cef6ef85fd)) + + `decode_io_channel()` assumes all IO channel PDUs begin with + a`ShareControlHeader`. Multitransport Request PDUs use a + `BasicSecurityHeader` with `SEC_TRANSPORT_REQ` instead ([MS-RDPBCGR] + 2.2.15.1). + + This adds a peek-based dispatch: check the first `u16` + for`TRANSPORT_REQ`, decode as `MultitransportRequestPdu` if set, + otherwise fall through to the existing `decode_share_control()` path + unchanged. + + The new variant is propagated through `ProcessorOutput` and + 'ActiveStageOutput` so applications can handle multitransport requests. + Client and web consumers log the request (no UDP transport yet). + +- Add bulk compression and wire negotiation ([ebf5da5f33](https://github.com/Devolutions/IronRDP/commit/ebf5da5f3380a3355f6c95814d669f8190425ded)) + + - add ironrdp-bulk crate with MPPC/NCRUSH/XCRUSH, bitstream, benches, and metrics + - advertise compression in Client Info and plumb compression_type through connector + - decode compressed FastPath/ShareData updates using BulkCompressor + - update CLI to numeric compression flags (enabled by default, level 0-3) + - extend screenshot example with compression options and negotiated logging + - refresh tests, FFI/web configs, typos, and Cargo.lock + +- Complete pixel format support for bitmap updates ([#1134](https://github.com/Devolutions/IronRDP/issues/1134)) ([a6b41093ce](https://github.com/Devolutions/IronRDP/commit/a6b41093ce4ece081d2538c157f6bc547c3b2607)) + + Wires missing bitmap pixel formats (8/15/24bpp) into the session rendering + pipeline so bitmap updates at those depths are rendered instead of being + dropped, and adds fast-path palette update parsing to support 8bpp indexed + color sessions. + +- Handle Auto-Detect Request PDUs from server ([#1178](https://github.com/Devolutions/IronRDP/issues/1178)) ([4dcad09980](https://github.com/Devolutions/IronRDP/commit/4dcad09980e4f5354e4e435a134cc0956e2fcf9e)) + + Fixes a crash when the server sends Auto-Detect Request PDUs during an + active session. After #1176 added ShareDataPdu::AutoDetectReq routing, + these PDUs decode correctly but hit the catch-all error path in the x224 + processor: "unhandled PDU: Auto-Detect Request PDU". + +- Handle slow-path graphics and pointer updates ([#1132](https://github.com/Devolutions/IronRDP/issues/1132)) ([9383380292](https://github.com/Devolutions/IronRDP/commit/938338029290f1be82a7f784d544bb77ac797aeb)) + + Adds support for slow-path graphics and pointer updates to IronRDP, fixing connectivity issues with servers like XRDP that use slow-path output instead of fast-path. The implementation parses slow-path framing headers and routes the inner payload structures through the existing fast-path processing pipeline by extracting shared bitmap and pointer processing methods. + +### Bug Fixes + +- Fix pixel format handling in bitmap decoders ([#1101](https://github.com/Devolutions/IronRDP/issues/1101)) ([75863245ab](https://github.com/Devolutions/IronRDP/commit/75863245ab376f15e35c00df434860c93b123633)) + +- Handle row padding in uncompressed bitmap updates ([4262ae75ff](https://github.com/Devolutions/IronRDP/commit/4262ae75ffa5cb1fabb4ca07d598e33d855e8fdd)) + + Uncompressed bitmap data has rows padded to 4-byte boundaries per + [MS-RDPBCGR] 2.2.9.1.1.3.1.2.2, but the bitmap apply functions + expect tightly packed pixel data. Strip the per-row padding before + passing raw bitmap data to the apply functions. + + This fixes garbled bitmap rendering when connecting to servers that + send uncompressed bitmaps with non-aligned row widths, such as XRDP + at 16 bpp. + +- Skip bitmap updates that exceed bounds ([#1146](https://github.com/Devolutions/IronRDP/issues/1146)) ([2b97a95e6d](https://github.com/Devolutions/IronRDP/commit/2b97a95e6da8833e8a84e9f42960da91eee87cd6)) + + After a desktop resize, an RDP server can send a burst of bitmap updates + for the old resolution before its rendering pipeline has fully + transitioned to the new one. These updates reference coordinates beyond + the current image buffer in `DecodedImage`, causing index-out-of-bounds + panics in the `apply_*` methods. On the server side, the same stale + bitmaps can reach the encoder with dimensions exceeding the negotiated + desktop size, panicking in `NoneHandler::handle()`. + + This commit adds bounds checks at two levels: + - `DecodedImage::rect_fits()` guard at the entry of each `apply_*` + method, returning an empty rectangle when the update doesn't fit + - Encoder-level guard in `EncoderIter::next()` that drops + `BitmapUpdate`s exceeding the current desktop size + +- Propagate negotiated share_id to all outgoing ShareDataPdu ([#1147](https://github.com/Devolutions/IronRDP/issues/1147)) ([2b24e9664d](https://github.com/Devolutions/IronRDP/commit/2b24e9664dd05620ff63a24d092377477fdde863)) + +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.7.0...ironrdp-session-v0.8.0)] - 2025-12-18 + + +## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.5.0...ironrdp-session-v0.6.0)] - 2025-08-29 + +### Features + +- Add QOI image codec ([613fd51f26](https://github.com/Devolutions/IronRDP/commit/613fd51f26315d8212662c46f8e625c541e4bb59)) + + The Quite OK Image format ([1]) losslessly compresses images to a similar size + of PNG, while offering 20x-50x faster encoding and 3x-4x faster decoding. + +- Add QOIZ image codec ([87df67fdc7](https://github.com/Devolutions/IronRDP/commit/87df67fdc76ff4f39d4b83521e34bf3b5e2e73bb)) + + Add a new QOIZ codec for SetSurface command. The PDU data contains the same + data as the QOI codec, with zstd compression. + ## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.4.0...ironrdp-session-v0.4.1)] - 2025-06-27 ### Features @@ -58,7 +207,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-session-v0.2.0...ironrdp-session-v0.2.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-session/Cargo.toml b/crates/ironrdp-session/Cargo.toml index e498c3eaf1..dd29ab906b 100644 --- a/crates/ironrdp-session/Cargo.toml +++ b/crates/ironrdp-session/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-session" -version = "0.5.0" +version = "0.11.0" readme = "README.md" description = "State machines to drive an RDP session" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -21,14 +22,14 @@ qoi = ["dep:qoicoubeh", "ironrdp-pdu/qoi"] qoiz = ["dep:zstd-safe", "qoi"] [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.6" } # public # TODO: at some point, this dependency could be removed (good for compilation speed) -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4" } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.3" } # public -ironrdp-error = { path = "../ironrdp-error", version = "0.1" } # public -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.4" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5", features = ["std"] } # public -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.3" } +ironrdp-bulk = { path = "../ironrdp-bulk", version = "0.1" } +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8" } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8" } # public +ironrdp-error = { path = "../ironrdp-error", version = "0.2" } # public +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.9" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["std"] } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.8" } tracing = { version = "0.1", features = ["log"] } qoicoubeh = { version = "0.5", optional = true } zstd-safe = { version = "7.2", optional = true, features = ["std"] } diff --git a/crates/ironrdp-session/src/active_stage.rs b/crates/ironrdp-session/src/active_stage.rs index 234071aecb..f1b712eb25 100644 --- a/crates/ironrdp-session/src/active_stage.rs +++ b/crates/ironrdp-session/src/active_stage.rs @@ -1,21 +1,34 @@ use std::sync::Arc; -use ironrdp_connector::connection_activation::ConnectionActivationSequence; -use ironrdp_connector::ConnectionResult; -use ironrdp_core::WriteBuf; +use ironrdp_bulk::BulkCompressor; +use ironrdp_core::{ReadCursor, WriteBuf}; use ironrdp_displaycontrol::client::DisplayControlClient; use ironrdp_dvc::{DrdynvcClient, DvcProcessor, DynamicVirtualChannel}; use ironrdp_graphics::pointer::DecodedPointer; use ironrdp_pdu::geometry::InclusiveRectangle; use ironrdp_pdu::input::fast_path::{FastPathInput, FastPathInputEvent}; +use ironrdp_pdu::rdp::autodetect::AutoDetectRequest; +use ironrdp_pdu::rdp::client_info::CompressionType as PduCompressionType; use ironrdp_pdu::rdp::headers::ShareDataPdu; -use ironrdp_pdu::{mcs, Action}; -use ironrdp_svc::{SvcMessage, SvcProcessor, SvcProcessorMessages}; -use tracing::debug; +use ironrdp_pdu::rdp::multitransport::MultitransportRequestPdu; +use ironrdp_pdu::slow_path::{self, GraphicsUpdateType}; +use ironrdp_pdu::{Action, mcs}; +use ironrdp_svc::{StaticChannelSet, SvcMessage, SvcProcessor, SvcProcessorMessages}; +use tracing::{debug, info, warn}; use crate::fast_path::UpdateKind; use crate::image::DecodedImage; -use crate::{fast_path, x224, SessionError, SessionErrorExt as _, SessionResult}; +use crate::{SessionError, SessionErrorExt as _, SessionResult, fast_path, x224}; + +/// Converts the PDU-layer compression type to the bulk crate's compression type. +fn to_bulk_compression_type(ct: PduCompressionType) -> ironrdp_bulk::CompressionType { + match ct { + PduCompressionType::K8 => ironrdp_bulk::CompressionType::Rdp4, + PduCompressionType::K64 => ironrdp_bulk::CompressionType::Rdp5, + PduCompressionType::Rdp6 => ironrdp_bulk::CompressionType::Rdp6, + PduCompressionType::Rdp61 => ironrdp_bulk::CompressionType::Rdp61, + } +} pub struct ActiveStage { x224_processor: x224::Processor, @@ -23,30 +36,79 @@ pub struct ActiveStage { enable_server_pointer: bool, } -impl ActiveStage { - pub fn new(connection_result: ConnectionResult) -> Self { +/// Builder for [`ActiveStage`]. +/// +/// All fields are required; they are typically taken straight from `ironrdp-connector`’s +/// `ConnectionResult` once the connection sequence is finalized. +pub struct ActiveStageBuilder { + pub static_channels: StaticChannelSet, + pub user_channel_id: u16, + pub io_channel_id: u16, + pub message_channel_id: Option, + pub share_id: u32, + /// The bulk compression type that was negotiated, if any. + pub compression_type: Option, + /// Enable server-side pointer updates (client-side pointer rendering). + pub enable_server_pointer: bool, + /// Use software rendering mode for pointer bitmap generation. + pub pointer_software_rendering: bool, +} + +impl ActiveStageBuilder { + pub fn build(self) -> ActiveStage { + let Self { + static_channels, + user_channel_id, + io_channel_id, + message_channel_id, + share_id, + compression_type, + enable_server_pointer, + pointer_software_rendering, + } = self; + let x224_processor = x224::Processor::new( - connection_result.static_channels, - connection_result.user_channel_id, - connection_result.io_channel_id, - connection_result.connection_activation, + static_channels, + user_channel_id, + io_channel_id, + message_channel_id, + share_id, ); + // Create bulk decompressor if compression was negotiated + let bulk_decompressor = compression_type.and_then(|ct| { + let bulk_ct = to_bulk_compression_type(ct); + match BulkCompressor::new(bulk_ct) { + Ok(compressor) => { + info!(compression_type = %bulk_ct, "Bulk decompressor initialized for FastPath"); + Some(compressor) + } + Err(e) => { + tracing::error!(error = %e, "Failed to create bulk decompressor, compression disabled"); + None + } + } + }); + let fast_path_processor = fast_path::ProcessorBuilder { - io_channel_id: connection_result.io_channel_id, - user_channel_id: connection_result.user_channel_id, - enable_server_pointer: connection_result.enable_server_pointer, - pointer_software_rendering: connection_result.pointer_software_rendering, + io_channel_id, + user_channel_id, + share_id, + enable_server_pointer, + pointer_software_rendering, + bulk_decompressor, } .build(); - Self { + ActiveStage { x224_processor, fast_path_processor, - enable_server_pointer: connection_result.enable_server_pointer, + enable_server_pointer, } } +} +impl ActiveStage { pub fn update_mouse_pos(&mut self, x: u16, y: u16) { self.fast_path_processor.update_mouse_pos(x, y); } @@ -68,7 +130,7 @@ impl ActiveStage { // Encoding fastpath response frame // PERF: unnecessary copy - let fastpath_input = FastPathInput(events.to_vec()); + let fastpath_input = FastPathInput::new(events.to_vec()).map_err(SessionError::decode)?; let frame = ironrdp_core::encode_vec(&fastpath_input).map_err(SessionError::encode)?; output.push(ActiveStageOutput::ResponseFrame(frame)); @@ -115,13 +177,27 @@ impl ActiveStage { ) } Action::X224 => { - let outputs = self - .x224_processor - .process(frame)? - .into_iter() - .map(TryFrom::try_from) - .collect::, _>>()?; - (outputs, Vec::new()) + let x224_outputs = self.x224_processor.process(frame)?; + let mut stage_outputs = Vec::new(); + let mut processor_updates = Vec::new(); + + for output in x224_outputs { + match output { + x224::ProcessorOutput::GraphicsUpdate(data) => { + let updates = process_slow_path_graphics(&mut self.fast_path_processor, image, &data)?; + processor_updates.extend(updates); + } + x224::ProcessorOutput::PointerUpdate(data) => { + let updates = process_slow_path_pointer(&mut self.fast_path_processor, image, &data)?; + processor_updates.extend(updates); + } + other => { + stage_outputs.push(ActiveStageOutput::try_from(other)?); + } + } + } + + (stage_outputs, processor_updates) } }; @@ -153,6 +229,12 @@ impl ActiveStage { self.fast_path_processor = processor; } + /// Updates the share_id used by the x224 processor for encoding ShareDataPdu. + /// Must be called during Deactivation-Reactivation if the server assigns a new share_id. + pub fn set_share_id(&mut self, share_id: u32) { + self.x224_processor.set_share_id(share_id); + } + pub fn set_enable_server_pointer(&mut self, enable_server_pointer: bool) { self.enable_server_pointer = enable_server_pointer; } @@ -224,9 +306,8 @@ impl ActiveStage { physical_dims: Option<(u32, u32)>, ) -> Option>> { if let Some(dvc) = self.get_dvc::() { - if dvc.is_open() { + if let Some(channel_id) = dvc.channel_id() { let display_control = dvc.channel_processor_downcast_ref::()?; - let channel_id = dvc.channel_id().unwrap(); // Safe to unwrap, as we checked if the channel is open let svc_messages = match display_control.encode_single_primary_monitor( channel_id, width, @@ -262,10 +343,32 @@ pub enum ActiveStageOutput { GraphicsUpdate(InclusiveRectangle), PointerDefault, PointerHidden, - PointerPosition { x: u16, y: u16 }, + PointerPosition { + x: u16, + y: u16, + }, PointerBitmap(Arc), Terminate(GracefulDisconnectReason), - DeactivateAll(Box), + /// Received a Server Deactivate All PDU. The consumer should execute the [Deactivation-Reactivation Sequence]. + /// + /// [Deactivation-Reactivation Sequence]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 + DeactivateAll, + /// Server Initiate Multitransport Request. The application should establish a + /// sideband UDP transport using the provided request parameters. + /// + /// See [\[MS-RDPBCGR\] 2.2.15.1]. + /// + /// [\[MS-RDPBCGR\] 2.2.15.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/de783158-8b01-4818-8fb0-62523a5b3490 + MultitransportRequest(MultitransportRequestPdu), + /// Server-reported network characteristics ([\[MS-RDPBCGR\] 2.2.14.1.5]). + /// + /// Contains an [`AutoDetectRequest::NetworkCharacteristicsResult`] with + /// RTT and/or bandwidth measurements computed by the server. + /// + /// See [\[MS-RDPBCGR\] 2.2.14.1.5]. + /// + /// [\[MS-RDPBCGR\] 2.2.14.1.5]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/228ffc5c-b60c-4d3e-9781-ac613f822fdf + AutoDetect(AutoDetectRequest), } impl TryFrom for ActiveStageOutput { @@ -286,7 +389,14 @@ impl TryFrom for ActiveStageOutput { Ok(Self::Terminate(desc)) } - x224::ProcessorOutput::DeactivateAll(cas) => Ok(Self::DeactivateAll(cas)), + x224::ProcessorOutput::DeactivateAll => Ok(Self::DeactivateAll), + x224::ProcessorOutput::MultitransportRequest(pdu) => Ok(Self::MultitransportRequest(pdu)), + x224::ProcessorOutput::AutoDetect(request) => Ok(Self::AutoDetect(request)), + // GraphicsUpdate and PointerUpdate are consumed in ActiveStage::process() + // before reaching this conversion. + x224::ProcessorOutput::GraphicsUpdate(_) | x224::ProcessorOutput::PointerUpdate(_) => Err( + SessionError::general("slow-path graphics/pointer updates should be handled before this conversion"), + ), } } } @@ -315,3 +425,45 @@ impl core::fmt::Display for GracefulDisconnectReason { f.write_str(&self.description()) } } + +/// Parse and process a slow-path graphics update through the shared bitmap pipeline. +fn process_slow_path_graphics( + fast_path_processor: &mut fast_path::Processor, + image: &mut DecodedImage, + data: &[u8], +) -> SessionResult> { + let mut src = ReadCursor::new(data); + let update_type = slow_path::read_graphics_update_type(&mut src).map_err(SessionError::decode)?; + + match update_type { + GraphicsUpdateType::Bitmap => { + let bitmap = slow_path::decode_slow_path_bitmap(&mut src).map_err(SessionError::decode)?; + fast_path_processor.process_bitmap_update(image, bitmap) + } + GraphicsUpdateType::Orders => { + warn!("Slow-path drawing orders not supported (MS-RDPEGDI)"); + Ok(Vec::new()) + } + GraphicsUpdateType::Palette => { + warn!("Slow-path palette update not supported (8bpp)"); + Ok(Vec::new()) + } + // Synchronize is an artifact from the T.128 multipoint protocol + // and carries no data. Safe to ignore. + GraphicsUpdateType::Synchronize => { + debug!("Ignoring slow-path synchronize update"); + Ok(Vec::new()) + } + } +} + +/// Parse and process a slow-path pointer update through the shared pointer pipeline. +fn process_slow_path_pointer( + fast_path_processor: &mut fast_path::Processor, + image: &mut DecodedImage, + data: &[u8], +) -> SessionResult> { + let mut src = ReadCursor::new(data); + let pointer = slow_path::decode_slow_path_pointer(&mut src).map_err(SessionError::decode)?; + fast_path_processor.process_pointer_update(image, pointer) +} diff --git a/crates/ironrdp-session/src/fast_path.rs b/crates/ironrdp-session/src/fast_path.rs index 0a8811b731..14136c5459 100644 --- a/crates/ironrdp-session/src/fast_path.rs +++ b/crates/ironrdp-session/src/fast_path.rs @@ -1,22 +1,25 @@ use std::sync::Arc; -use ironrdp_core::{decode_cursor, DecodeErrorKind, ReadCursor, WriteBuf}; +use ironrdp_bulk::BulkCompressor; +use ironrdp_core::{DecodeErrorKind, ReadCursor, WriteBuf, decode_cursor}; use ironrdp_graphics::image_processing::PixelFormat; use ironrdp_graphics::pointer::{DecodedPointer, PointerBitmapTarget}; use ironrdp_graphics::rdp6::BitmapStreamDecoder; use ironrdp_graphics::rle::RlePixelFormat; +use ironrdp_pdu::bitmap::BitmapUpdateData; use ironrdp_pdu::codecs::rfx::FrameAcknowledgePdu; use ironrdp_pdu::fast_path::{FastPathHeader, FastPathUpdate, FastPathUpdatePdu, Fragmentation}; use ironrdp_pdu::geometry::{InclusiveRectangle, Rectangle as _}; use ironrdp_pdu::pointer::PointerUpdateData; -use ironrdp_pdu::rdp::capability_sets::{CodecId, CODEC_ID_NONE, CODEC_ID_REMOTEFX}; -use ironrdp_pdu::rdp::headers::ShareDataPdu; +use ironrdp_pdu::rdp::capability_sets::{CODEC_ID_NONE, CODEC_ID_REMOTEFX, CodecId}; +use ironrdp_pdu::rdp::headers::{CompressionFlags, ShareDataPdu}; use ironrdp_pdu::surface_commands::{FrameAction, FrameMarkerPdu, SurfaceCommand}; use tracing::{debug, trace, warn}; use crate::image::DecodedImage; +use crate::palette::Palette; use crate::pointer::PointerCache; -use crate::{custom_err, reason_err, rfx, SessionError, SessionErrorExt as _, SessionResult}; +use crate::{SessionError, SessionErrorExt as _, SessionResult, custom_err, reason_err, rfx}; #[derive(Debug)] pub enum UpdateKind { @@ -38,6 +41,11 @@ pub struct Processor { mouse_pos_update: Option<(u16, u16)>, enable_server_pointer: bool, pointer_software_rendering: bool, + /// Bulk decompressor for server-to-client compressed PDUs. + /// `None` when compression was not negotiated. + bulk_decompressor: Option, + /// Current 8bpp color palette. Updated by Palette fast-path updates. + palette: Palette, #[cfg(feature = "qoiz")] zdctx: zstd_safe::DCtx<'static>, } @@ -67,17 +75,71 @@ impl Processor { let header = decode_cursor::(&mut input).map_err(SessionError::decode)?; trace!(fast_path_header = ?header, "Received Fast-Path packet"); - let update_pdu = decode_cursor::>(&mut input).map_err(SessionError::decode)?; + // A single FastPath output PDU can contain multiple updates. + // Loop over all updates within the PDU payload. + while !input.is_empty() { + let update_result = self.process_single_update(&mut input, image, output)?; + processor_updates.extend(update_result); + } + + Ok(processor_updates) + } + + /// Process a single FastPath update from the cursor, advancing past it. + fn process_single_update( + &mut self, + input: &mut ReadCursor<'_>, + image: &mut DecodedImage, + output: &mut WriteBuf, + ) -> SessionResult> { + let mut processor_updates = Vec::new(); + + let update_pdu = decode_cursor::>(input).map_err(SessionError::decode)?; trace!(fast_path_update_fragmentation = ?update_pdu.fragmentation); - let processed_complete_data = self - .complete_data - .process_data(update_pdu.data, update_pdu.fragmentation); + // Decompress the payload if the server sent it compressed. + let decompressed_data; + let payload = if let Some(flags) = update_pdu.compression_flags { + if flags.contains(CompressionFlags::COMPRESSED) || flags.contains(CompressionFlags::FLUSHED) { + let bulk_flags = + u32::from(flags.bits()) | u32::from(update_pdu.compression_type.map_or(0, |ct| ct.as_u8())); + + if let Some(ref mut decompressor) = self.bulk_decompressor { + let decompressed = decompressor + .decompress(update_pdu.data, bulk_flags) + .map_err(|e| reason_err!("FastPath", "bulk decompression failed: {}", e))?; + // Copy decompressed data before accessing metrics (releases the mutable borrow). + decompressed_data = decompressed.to_vec(); + debug!( + compressed_size = update_pdu.data.len(), + decompressed_size = decompressed_data.len(), + compression_type = ?update_pdu.compression_type, + compression_ratio = format_args!("{:.2}x", decompressor.compression_ratio()), + total_compressed = decompressor.total_compressed_bytes(), + total_uncompressed = decompressor.total_uncompressed_bytes(), + "Decompressed FastPath update" + ); + decompressed_data.as_slice() + } else { + warn!("Received compressed FastPath data but no decompressor is configured"); + update_pdu.data + } + } else { + // Compression flags present but COMPRESSED bit not set — pass data through. + // Still need to inform the decompressor of FLUSHED/AT_FRONT flags even + // without compressed payload. + update_pdu.data + } + } else { + update_pdu.data + }; + + let processed_complete_data = self.complete_data.process_data(payload, update_pdu.fragmentation); let update_code = update_pdu.update_code; let Some(data) = processed_complete_data else { - return Ok(Vec::new()); + return Ok(processor_updates); }; let update = FastPathUpdate::decode_with_code(data.as_slice(), update_code); @@ -90,216 +152,283 @@ impl Processor { } Ok(FastPathUpdate::Bitmap(bitmap_update)) => { trace!("Received bitmap update"); + let updates = self.process_bitmap_update(image, bitmap_update)?; + processor_updates.extend(updates); + } + Ok(FastPathUpdate::Pointer(update)) => { + let updates = self.process_pointer_update(image, update)?; + processor_updates.extend(updates); + } + Ok(FastPathUpdate::Palette(palette_data)) => { + trace!("Received palette update"); + self.palette.process_update(palette_data); + } + Err(e) => { + // FIXME: This seems to be a way of special-handling the error case in FastPathUpdate::decode_cursor_with_code + // to ignore the unsupported update PDUs, but this is a fragile logic and the rationale behind it is not + // obvious. + if let DecodeErrorKind::InvalidField { field, reason } = e.kind() { + warn!(field, reason, "Received invalid Fast-Path update"); + processor_updates.push(UpdateKind::None); + } else { + return Err(custom_err!("Fast-Path", e)); + } + } + }; - let mut buf = Vec::new(); - let mut update_kind = UpdateKind::None; - - for update in bitmap_update.rectangles { - trace!("{update:?}"); - buf.clear(); - - // Bitmap data is either compressed or uncompressed, depending - // on whether the BITMAP_COMPRESSION flag is present in the - // flags field. - let update_rectangle = if update - .compression_flags - .contains(ironrdp_pdu::bitmap::Compression::BITMAP_COMPRESSION) - { - if update.bits_per_pixel == 32 { - // Compressed bitmaps at a color depth of 32 bpp are compressed using RDP 6.0 - // Bitmap Compression and stored inside an RDP 6.0 Bitmap Compressed Stream - // structure ([MS-RDPEGDI] section 2.2.2.5.1). - debug!("32 bpp compressed RDP6_BITMAP_STREAM"); - - match self.bitmap_stream_decoder.decode_bitmap_stream_to_rgb24( - update.bitmap_data, - &mut buf, - usize::from(update.width), - usize::from(update.height), - ) { - Ok(()) => image.apply_rgb24(&buf, &update.rectangle, true)?, - Err(err) => { - warn!("Invalid RDP6_BITMAP_STREAM: {err}"); - update.rectangle.clone() - } - } - } else { - // Compressed bitmaps not in 32 bpp format are compressed using Interleaved - // RLE and encapsulated in an RLE Compressed Bitmap Stream structure (section - // 2.2.9.1.1.3.1.2.4). - debug!(bpp = update.bits_per_pixel, "Non-32 bpp compressed RLE_BITMAP_STREAM",); - - match ironrdp_graphics::rle::decompress( - update.bitmap_data, - &mut buf, - usize::from(update.width), - usize::from(update.height), - usize::from(update.bits_per_pixel), - ) { - Ok(RlePixelFormat::Rgb16) => image.apply_rgb16_bitmap(&buf, &update.rectangle)?, - - // TODO: support other pixel formats… - Ok(format @ (RlePixelFormat::Rgb8 | RlePixelFormat::Rgb15 | RlePixelFormat::Rgb24)) => { - warn!("Received RLE-compressed bitmap with unsupported color depth: {format:?}"); - update.rectangle.clone() - } + Ok(processor_updates) + } - Err(e) => { - warn!("Invalid RLE-compressed bitmap: {e}"); - update.rectangle.clone() - } - } - } - } else { - // Uncompressed bitmap data is formatted as a bottom-up, left-to-right series of - // pixels. Each pixel is a whole number of bytes. Each row contains a multiple of - // four bytes (including up to three bytes of padding, as necessary). - trace!("Uncompressed raw bitmap"); - - match update.bits_per_pixel { - 16 => image.apply_rgb16_bitmap(update.bitmap_data, &update.rectangle)?, - // TODO: support other pixel formats… - unsupported => { - warn!("Invalid raw bitmap with {unsupported} bytes per pixels"); - update.rectangle.clone() - } + /// Process a bitmap update, shared between fast-path and slow-path pipelines. + pub fn process_bitmap_update( + &mut self, + image: &mut DecodedImage, + bitmap_update: BitmapUpdateData<'_>, + ) -> SessionResult> { + let mut buf = Vec::new(); + let mut update_kind = UpdateKind::None; + + for update in bitmap_update.rectangles { + trace!("{update:?}"); + buf.clear(); + + // The apply_* functions use the destination rectangle width as the + // source row stride. Some servers (e.g. xrdp) align the bitmap width + // up to a multiple of 4, making update.width wider than the rectangle; + // the extra right-hand columns must be trimmed or every row is read + // short and the image shears diagonally. + let bmp_width = usize::from(update.width); + let rect_width = usize::from(update.rectangle.width()); + + // Bitmap data is either compressed or uncompressed, depending + // on whether the BITMAP_COMPRESSION flag is present in the + // flags field. + let update_rectangle = if update + .compression_flags + .contains(ironrdp_pdu::bitmap::Compression::BITMAP_COMPRESSION) + { + if update.bits_per_pixel == 32 { + // Compressed bitmaps at a color depth of 32 bpp are compressed using RDP 6.0 + // Bitmap Compression and stored inside an RDP 6.0 Bitmap Compressed Stream + // structure ([MS-RDPEGDI] section 2.2.2.5.1). + debug!("32 bpp compressed RDP6_BITMAP_STREAM"); + + match self.bitmap_stream_decoder.decode_bitmap_stream_to_rgb24( + update.bitmap_data, + &mut buf, + usize::from(update.width), + usize::from(update.height), + ) { + Ok(()) => { + narrow_rows_in_place(&mut buf, bmp_width, rect_width, 3); + image.apply_rgb24(&buf, &update.rectangle, true)? } - }; - - match update_kind { - UpdateKind::Region(current) => { - update_kind = UpdateKind::Region(current.union(&update_rectangle)) + Err(err) => { + warn!("Invalid RDP6_BITMAP_STREAM: {err}"); + update.rectangle.clone() } - _ => update_kind = UpdateKind::Region(update_rectangle), } - } - - processor_updates.push(update_kind); - } - Ok(FastPathUpdate::Pointer(update)) => { - if !self.enable_server_pointer { - return Ok(processor_updates); - } - - let bitmap_target = if self.pointer_software_rendering { - PointerBitmapTarget::Software } else { - PointerBitmapTarget::Accelerated - }; - - match update { - PointerUpdateData::SetHidden => { - processor_updates.push(UpdateKind::PointerHidden); - if self.pointer_software_rendering && !self.use_system_pointer { - self.use_system_pointer = true; - if let Some(rect) = image.hide_pointer()? { - processor_updates.push(UpdateKind::Region(rect)); - } + // Compressed bitmaps not in 32 bpp format are compressed using Interleaved + // RLE and encapsulated in an RLE Compressed Bitmap Stream structure (section + // 2.2.9.1.1.3.1.2.4). + debug!(bpp = update.bits_per_pixel, "Non-32 bpp compressed RLE_BITMAP_STREAM",); + + match ironrdp_graphics::rle::decompress( + update.bitmap_data, + &mut buf, + usize::from(update.width), + usize::from(update.height), + usize::from(update.bits_per_pixel), + ) { + Ok(RlePixelFormat::Rgb16) => { + narrow_rows_in_place(&mut buf, bmp_width, rect_width, 2); + image.apply_rgb16_bitmap(&buf, &update.rectangle)? } - } - PointerUpdateData::SetDefault => { - processor_updates.push(UpdateKind::PointerDefault); - if self.pointer_software_rendering && !self.use_system_pointer { - self.use_system_pointer = true; - if let Some(rect) = image.hide_pointer()? { - processor_updates.push(UpdateKind::Region(rect)); - } + Ok(RlePixelFormat::Rgb15) => { + narrow_rows_in_place(&mut buf, bmp_width, rect_width, 2); + image.apply_rgb15_bitmap(&buf, &update.rectangle)? } - } - PointerUpdateData::SetPosition(position) => { - if self.use_system_pointer || !self.pointer_software_rendering { - processor_updates.push(UpdateKind::PointerPosition { - x: position.x, - y: position.y, - }); - } else if let Some(rect) = image.move_pointer(position.x, position.y)? { - processor_updates.push(UpdateKind::Region(rect)); + Ok(RlePixelFormat::Rgb24) => { + narrow_rows_in_place(&mut buf, bmp_width, rect_width, 3); + image.apply_bgr24_bitmap(&buf, &update.rectangle)? + } + Ok(RlePixelFormat::Rgb8) => { + narrow_rows_in_place(&mut buf, bmp_width, rect_width, 1); + image.apply_rgb8_with_palette(&buf, &update.rectangle, self.palette.colors())? } - } - PointerUpdateData::Color(pointer) => { - let cache_index = pointer.cache_index; - - let decoded_pointer = Arc::new( - DecodedPointer::decode_color_pointer_attribute(&pointer, bitmap_target) - .expect("Failed to decode color pointer attribute"), - ); - - let _ = self - .pointer_cache - .insert(usize::from(cache_index), Arc::clone(&decoded_pointer)); - if !self.pointer_software_rendering { - processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&decoded_pointer))); - } else if let Some(rect) = image.update_pointer(decoded_pointer)? { - processor_updates.push(UpdateKind::Region(rect)); + Err(e) => { + warn!("Invalid RLE-compressed bitmap: {e}"); + update.rectangle.clone() } } - PointerUpdateData::Cached(cached) => { - let cache_index = cached.cache_index; - - if let Some(cached_pointer) = self.pointer_cache.get(usize::from(cache_index)) { - // Disable system pointer - processor_updates.push(UpdateKind::PointerHidden); - self.use_system_pointer = false; - // Send graphics update - if !self.pointer_software_rendering { - processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&cached_pointer))); - } else if let Some(rect) = image.update_pointer(cached_pointer)? { - processor_updates.push(UpdateKind::Region(rect)); - } else { - // In case pointer was hidden previously - if let Some(rect) = image.show_pointer()? { - processor_updates.push(UpdateKind::Region(rect)); - } - } - } else { - warn!("Cached pointer not found {}", cache_index); - } + } + } else { + // Uncompressed bitmap data is formatted as a bottom-up, left-to-right series of + // pixels. Each pixel is a whole number of bytes. Each row contains a multiple of + // four bytes (including up to three bytes of padding, as necessary). + // [MS-RDPBCGR] 2.2.9.1.1.3.1.2.2 + trace!("Uncompressed raw bitmap"); + + let bpp = usize::from(update.bits_per_pixel); + let bytes_per_pixel = bpp.div_ceil(8); + let row_bytes = bmp_width * bytes_per_pixel; + let padded_row_bytes = (row_bytes + 3) & !3; + let dst_row_bytes = rect_width * bytes_per_pixel; + + // Strip per-row 4-byte padding and any right-hand columns beyond + // the destination rectangle, leaving tightly packed rows exactly + // rect_width pixels wide (what the apply functions expect). + buf.clear(); + for row in update.bitmap_data.chunks(padded_row_bytes) { + let end = dst_row_bytes.min(row.len()); + buf.extend_from_slice(&row[..end]); + } + + match update.bits_per_pixel { + 8 => image.apply_rgb8_with_palette(&buf, &update.rectangle, self.palette.colors())?, + 15 => image.apply_rgb15_bitmap(&buf, &update.rectangle)?, + 16 => image.apply_rgb16_bitmap(&buf, &update.rectangle)?, + 24 => image.apply_bgr24_bitmap(&buf, &update.rectangle)?, + 32 => image.apply_rgb32_bitmap(&buf, PixelFormat::BgrX32, &update.rectangle)?, + _ => { + warn!("Unsupported uncompressed bitmap depth: {bpp} bpp"); + update.rectangle.clone() } - PointerUpdateData::New(pointer) => { - let cache_index = pointer.color_pointer.cache_index; + } + }; - let decoded_pointer = Arc::new( - DecodedPointer::decode_pointer_attribute(&pointer, bitmap_target) - .expect("Failed to decode pointer attribute"), - ); + match update_kind { + UpdateKind::Region(current) => update_kind = UpdateKind::Region(current.union(&update_rectangle)), + _ => update_kind = UpdateKind::Region(update_rectangle), + } + } - let _ = self - .pointer_cache - .insert(usize::from(cache_index), Arc::clone(&decoded_pointer)); + Ok(vec![update_kind]) + } - if !self.pointer_software_rendering { - processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&decoded_pointer))); - } else if let Some(rect) = image.update_pointer(decoded_pointer)? { - processor_updates.push(UpdateKind::Region(rect)); - } - } - PointerUpdateData::Large(pointer) => { - let cache_index = pointer.cache_index; + /// Process a pointer update, shared between fast-path and slow-path pipelines. + pub fn process_pointer_update( + &mut self, + image: &mut DecodedImage, + update: PointerUpdateData<'_>, + ) -> SessionResult> { + let mut processor_updates = Vec::new(); - let decoded_pointer: Arc = Arc::new( - DecodedPointer::decode_large_pointer_attribute(&pointer, bitmap_target) - .expect("Failed to decode large pointer attribute"), - ); + if !self.enable_server_pointer { + return Ok(processor_updates); + } - let _ = self - .pointer_cache - .insert(usize::from(cache_index), Arc::clone(&decoded_pointer)); + let bitmap_target = if self.pointer_software_rendering { + PointerBitmapTarget::Software + } else { + PointerBitmapTarget::Accelerated + }; - if !self.pointer_software_rendering { - processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&decoded_pointer))); - } else if let Some(rect) = image.update_pointer(decoded_pointer)? { + match update { + PointerUpdateData::SetHidden => { + processor_updates.push(UpdateKind::PointerHidden); + if self.pointer_software_rendering && !self.use_system_pointer { + self.use_system_pointer = true; + if let Some(rect) = image.hide_pointer()? { + processor_updates.push(UpdateKind::Region(rect)); + } + } + } + PointerUpdateData::SetDefault => { + processor_updates.push(UpdateKind::PointerDefault); + if self.pointer_software_rendering && !self.use_system_pointer { + self.use_system_pointer = true; + if let Some(rect) = image.hide_pointer()? { + processor_updates.push(UpdateKind::Region(rect)); + } + } + } + PointerUpdateData::SetPosition(position) => { + if self.use_system_pointer || !self.pointer_software_rendering { + processor_updates.push(UpdateKind::PointerPosition { + x: position.x, + y: position.y, + }); + } else if let Some(rect) = image.move_pointer(position.x, position.y)? { + processor_updates.push(UpdateKind::Region(rect)); + } + } + PointerUpdateData::Color(pointer) => { + let cache_index = pointer.cache_index; + + let decoded_pointer = Arc::new( + DecodedPointer::decode_color_pointer_attribute(&pointer, bitmap_target) + .map_err(|e| SessionError::custom("failed to decode color pointer attribute", e))?, + ); + + let _ = self + .pointer_cache + .insert(usize::from(cache_index), Arc::clone(&decoded_pointer)); + + if !self.pointer_software_rendering { + processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&decoded_pointer))); + } else if let Some(rect) = image.update_pointer(decoded_pointer)? { + processor_updates.push(UpdateKind::Region(rect)); + } + } + PointerUpdateData::Cached(cached) => { + let cache_index = cached.cache_index; + + if let Some(cached_pointer) = self.pointer_cache.get(usize::from(cache_index)) { + // Disable system pointer + processor_updates.push(UpdateKind::PointerHidden); + self.use_system_pointer = false; + // Send graphics update + if !self.pointer_software_rendering { + processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&cached_pointer))); + } else if let Some(rect) = image.update_pointer(cached_pointer)? { + processor_updates.push(UpdateKind::Region(rect)); + } else { + // In case pointer was hidden previously + if let Some(rect) = image.show_pointer()? { processor_updates.push(UpdateKind::Region(rect)); } } - }; - } - Err(e) => { - if let DecodeErrorKind::InvalidField { field, reason } = e.kind { - warn!(field, reason, "Received invalid Fast-Path update"); - processor_updates.push(UpdateKind::None); } else { - return Err(custom_err!("Fast-Path", e)); + warn!("Cached pointer not found {}", cache_index); + } + } + PointerUpdateData::New(pointer) => { + let cache_index = pointer.color_pointer.cache_index; + + let decoded_pointer = Arc::new( + DecodedPointer::decode_pointer_attribute(&pointer, bitmap_target) + .map_err(|e| SessionError::custom("failed to decode pointer attribute", e))?, + ); + + let _ = self + .pointer_cache + .insert(usize::from(cache_index), Arc::clone(&decoded_pointer)); + + if !self.pointer_software_rendering { + processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&decoded_pointer))); + } else if let Some(rect) = image.update_pointer(decoded_pointer)? { + processor_updates.push(UpdateKind::Region(rect)); + } + } + PointerUpdateData::Large(pointer) => { + let cache_index = pointer.cache_index; + + let decoded_pointer: Arc = Arc::new( + DecodedPointer::decode_large_pointer_attribute(&pointer, bitmap_target) + .map_err(|e| SessionError::custom("failed to decode large pointer attribute", e))?, + ); + + let _ = self + .pointer_cache + .insert(usize::from(cache_index), Arc::clone(&decoded_pointer)); + + if !self.pointer_software_rendering { + processor_updates.push(UpdateKind::PointerBitmap(Arc::clone(&decoded_pointer))); + } else if let Some(rect) = image.update_pointer(decoded_pointer)? { + processor_updates.push(UpdateKind::Region(rect)); } } }; @@ -342,18 +471,22 @@ impl Processor { match codec_id { CODEC_ID_NONE => { let ext_data = bits.extended_bitmap_data; - match ext_data.bpp { - 32 => { - let rectangle = - image.apply_rgb32_bitmap(ext_data.data, PixelFormat::BgrX32, &destination)?; - update_rectangle = update_rectangle - .map(|rect: InclusiveRectangle| rect.union(&rectangle)) - .or(Some(rectangle)); + let rectangle = match ext_data.bpp { + 8 => { + image.apply_rgb8_with_palette(ext_data.data, &destination, self.palette.colors())? } + 15 => image.apply_rgb15_bitmap(ext_data.data, &destination)?, + 16 => image.apply_rgb16_bitmap(ext_data.data, &destination)?, + 24 => image.apply_bgr24_bitmap(ext_data.data, &destination)?, + 32 => image.apply_rgb32_bitmap(ext_data.data, PixelFormat::BgrX32, &destination)?, bpp => { - warn!("Unsupported bpp: {bpp}") + warn!("Unsupported surface CODEC_ID_NONE bpp: {bpp}"); + continue; } - } + }; + update_rectangle = update_rectangle + .map(|rect: InclusiveRectangle| rect.union(&rectangle)) + .or(Some(rectangle)); } CODEC_ID_REMOTEFX => { let mut data = ReadCursor::new(bits.extended_bitmap_data.data); @@ -415,6 +548,30 @@ impl Processor { } } +// narrow_rows_in_place trims a tightly packed, row-major pixel buffer whose +// rows are `src_width_px` pixels wide down to `dst_width_px` pixels, dropping +// the right-hand padding columns some servers add when aligning bitmap width up +// to a multiple of 4. Row order is preserved. No-op when the widths already +// match or the destination is wider. +fn narrow_rows_in_place(buf: &mut Vec, src_width_px: usize, dst_width_px: usize, bytes_per_px: usize) { + if dst_width_px >= src_width_px || src_width_px == 0 { + return; + } + let src_stride = src_width_px * bytes_per_px; + let dst_stride = dst_width_px * bytes_per_px; + if src_stride == 0 { + return; + } + let mut write = 0; + let mut read = 0; + while read + dst_stride <= buf.len() { + buf.copy_within(read..read + dst_stride, write); + write += dst_stride; + read += src_stride; + } + buf.truncate(write); +} + #[cfg(feature = "qoi")] fn qoi_apply( image: &mut DecodedImage, @@ -423,31 +580,55 @@ fn qoi_apply( update_rectangle: &mut Option, ) -> SessionResult<()> { let (header, decoded) = qoi::decode_to_vec(data).map_err(|e| reason_err!("QOI decode", "{}", e))?; - match header.channels { - qoi::Channels::Rgb => { - let rectangle = image.apply_rgb24(&decoded, &destination, false)?; - - *update_rectangle = update_rectangle - .as_ref() - .map(|rect: &InclusiveRectangle| rect.union(&rectangle)) - .or(Some(rectangle)); - } - qoi::Channels::Rgba => { - warn!("Unsupported RGBA QOI data"); - } + + // Guard against a decoded buffer that doesn't match the destination + // rectangle. `apply_rgb24`/`apply_rgba32` derive the row count from the + // decoded length, and the only bounds check downstream (`rect_fits`) + // validates the rectangle against the image, not the buffer against the + // rectangle. A malformed/oversized QOI payload would otherwise drive the + // per-row index past `self.data` and panic (client-side DoS). + let channels = match header.channels { + qoi::Channels::Rgb => 3, + qoi::Channels::Rgba => 4, + }; + let expected = usize::from(destination.width()) * usize::from(destination.height()) * channels; + if decoded.len() != expected { + return Err(reason_err!( + "QOI decode", + "decoded {} bytes, expected {} for {}x{} ({} channels)", + decoded.len(), + expected, + destination.width(), + destination.height(), + channels + )); } + + let rectangle = match header.channels { + qoi::Channels::Rgb => image.apply_rgb24(&decoded, &destination, false)?, + qoi::Channels::Rgba => image.apply_rgba32(&decoded, &destination, false)?, + }; + + *update_rectangle = update_rectangle + .as_ref() + .map(|rect: &InclusiveRectangle| rect.union(&rectangle)) + .or(Some(rectangle)); Ok(()) } pub struct ProcessorBuilder { pub io_channel_id: u16, pub user_channel_id: u16, + pub share_id: u32, /// Ignore server pointer updates. pub enable_server_pointer: bool, /// Use software rendering mode for pointer bitmap generation. When this option is active, /// `UpdateKind::PointerBitmap` will not be generated. Remote pointer will be drawn /// via software rendering on top of the output image. pub pointer_software_rendering: bool, + /// Bulk decompressor for server-to-client compressed PDUs. + /// `None` when compression was not negotiated. + pub bulk_decompressor: Option, } impl ProcessorBuilder { @@ -455,13 +636,15 @@ impl ProcessorBuilder { Processor { complete_data: CompleteData::new(), rfx_handler: rfx::DecodingContext::new(), - marker_processor: FrameMarkerProcessor::new(self.user_channel_id, self.io_channel_id), + marker_processor: FrameMarkerProcessor::new(self.user_channel_id, self.io_channel_id, self.share_id), bitmap_stream_decoder: BitmapStreamDecoder::default(), pointer_cache: PointerCache::default(), use_system_pointer: true, mouse_pos_update: None, enable_server_pointer: self.enable_server_pointer, pointer_software_rendering: self.pointer_software_rendering, + bulk_decompressor: self.bulk_decompressor, + palette: Palette::system_default(), #[cfg(feature = "qoiz")] zdctx: zstd_safe::DCtx::default(), } @@ -524,13 +707,15 @@ impl CompleteData { struct FrameMarkerProcessor { user_channel_id: u16, io_channel_id: u16, + share_id: u32, } impl FrameMarkerProcessor { - fn new(user_channel_id: u16, io_channel_id: u16) -> Self { + fn new(user_channel_id: u16, io_channel_id: u16, share_id: u32) -> Self { Self { user_channel_id, io_channel_id, + share_id, } } @@ -538,16 +723,16 @@ impl FrameMarkerProcessor { match marker.frame_action { FrameAction::Begin => Ok(()), FrameAction::End => { - ironrdp_connector::legacy::encode_share_data( + ironrdp_pdu::rdp::headers::encode_share_data( self.user_channel_id, self.io_channel_id, - 0, + self.share_id, ShareDataPdu::FrameAcknowledge(FrameAcknowledgePdu { frame_id: marker.frame_id.unwrap_or(0), }), output, ) - .map_err(crate::legacy::map_error)?; + .map_err(SessionError::encode)?; Ok(()) } diff --git a/crates/ironrdp-session/src/image.rs b/crates/ironrdp-session/src/image.rs index 9da0379e84..0d420726b8 100644 --- a/crates/ironrdp-session/src/image.rs +++ b/crates/ironrdp-session/src/image.rs @@ -1,14 +1,14 @@ use std::sync::Arc; use ironrdp_core::assert_impl; -use ironrdp_graphics::color_conversion::rdp_16bit_to_rgb; +use ironrdp_graphics::color_conversion::{rdp_15bit_to_rgb, rdp_16bit_to_rgb}; use ironrdp_graphics::image_processing::{ImageRegion, ImageRegionMut, PixelFormat}; use ironrdp_graphics::pointer::DecodedPointer; use ironrdp_graphics::rectangle_processing::Region; use ironrdp_pdu::geometry::{InclusiveRectangle, Rectangle as _}; -use tracing::trace; +use tracing::{debug, trace}; -use crate::{custom_err, SessionResult}; +use crate::{SessionResult, custom_err}; const TILE_SIZE: u16 = 64; @@ -72,7 +72,6 @@ struct PointerRenderingState { } #[expect(clippy::too_many_arguments)] -#[expect(clippy::cast_lossless)] // FIXME fn copy_cursor_data( from: &[u8], from_pos: (usize, usize), @@ -124,11 +123,17 @@ fn copy_cursor_data( continue; } - // Integer alpha blending, source represented as premultiplied alpha color, calculation in floating point - to[to_start + pixel * PIXEL_SIZE] = src_r + (((dest_r as u16) * (255 - src_a) as u16) >> 8) as u8; - to[to_start + pixel * PIXEL_SIZE + 1] = src_g + (((dest_g as u16) * (255 - src_a) as u16) >> 8) as u8; - to[to_start + pixel * PIXEL_SIZE + 2] = src_b + (((dest_b as u16) * (255 - src_a) as u16) >> 8) as u8; - // Framebuffer is always opaque, so we can skip alpha channel change + #[expect(clippy::as_conversions, reason = "(u16 >> 8) fits into u8 + hot loop")] + { + // Integer alpha blending, source represented as premultiplied alpha color, calculation in floating point + to[to_start + pixel * PIXEL_SIZE] = + src_r + ((u16::from(dest_r) * u16::from(255 - src_a)) >> 8) as u8; + to[to_start + pixel * PIXEL_SIZE + 1] = + src_g + ((u16::from(dest_g) * u16::from(255 - src_a)) >> 8) as u8; + to[to_start + pixel * PIXEL_SIZE + 2] = + src_b + ((u16::from(dest_b) * u16::from(255 - src_a)) >> 8) as u8; + // Framebuffer is always opaque, so we can skip alpha channel change + } } } else { to[to_start..to_start + width * PIXEL_SIZE] @@ -195,6 +200,11 @@ impl DecodedImage { self.height } + /// Returns `true` if the rectangle fits entirely within the image bounds. + fn rect_fits(&self, rect: &InclusiveRectangle) -> bool { + rect.right < self.width && rect.bottom < self.height + } + fn apply_pointer_layer(&mut self, layer: PointerLayer) -> SessionResult> { // Pointer is not hidden, but its texture is not visible on the screen, so we don't // need to render it @@ -227,6 +237,13 @@ impl DecodedImage { return Ok(None); } + let pointer_src_rect_width = usize::from(self.pointer_src_rect.width()); + let pointer_src_rect_height = usize::from(self.pointer_src_rect.height()); + let pointer_draw_x = usize::from(self.pointer_draw_x); + let pointer_draw_y = usize::from(self.pointer_draw_y); + let width = usize::from(self.width); + let height = usize::from(self.height); + match &layer { PointerLayer::Background => { if self.pointer_backbuffer.is_empty() { @@ -237,15 +254,12 @@ impl DecodedImage { copy_cursor_data( &self.pointer_backbuffer, (0, 0), - self.pointer_src_rect.width() as usize * 4, + pointer_src_rect_width * 4, &mut self.data, - self.width as usize * 4, - (self.pointer_draw_x as usize, self.pointer_draw_y as usize), - ( - self.pointer_src_rect.width() as usize, - self.pointer_src_rect.height() as usize, - ), - (self.width as usize, self.height as usize), + width * 4, + (pointer_draw_x, pointer_draw_y), + (pointer_src_rect_width, pointer_src_rect_height), + (width, height), false, ); } @@ -254,37 +268,34 @@ impl DecodedImage { let buffer_size = self .pointer_backbuffer .len() - .max(self.pointer_src_rect.width() as usize * self.pointer_src_rect.height() as usize * 4); + .max(pointer_src_rect_width * pointer_src_rect_height * 4); self.pointer_backbuffer.resize(buffer_size, 0); copy_cursor_data( &self.data, - (self.pointer_draw_x as usize, self.pointer_draw_y as usize), - self.width as usize * 4, + (pointer_draw_x, pointer_draw_y), + width * 4, &mut self.pointer_backbuffer, - self.pointer_src_rect.width() as usize * 4, + pointer_src_rect_width * 4, (0, 0), - ( - self.pointer_src_rect.width() as usize, - self.pointer_src_rect.height() as usize, - ), - (self.width as usize, self.height as usize), + (pointer_src_rect_width, pointer_src_rect_height), + (width, height), false, ); // Draw pointer (with compositing) copy_cursor_data( pointer.bitmap_data.as_slice(), - (self.pointer_src_rect.left as usize, self.pointer_src_rect.top as usize), - usize::from(pointer.width) * 4, - &mut self.data, - self.width as usize * 4, - (self.pointer_draw_x as usize, self.pointer_draw_y as usize), ( - self.pointer_src_rect.width() as usize, - self.pointer_src_rect.height() as usize, + usize::from(self.pointer_src_rect.left), + usize::from(self.pointer_src_rect.top), ), - (self.width as usize, self.height as usize), + usize::from(pointer.width) * 4, + &mut self.data, + width * 4, + (pointer_draw_x, pointer_draw_y), + (pointer_src_rect_width, pointer_src_rect_height), + (width, height), true, ); } @@ -312,7 +323,6 @@ impl DecodedImage { } } - #[expect(clippy::cast_possible_wrap)] // FIXME fn recalculate_pointer_geometry(&mut self) { let x = self.pointer_x; let y = self.pointer_y; @@ -322,10 +332,10 @@ impl DecodedImage { _ => return, }; - let left_virtual = x as i16 - pointer.hotspot_x as i16; - let top_virtual = y as i16 - pointer.hotspot_y as i16; - let right_virtual = left_virtual + pointer.width as i16 - 1; - let bottom_virtual = top_virtual + pointer.height as i16 - 1; + let left_virtual = i32::from(x) - i32::from(pointer.hotspot_x); + let top_virtual = i32::from(y) - i32::from(pointer.hotspot_y); + let right_virtual = left_virtual + i32::from(pointer.width) - 1; + let bottom_virtual = top_virtual + i32::from(pointer.height) - 1; let (left, draw_x) = if left_virtual < 0 { // Cut left side if required @@ -342,7 +352,7 @@ impl DecodedImage { }; // Cut right side if required - let right = if right_virtual >= (self.width - 1) as i16 { + let right = if right_virtual >= i32::from(self.width - 1) { if draw_x + 1 >= self.width { // Pointer is completely out of bounds horizontally self.pointer_visible_on_screen = false; @@ -355,7 +365,7 @@ impl DecodedImage { }; // Cut bottom side if required - let bottom = if bottom_virtual >= (self.height - 1) as i16 { + let bottom = if bottom_virtual >= i32::from(self.height - 1) { if (draw_y + 1) >= self.height { // Pointer is completely out of bounds vertically self.pointer_visible_on_screen = false; @@ -491,6 +501,14 @@ impl DecodedImage { ) -> SessionResult { trace!("Tile: {:?}", update_rectangle); + if !self.rect_fits(&clipping_rectangles.extents) { + debug!( + "Skipping tile update {:?} outside image bounds {}x{}", + clipping_rectangles.extents, self.width, self.height, + ); + return Ok(InclusiveRectangle::empty()); + } + let pointer_rendering_state = self.pointer_rendering_begin(&clipping_rectangles.extents)?; let update_region = clipping_rectangles.intersect_rectangle(update_rectangle); @@ -530,19 +548,27 @@ impl DecodedImage { Ok(update_rectangle) } - // FIXME: this assumes PixelFormat::RgbA32 pub(crate) fn apply_rgb16_bitmap( &mut self, rgb16: &[u8], update_rectangle: &InclusiveRectangle, ) -> SessionResult { + if !self.rect_fits(update_rectangle) { + debug!( + "Skipping rgb16 update {:?} outside image bounds {}x{}", + update_rectangle, self.width, self.height, + ); + return Ok(InclusiveRectangle::empty()); + } + const SRC_COLOR_DEPTH: usize = 2; const DST_COLOR_DEPTH: usize = 4; - let image_width = self.width as usize; + let image_width = usize::from(self.width); let rectangle_width = usize::from(update_rectangle.width()); let top = usize::from(update_rectangle.top); let left = usize::from(update_rectangle.left); + let [ri, gi, bi, ai] = self.pixel_format.channel_offsets(); let pointer_rendering_state = self.pointer_rendering_begin(update_rectangle)?; @@ -554,14 +580,121 @@ impl DecodedImage { row.chunks_exact(SRC_COLOR_DEPTH) .enumerate() .for_each(|(col_idx, src_pixel)| { - let rgb16_value = u16::from_le_bytes(src_pixel.try_into().unwrap()); + let rgb16_value = u16::from_le_bytes( + src_pixel + .try_into() + .expect("src_pixel contains exactly two u8 elements"), + ); let dst_idx = ((top + row_idx) * image_width + left + col_idx) * DST_COLOR_DEPTH; let [r, g, b] = rdp_16bit_to_rgb(rgb16_value); - self.data[dst_idx] = r; - self.data[dst_idx + 1] = g; - self.data[dst_idx + 2] = b; - self.data[dst_idx + 3] = 0xff; + self.data[dst_idx + ri] = r; + self.data[dst_idx + gi] = g; + self.data[dst_idx + bi] = b; + self.data[dst_idx + ai] = 0xff; + }) + }); + + let update_rectangle = self.pointer_rendering_end(pointer_rendering_state)?; + + Ok(update_rectangle) + } + + /// Apply a 15-bit (RGB555) bitmap. Bottom-up row order, 2 bytes per pixel. + pub(crate) fn apply_rgb15_bitmap( + &mut self, + rgb15: &[u8], + update_rectangle: &InclusiveRectangle, + ) -> SessionResult { + if !self.rect_fits(update_rectangle) { + debug!( + "Skipping rgb15 update {:?} outside image bounds {}x{}", + update_rectangle, self.width, self.height, + ); + return Ok(InclusiveRectangle::empty()); + } + + const SRC_COLOR_DEPTH: usize = 2; + const DST_COLOR_DEPTH: usize = 4; + + let image_width = usize::from(self.width); + let rectangle_width = usize::from(update_rectangle.width()); + let top = usize::from(update_rectangle.top); + let left = usize::from(update_rectangle.left); + let [ri, gi, bi, ai] = self.pixel_format.channel_offsets(); + + let pointer_rendering_state = self.pointer_rendering_begin(update_rectangle)?; + + rgb15 + .chunks_exact(rectangle_width * SRC_COLOR_DEPTH) + .rev() + .enumerate() + .for_each(|(row_idx, row)| { + row.chunks_exact(SRC_COLOR_DEPTH) + .enumerate() + .for_each(|(col_idx, src_pixel)| { + let rgb15_value = u16::from_le_bytes( + src_pixel + .try_into() + .expect("src_pixel contains exactly two u8 elements"), + ); + let dst_idx = ((top + row_idx) * image_width + left + col_idx) * DST_COLOR_DEPTH; + + let [r, g, b] = rdp_15bit_to_rgb(rgb15_value); + self.data[dst_idx + ri] = r; + self.data[dst_idx + gi] = g; + self.data[dst_idx + bi] = b; + self.data[dst_idx + ai] = 0xff; + }) + }); + + let update_rectangle = self.pointer_rendering_end(pointer_rendering_state)?; + + Ok(update_rectangle) + } + + /// Apply a 24-bit BGR bitmap. RLE 24bpp decompresses to BGR byte order, + /// and uncompressed 24bpp bitmaps are also BGR per MS-RDPBCGR. + /// Bottom-up row order, 3 bytes per pixel. + pub(crate) fn apply_bgr24_bitmap( + &mut self, + bgr24: &[u8], + update_rectangle: &InclusiveRectangle, + ) -> SessionResult { + if !self.rect_fits(update_rectangle) { + debug!( + "Skipping bgr24 update {:?} outside image bounds {}x{}", + update_rectangle, self.width, self.height, + ); + return Ok(InclusiveRectangle::empty()); + } + + const SRC_COLOR_DEPTH: usize = 3; + const DST_COLOR_DEPTH: usize = 4; + + let image_width = usize::from(self.width); + let rectangle_width = usize::from(update_rectangle.width()); + let top = usize::from(update_rectangle.top); + let left = usize::from(update_rectangle.left); + let [ri, gi, bi, ai] = self.pixel_format.channel_offsets(); + + let pointer_rendering_state = self.pointer_rendering_begin(update_rectangle)?; + + bgr24 + .chunks_exact(rectangle_width * SRC_COLOR_DEPTH) + .rev() + .enumerate() + .for_each(|(row_idx, row)| { + row.chunks_exact(SRC_COLOR_DEPTH) + .enumerate() + .for_each(|(col_idx, src_pixel)| { + let dst_idx = ((top + row_idx) * image_width + left + col_idx) * DST_COLOR_DEPTH; + + // BGR -> RGB channel swap + self.data[dst_idx + ri] = src_pixel[2]; + self.data[dst_idx + gi] = src_pixel[1]; + self.data[dst_idx + bi] = src_pixel[0]; + self.data[dst_idx + ai] = 0xff; }) }); @@ -570,7 +703,52 @@ impl DecodedImage { Ok(update_rectangle) } - // FIXME: this assumes PixelFormat::RgbA32 + /// Apply an 8-bit palette-indexed bitmap. Each source byte is a palette index. + /// Bottom-up row order. + pub(crate) fn apply_rgb8_with_palette( + &mut self, + indexed: &[u8], + update_rectangle: &InclusiveRectangle, + palette: &[[u8; 3]; 256], + ) -> SessionResult { + if !self.rect_fits(update_rectangle) { + debug!( + "Skipping rgb8 update {:?} outside image bounds {}x{}", + update_rectangle, self.width, self.height, + ); + return Ok(InclusiveRectangle::empty()); + } + + const DST_COLOR_DEPTH: usize = 4; + + let image_width = usize::from(self.width); + let rectangle_width = usize::from(update_rectangle.width()); + let top = usize::from(update_rectangle.top); + let left = usize::from(update_rectangle.left); + let [ri, gi, bi, ai] = self.pixel_format.channel_offsets(); + + let pointer_rendering_state = self.pointer_rendering_begin(update_rectangle)?; + + indexed + .chunks_exact(rectangle_width) + .rev() + .enumerate() + .for_each(|(row_idx, row)| { + row.iter().enumerate().for_each(|(col_idx, &index)| { + let dst_idx = ((top + row_idx) * image_width + left + col_idx) * DST_COLOR_DEPTH; + let [r, g, b] = palette[usize::from(index)]; + self.data[dst_idx + ri] = r; + self.data[dst_idx + gi] = g; + self.data[dst_idx + bi] = b; + self.data[dst_idx + ai] = 0xff; + }) + }); + + let update_rectangle = self.pointer_rendering_end(pointer_rendering_state)?; + + Ok(update_rectangle) + } + fn apply_rgb24_iter<'a, I>( &mut self, rgb24: I, @@ -579,12 +757,21 @@ impl DecodedImage { where I: Iterator, { + if !self.rect_fits(update_rectangle) { + debug!( + "Skipping rgb24 update {:?} outside image bounds {}x{}", + update_rectangle, self.width, self.height, + ); + return Ok(InclusiveRectangle::empty()); + } + const SRC_COLOR_DEPTH: usize = 3; const DST_COLOR_DEPTH: usize = 4; - let image_width = self.width as usize; + let image_width = usize::from(self.width); let top = usize::from(update_rectangle.top); let left = usize::from(update_rectangle.left); + let [ri, gi, bi, ai] = self.pixel_format.channel_offsets(); let pointer_rendering_state = self.pointer_rendering_begin(update_rectangle)?; @@ -594,10 +781,10 @@ impl DecodedImage { .for_each(|(col_idx, src_pixel)| { let dst_idx = ((top + row_idx) * image_width + left + col_idx) * DST_COLOR_DEPTH; - // Copy RGB channels as is - self.data[dst_idx..dst_idx + SRC_COLOR_DEPTH].copy_from_slice(src_pixel); - // Set alpha channel to opaque(0xFF) - self.data[dst_idx + 3] = 0xFF; + self.data[dst_idx + ri] = src_pixel[0]; + self.data[dst_idx + gi] = src_pixel[1]; + self.data[dst_idx + bi] = src_pixel[2]; + self.data[dst_idx + ai] = 0xFF; }) }); @@ -622,17 +809,86 @@ impl DecodedImage { } } - // FIXME: this assumes PixelFormat::RgbA32 + #[cfg(feature = "qoi")] + fn apply_rgba32_iter<'a, I>( + &mut self, + rgba32: I, + update_rectangle: &InclusiveRectangle, + ) -> SessionResult + where + I: Iterator, + { + if !self.rect_fits(update_rectangle) { + debug!( + "Skipping rgba32 update {:?} outside image bounds {}x{}", + update_rectangle, self.width, self.height, + ); + return Ok(InclusiveRectangle::empty()); + } + + const SRC_COLOR_DEPTH: usize = 4; + const DST_COLOR_DEPTH: usize = 4; + + let image_width = usize::from(self.width); + let top = usize::from(update_rectangle.top); + let left = usize::from(update_rectangle.left); + let [ri, gi, bi, ai] = self.pixel_format.channel_offsets(); + + let pointer_rendering_state = self.pointer_rendering_begin(update_rectangle)?; + + rgba32.enumerate().for_each(|(row_idx, row)| { + row.chunks_exact(SRC_COLOR_DEPTH) + .enumerate() + .for_each(|(col_idx, src_pixel)| { + let dst_idx = ((top + row_idx) * image_width + left + col_idx) * DST_COLOR_DEPTH; + + self.data[dst_idx + ri] = src_pixel[0]; + self.data[dst_idx + gi] = src_pixel[1]; + self.data[dst_idx + bi] = src_pixel[2]; + self.data[dst_idx + ai] = src_pixel[3]; + }) + }); + + let update_rectangle = self.pointer_rendering_end(pointer_rendering_state)?; + + Ok(update_rectangle) + } + + #[cfg(feature = "qoi")] + pub(crate) fn apply_rgba32( + &mut self, + rgba32: &[u8], + update_rectangle: &InclusiveRectangle, + flip: bool, + ) -> SessionResult { + const SRC_COLOR_DEPTH: usize = 4; + let rectangle_width = usize::from(update_rectangle.width()); + let lines = rgba32.chunks_exact(rectangle_width * SRC_COLOR_DEPTH); + if flip { + self.apply_rgba32_iter(lines.rev(), update_rectangle) + } else { + self.apply_rgba32_iter(lines, update_rectangle) + } + } + pub(crate) fn apply_rgb32_bitmap( &mut self, rgb32: &[u8], format: PixelFormat, update_rectangle: &InclusiveRectangle, ) -> SessionResult { + if !self.rect_fits(update_rectangle) { + debug!( + "Skipping rgb32 update {:?} outside image bounds {}x{}", + update_rectangle, self.width, self.height, + ); + return Ok(InclusiveRectangle::empty()); + } + const SRC_COLOR_DEPTH: usize = 4; const DST_COLOR_DEPTH: usize = 4; - let image_width = self.width as usize; + let image_width = usize::from(self.width); let rectangle_width = usize::from(update_rectangle.width()); let top = usize::from(update_rectangle.top); let left = usize::from(update_rectangle.left); @@ -654,20 +910,31 @@ impl DecodedImage { }) }); } else { + let [ri, gi, bi, ai] = self.pixel_format.channel_offsets(); rgb32 .chunks_exact(rectangle_width * SRC_COLOR_DEPTH) .rev() .enumerate() - .for_each(|(row_idx, row)| { + .try_for_each(|(row_idx, row)| { row.chunks_exact(SRC_COLOR_DEPTH) .enumerate() - .for_each(|(col_idx, src_pixel)| { + .try_for_each(|(col_idx, src_pixel)| { let dst_idx = ((top + row_idx) * image_width + left + col_idx) * DST_COLOR_DEPTH; - let c = format.read_color(src_pixel).unwrap(); - self.data[dst_idx..dst_idx + SRC_COLOR_DEPTH].copy_from_slice(&[c.r, c.g, c.b, c.a]); - }) - }); + let c = format + .read_color(src_pixel) + .map_err(|err| custom_err!("read color", err))?; + + self.data[dst_idx + ri] = c.r; + self.data[dst_idx + gi] = c.g; + self.data[dst_idx + bi] = c.b; + self.data[dst_idx + ai] = c.a; + + Ok(()) + })?; + + Ok(()) + })?; } let update_rectangle = self.pointer_rendering_end(pointer_rendering_state)?; diff --git a/crates/ironrdp-session/src/legacy.rs b/crates/ironrdp-session/src/legacy.rs deleted file mode 100644 index 44e1399f0e..0000000000 --- a/crates/ironrdp-session/src/legacy.rs +++ /dev/null @@ -1,18 +0,0 @@ -use crate::SessionError; - -// FIXME: code should be fixed so that we never need this conversion -// For that, some code from this ironrdp_session::legacy and ironrdp_connector::legacy modules should be moved to ironrdp_pdu itself -impl From for crate::SessionErrorKind { - fn from(value: ironrdp_connector::ConnectorErrorKind) -> Self { - match value { - ironrdp_connector::ConnectorErrorKind::Custom | ironrdp_connector::ConnectorErrorKind::Credssp(_) => { - crate::SessionErrorKind::Custom - } - _ => crate::SessionErrorKind::General, - } - } -} - -pub(crate) fn map_error(error: ironrdp_connector::ConnectorError) -> SessionError { - error.into_other_kind() -} diff --git a/crates/ironrdp-session/src/lib.rs b/crates/ironrdp-session/src/lib.rs index 54a3721ca1..4d45fad1fb 100644 --- a/crates/ironrdp-session/src/lib.rs +++ b/crates/ironrdp-session/src/lib.rs @@ -6,16 +6,16 @@ mod macros; pub mod fast_path; pub mod image; -pub mod legacy; pub mod pointer; pub mod rfx; // FIXME: maybe this module should not be in this crate pub mod x224; mod active_stage; +mod palette; use core::fmt; -pub use active_stage::{ActiveStage, ActiveStageOutput, GracefulDisconnectReason}; +pub use active_stage::{ActiveStage, ActiveStageBuilder, ActiveStageOutput, GracefulDisconnectReason}; pub type SessionResult = Result; @@ -70,26 +70,32 @@ pub trait SessionErrorExt { } impl SessionErrorExt for SessionError { + #[track_caller] fn pdu(error: ironrdp_pdu::PduError) -> Self { Self::new("payload error", SessionErrorKind::Pdu(error)) } + #[track_caller] fn encode(error: ironrdp_core::EncodeError) -> Self { Self::new("encode error", SessionErrorKind::Encode(error)) } + #[track_caller] fn decode(error: ironrdp_core::DecodeError) -> Self { Self::new("decode error", SessionErrorKind::Decode(error)) } + #[track_caller] fn general(context: &'static str) -> Self { Self::new(context, SessionErrorKind::General) } + #[track_caller] fn reason(context: &'static str, reason: impl Into) -> Self { Self::new(context, SessionErrorKind::Reason(reason.into())) } + #[track_caller] fn custom(context: &'static str, e: E) -> Self where E: core::error::Error + Sync + Send + 'static, @@ -110,7 +116,7 @@ pub trait SessionResultExt { impl SessionResultExt for SessionResult { fn with_context(self, context: &'static str) -> Self { self.map_err(|mut e| { - e.context = context; + e.set_context(context); e }) } diff --git a/crates/ironrdp-session/src/macros.rs b/crates/ironrdp-session/src/macros.rs index b612ced6fa..5695913cc8 100644 --- a/crates/ironrdp-session/src/macros.rs +++ b/crates/ironrdp-session/src/macros.rs @@ -6,9 +6,7 @@ /// ``` #[macro_export] macro_rules! general_err { - ( $context:expr $(,)? ) => {{ - <$crate::SessionError as $crate::SessionErrorExt>::general($context) - }}; + ( $context:expr $(,)? ) => {{ <$crate::SessionError as $crate::SessionErrorExt>::general($context) }}; } /// Creates a `SessionError` with `Reason` kind @@ -32,9 +30,7 @@ macro_rules! reason_err { /// ``` #[macro_export] macro_rules! custom_err { - ( $context:expr, $source:expr $(,)? ) => {{ - <$crate::SessionError as $crate::SessionErrorExt>::custom($context, $source) - }}; + ( $context:expr, $source:expr $(,)? ) => {{ <$crate::SessionError as $crate::SessionErrorExt>::custom($context, $source) }}; } #[macro_export] diff --git a/crates/ironrdp-session/src/palette.rs b/crates/ironrdp-session/src/palette.rs new file mode 100644 index 0000000000..6df8b66813 --- /dev/null +++ b/crates/ironrdp-session/src/palette.rs @@ -0,0 +1,89 @@ +use tracing::{debug, warn}; + +/// 8bpp color palette (256 RGB entries). +/// +/// Initialized with the default Windows system palette (VGA colors) +/// per MS-RDPBCGR 2.2.9.1.1.3.1.1. Updated by TS_UPDATE_PALETTE_DATA +/// fast-path updates during the session. +#[derive(Debug, Clone)] +pub(crate) struct Palette { + colors: [[u8; 3]; 256], +} + +impl Palette { + /// Create a palette initialized with the 20 static colors from the + /// Windows default system palette. Indices 0-9 and 246-255 are the + /// reserved static colors; the middle 236 entries (10-245) are black. + /// + /// Reference: + pub(crate) fn system_default() -> Self { + let mut colors = [[0u8; 3]; 256]; + // Lower 10 static colors (indices 0-9) + colors[0] = [0, 0, 0]; // Black + colors[1] = [128, 0, 0]; // Dark Red + colors[2] = [0, 128, 0]; // Dark Green + colors[3] = [128, 128, 0]; // Dark Yellow + colors[4] = [0, 0, 128]; // Dark Blue + colors[5] = [128, 0, 128]; // Dark Magenta + colors[6] = [0, 128, 128]; // Dark Cyan + colors[7] = [192, 192, 192]; // Light Gray + colors[8] = [192, 220, 192]; // Money Green + colors[9] = [166, 202, 240]; // Sky Blue + // Upper 10 static colors (indices 246-255) + colors[246] = [255, 251, 240]; // Cream + colors[247] = [160, 160, 164]; // Medium Gray + colors[248] = [128, 128, 128]; // Dark Gray + colors[249] = [255, 0, 0]; // Red + colors[250] = [0, 255, 0]; // Green + colors[251] = [255, 255, 0]; // Yellow + colors[252] = [0, 0, 255]; // Blue + colors[253] = [255, 0, 255]; // Magenta + colors[254] = [0, 255, 255]; // Cyan + colors[255] = [255, 255, 255]; // White + Self { colors } + } + + /// Parse TS_UPDATE_PALETTE_DATA and update palette entries. + /// Wire format: pad(2) + numberColors(u32) + N x TS_COLOR_QUAD [B, G, R, pad]. + pub(crate) fn process_update(&mut self, data: &[u8]) { + if data.len() < 6 { + warn!("Palette update too short: {} bytes", data.len()); + return; + } + + let raw_count = u32::from_le_bytes([data[2], data[3], data[4], data[5]]); + // Palette can have at most 256 entries; clamp before any arithmetic + // to prevent overflow on untrusted input + let clamped = raw_count.min(256); + let number_colors = usize::try_from(clamped).unwrap_or(256); + let entry_data = &data[6..]; + + let Some(required_len) = number_colors.checked_mul(4) else { + warn!("Palette entry count overflow"); + return; + }; + + if entry_data.len() < required_len { + warn!( + "Palette data truncated: expected {} bytes for {} colors, got {}", + required_len, + number_colors, + entry_data.len() + ); + return; + } + + for i in 0..number_colors { + let offset = i * 4; + // TS_COLOR_QUAD: Blue, Green, Red, Pad + self.colors[i] = [entry_data[offset + 2], entry_data[offset + 1], entry_data[offset]]; + } + + debug!("Updated palette with {} colors", number_colors); + } + + /// Borrow the underlying color table for bitmap application. + pub(crate) fn colors(&self) -> &[[u8; 3]; 256] { + &self.colors + } +} diff --git a/crates/ironrdp-session/src/rfx.rs b/crates/ironrdp-session/src/rfx.rs index 89edea4521..6a5fcbad97 100644 --- a/crates/ironrdp-session/src/rfx.rs +++ b/crates/ironrdp-session/src/rfx.rs @@ -6,11 +6,11 @@ use ironrdp_graphics::rectangle_processing::Region; use ironrdp_graphics::{dwt, quantization, rlgr, subband_reconstruction}; use ironrdp_pdu::codecs::rfx::{self, EntropyAlgorithm, Quant, RfxRectangle, Tile}; use ironrdp_pdu::geometry::{InclusiveRectangle, Rectangle as _}; -use ironrdp_pdu::{decode_cursor, Decode as _, ReadCursor}; +use ironrdp_pdu::{Decode as _, ReadCursor, decode_cursor}; use tracing::{instrument, trace}; use crate::image::DecodedImage; -use crate::{custom_err, general_err, reason_err, SessionResult}; +use crate::{SessionResult, custom_err, general_err, reason_err}; const TILE_SIZE: u16 = 64; @@ -108,7 +108,11 @@ impl DecodingContext { image: &mut DecodedImage, destination: &InclusiveRectangle, ) -> SessionResult<(FrameId, InclusiveRectangle)> { - let channel = self.channels.0.first().unwrap(); + let channel = self + .channels + .0 + .first() + .ok_or_else(|| general_err!("no RFX channel found"))?; let width = channel.width.try_into().map_err(|_| general_err!("invalid width"))?; let height = channel.height.try_into().map_err(|_| general_err!("invalid height"))?; let entropy_algorithm = self.context.entropy_algorithm; @@ -183,10 +187,11 @@ struct DecodingTileContext { impl DecodingTileContext { fn new() -> Self { + let tile_size = usize::from(TILE_SIZE); Self { - tile_output: vec![0; TILE_SIZE as usize * TILE_SIZE as usize * 4], - ycbcr_buffer: vec![vec![0; TILE_SIZE as usize * TILE_SIZE as usize]; 3], - ycbcr_temp_buffer: vec![0; TILE_SIZE as usize * TILE_SIZE as usize], + tile_output: vec![0; tile_size * tile_size * 4], + ycbcr_buffer: vec![vec![0; tile_size * tile_size]; 3], + ycbcr_temp_buffer: vec![0; tile_size * tile_size], } } } diff --git a/crates/ironrdp-session/src/x224/mod.rs b/crates/ironrdp-session/src/x224/mod.rs index d82793bb21..b2a6927aa2 100644 --- a/crates/ironrdp-session/src/x224/mod.rs +++ b/crates/ironrdp-session/src/x224/mod.rs @@ -1,15 +1,15 @@ -use ironrdp_connector::connection_activation::ConnectionActivationSequence; -use ironrdp_connector::legacy::SendDataIndicationCtx; -use ironrdp_core::WriteBuf; +use ironrdp_core::{WriteBuf, decode}; use ironrdp_dvc::{DrdynvcClient, DvcProcessor, DynamicVirtualChannel}; -use ironrdp_pdu::mcs::{DisconnectProviderUltimatum, DisconnectReason, McsMessage}; +use ironrdp_pdu::mcs::{DisconnectProviderUltimatum, DisconnectReason, McsMessage, SendDataIndicationCtx}; +use ironrdp_pdu::rdp::autodetect::{AutoDetectReqPdu, AutoDetectRequest, AutoDetectResponse, AutoDetectRspPdu}; use ironrdp_pdu::rdp::headers::ShareDataPdu; +use ironrdp_pdu::rdp::multitransport::MultitransportRequestPdu; use ironrdp_pdu::rdp::server_error_info::{ErrorInfo, ProtocolIndependentCode, ServerSetErrorInfoPdu}; use ironrdp_pdu::x224::X224; -use ironrdp_svc::{client_encode_svc_messages, StaticChannelSet, SvcMessage, SvcProcessor, SvcProcessorMessages}; +use ironrdp_svc::{StaticChannelSet, SvcMessage, SvcProcessor, SvcProcessorMessages, client_encode_svc_messages}; use tracing::debug; -use crate::{reason_err, SessionError, SessionErrorExt as _, SessionResult}; +use crate::{SessionError, SessionErrorExt as _, SessionResult, reason_err}; /// X224 Processor output #[derive(Debug, Clone)] @@ -22,7 +22,29 @@ pub enum ProcessorOutput { /// [Deactivation-Reactivation Sequence]. /// /// [Deactivation-Reactivation Sequence]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 - DeactivateAll(Box), + DeactivateAll, + /// Server Initiate Multitransport Request. The application should establish a + /// sideband UDP transport using the request ID and security cookie, then send + /// a [`MultitransportResponsePdu`] back on the IO channel. + /// + /// See [\[MS-RDPBCGR\] 2.2.15.1]. + /// + /// [\[MS-RDPBCGR\] 2.2.15.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/de783158-8b01-4818-8fb0-62523a5b3490 + /// [`MultitransportResponsePdu`]: ironrdp_pdu::rdp::multitransport::MultitransportResponsePdu + MultitransportRequest(MultitransportRequestPdu), + /// Auto-detect network characteristics from server ([\[MS-RDPBCGR\] 2.2.14]). + /// + /// Currently only surfaces [`AutoDetectRequest::NetworkCharacteristicsResult`]. + /// RTT requests are handled internally with automatic responses. + /// + /// [\[MS-RDPBCGR\] 2.2.14]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dc672839-4f4e-40b1-a71c-cd6a959baa38 + AutoDetect(AutoDetectRequest), + /// Slow-path graphics update ([MS-RDPBCGR] 2.2.9.1.1.3). + /// Raw update payload starting with `updateType(u16)`. + GraphicsUpdate(Vec), + /// Slow-path pointer update ([MS-RDPBCGR] 2.2.9.1.1.4). + /// Raw pointer payload starting with `messageType(u16) + pad(u16)`. + PointerUpdate(Vec), } #[derive(Debug, Clone)] @@ -41,7 +63,8 @@ pub struct Processor { static_channels: StaticChannelSet, user_channel_id: u16, io_channel_id: u16, - connection_activation: ConnectionActivationSequence, + message_channel_id: Option, + share_id: u32, } impl Processor { @@ -49,16 +72,22 @@ impl Processor { static_channels: StaticChannelSet, user_channel_id: u16, io_channel_id: u16, - connection_activation: ConnectionActivationSequence, + message_channel_id: Option, + share_id: u32, ) -> Self { Self { static_channels, user_channel_id, io_channel_id, - connection_activation, + message_channel_id, + share_id, } } + pub fn set_share_id(&mut self, share_id: u32) { + self.share_id = share_id; + } + pub fn get_svc_processor(&self) -> Option<&T> { self.static_channels .get_by_type::() @@ -98,11 +127,13 @@ impl Processor { /// in the returned order. pub fn process(&mut self, frame: &[u8]) -> SessionResult> { let data_ctx: SendDataIndicationCtx<'_> = - ironrdp_connector::legacy::decode_send_data_indication(frame).map_err(crate::legacy::map_error)?; + ironrdp_pdu::mcs::decode_send_data_indication(frame).map_err(SessionError::decode)?; let channel_id = data_ctx.channel_id; if channel_id == self.io_channel_id { self.process_io_channel(data_ctx) + } else if self.message_channel_id == Some(channel_id) { + self.process_message_channel(data_ctx) } else if let Some(svc) = self.static_channels.get_by_channel_id_mut(channel_id) { let response_pdus = svc.process(data_ctx.user_data).map_err(SessionError::pdu)?; process_svc_messages(response_pdus, channel_id, data_ctx.initiator_id) @@ -115,10 +146,10 @@ impl Processor { fn process_io_channel(&self, data_ctx: SendDataIndicationCtx<'_>) -> SessionResult> { debug_assert_eq!(data_ctx.channel_id, self.io_channel_id); - let io_channel = ironrdp_connector::legacy::decode_io_channel(data_ctx).map_err(crate::legacy::map_error)?; + let io_channel = ironrdp_pdu::rdp::headers::decode_io_channel(data_ctx).map_err(SessionError::decode)?; match io_channel { - ironrdp_connector::legacy::IoChannelPdu::Data(ctx) => { + ironrdp_pdu::rdp::headers::IoChannelPdu::Data(ctx) => { match ctx.pdu { ShareDataPdu::SaveSessionInfo(session_info) => { debug!("Got Session Save Info PDU: {session_info:?}"); @@ -164,6 +195,21 @@ impl Processor { )), ]) } + // TODO: slow-path payloads may be bulk-compressed when + // ClientInfoFlags::COMPRESSION is negotiated. Decompression + // should happen here before passing data downstream. Currently + // IronRDP does not wire bulk decompression into this path. + // FIXME: until this is wired, the client deliberately defaults to the simple, + // stateless-friendly MPPC 64K (RDP5) compression level rather than XCRUSH; a + // stateful codec would risk silent corruption on slow-path updates. + ShareDataPdu::Update(data) => { + debug!("Got slow-path graphics update ({} bytes)", data.len()); + Ok(vec![ProcessorOutput::GraphicsUpdate(data)]) + } + ShareDataPdu::Pointer(data) => { + debug!("Got slow-path pointer update ({} bytes)", data.len()); + Ok(vec![ProcessorOutput::PointerUpdate(data)]) + } _ => Err(reason_err!( "IO channel", "unhandled PDU: {:?}", @@ -171,17 +217,64 @@ impl Processor { )), } } - ironrdp_connector::legacy::IoChannelPdu::DeactivateAll(_) => Ok(vec![ProcessorOutput::DeactivateAll( - Box::new(self.connection_activation.reset_clone()), - )]), + ironrdp_pdu::rdp::headers::IoChannelPdu::MultitransportRequest(pdu) => { + debug!( + "Received Initiate Multitransport Request: request_id={}", + pdu.request_id + ); + Ok(vec![ProcessorOutput::MultitransportRequest(pdu)]) + } + ironrdp_pdu::rdp::headers::IoChannelPdu::DeactivateAll(_) => Ok(vec![ProcessorOutput::DeactivateAll]), + } + } + + /// Process an auto-detect request received on the MCS message channel. + /// + /// During continuous auto-detection ([MS-RDPBCGR] 2.2.14) the server sends + /// RTT (and bandwidth) requests on the message channel; the client answers + /// RTT requests and surfaces the final Network Characteristics Result. + fn process_message_channel(&self, data_ctx: SendDataIndicationCtx<'_>) -> SessionResult> { + let Some(message_channel_id) = self.message_channel_id else { + return Err(reason_err!("message channel", "no message channel negotiated")); + }; + + let req = decode::(data_ctx.user_data).map_err(SessionError::decode)?; + + match req.request { + AutoDetectRequest::RttRequest { sequence_number, .. } => { + let response = AutoDetectRspPdu::new(AutoDetectResponse::RttResponse { sequence_number }); + let mut frame = WriteBuf::new(); + ironrdp_pdu::mcs::encode_send_data_request( + self.user_channel_id, + message_channel_id, + &response, + &mut frame, + ) + .map_err(SessionError::encode)?; + debug!(sequence_number, "Responded to auto-detect RTT request"); + Ok(vec![ProcessorOutput::ResponseFrame(frame.into_inner())]) + } + req @ AutoDetectRequest::NetworkCharacteristicsResult { .. } => { + debug!(?req, "Received network characteristics from server"); + Ok(vec![ProcessorOutput::AutoDetect(req)]) + } + req => { + debug!(?req, "Auto-detect request not yet implemented"); + Ok(Vec::new()) + } } } /// Send a pdu on the static global channel. Typically used to send input events pub fn encode_static(&self, output: &mut WriteBuf, pdu: ShareDataPdu) -> SessionResult { - let written = - ironrdp_connector::legacy::encode_share_data(self.user_channel_id, self.io_channel_id, 0, pdu, output) - .map_err(crate::legacy::map_error)?; + let written = ironrdp_pdu::rdp::headers::encode_share_data( + self.user_channel_id, + self.io_channel_id, + self.share_id, + pdu, + output, + ) + .map_err(SessionError::encode)?; Ok(written) } } diff --git a/crates/ironrdp-str/CHANGELOG.md b/crates/ironrdp-str/CHANGELOG.md new file mode 100644 index 0000000000..1bae5ef861 --- /dev/null +++ b/crates/ironrdp-str/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-str-v0.1.0...ironrdp-str-v0.1.1)] - 2026-05-27 + +### Build + +- Update dependencies. diff --git a/crates/ironrdp-str/Cargo.toml b/crates/ironrdp-str/Cargo.toml new file mode 100644 index 0000000000..03a4f60189 --- /dev/null +++ b/crates/ironrdp-str/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "ironrdp-str" +version = "0.1.1" +description = "Typed wire-aware string primitives for RDP protocol fields" +edition.workspace = true +rust-version = "1.89" +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true + +[features] +default = ["std"] +std = ["alloc", "ironrdp-core/std"] +alloc = ["ironrdp-core/alloc", "bytemuck/extern_crate_alloc"] + +[dependencies] +bytemuck = { version = "1", default-features = false } +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } + +[lints] +workspace = true diff --git a/crates/ironrdp-str/README.md b/crates/ironrdp-str/README.md new file mode 100644 index 0000000000..a261ab0e73 --- /dev/null +++ b/crates/ironrdp-str/README.md @@ -0,0 +1,40 @@ +# ironrdp-str + +Typed wire-aware string primitives for RDP protocol fields. + +RDP encodes strings as UTF-16LE on the wire, with three independent dimensions in how string fields are laid out: + +1. **Length prefix**: none (fixed-size), `cch` (WCHAR count), or `cb` (byte count) +2. **Null terminator**: present and counted in prefix, present but not counted, or absent +3. **Multi-string**: single string vs. `MULTI_SZ` + +This crate provides typed wrappers for each combination, along with foundational free functions that are the only correct source of truth for wire-length calculations. + +## Architectural invariant: defer native conversion + +**String values must not be eagerly converted to Rust `String` on decode.** +Every string type in this crate stores the wire representation internally — as a flat `Vec` of UTF-16 code units — and only converts to a Rust-native `String` when the caller explicitly calls `to_native()`, `to_native_lossy()`, or `into_native()`. + +Two concrete benefits drive this invariant: + +**Efficient decode.** +Converting UTF-16LE wire bytes to a `Vec` is a single `memcpy` (via `bytemuck` on little-endian targets). +Producing a `String` from that requires a second allocation and a full scan to validate or transcode the code units. +Many callers decode a PDU, inspect one or two fields, and discard the rest — paying for transcoding every string field up front would be wasteful. + +**Zero-cost decode-encode passthrough.** +Proxy and relay components typically decode a PDU and re-encode it verbatim, possibly forwarding it to another peer. +When the wire representation is retained, re-encoding a string that was not touched is also a single `memcpy` — the bytes going out are identical to the bytes that came in. +Eager conversion to `String` would round-trip through UTF-8 and back to UTF-16LE, changing the representation unnecessarily and requiring two additional allocations per field. + +### What this means in practice + +- `decode_owned` / `decode` store `Wire(Vec)` internally; no `String` is ever allocated. +- `to_native()` / `into_native()` validate and allocate on demand; call them only when you need a `String`. +- `to_native_lossy()` accepts lone surrogates by replacing them with U+FFFD; prefer this for display or logging. +- Construction from Rust code (e.g. `new("hello")`) uses `Native(String)` internally; encoding that path is equally efficient because the UTF-16 units are computed lazily during `encode()`. + +## Critical invariant: wire-length arithmetic + +**Never use `.len()` or `.chars().count()` on a Rust `&str` to derive any wire length.** +Both are wrong for non-BMP input (e.g. U+1F600 GRINNING FACE is one scalar value but two UTF-16 code units). The only correct source is [`utf16_code_units`]. diff --git a/crates/ironrdp-str/src/fixed.rs b/crates/ironrdp-str/src/fixed.rs new file mode 100644 index 0000000000..28545eb9c2 --- /dev/null +++ b/crates/ironrdp-str/src/fixed.rs @@ -0,0 +1,341 @@ +//! Fixed-size Unicode string fields. +//! +//! Used for RDP fields whose wire representation occupies a statically-known number +//! of WCHARs, such as `clientName` (16 WCHARs = 32 bytes) or `fileName` (260 WCHARs = 520 bytes). + +use alloc::borrow::Cow; +#[cfg(not(feature = "std"))] +use alloc::string::String; +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; +use core::fmt; + +use ironrdp_core::{DecodeOwned, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, ensure_size}; + +use crate::repr::StringRepr; +use crate::{InvalidUtf16, check_invariant, utf16_code_units}; + +// ── Error type ──────────────────────────────────────────────────────────────── + +/// Error returned when a string is too long for a [`FixedString`] field. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StringTooLong { + /// Maximum number of UTF-16 code units the field can hold (excluding the null terminator slot). + pub max_code_units: usize, + /// Actual number of UTF-16 code units in the string. + pub actual_code_units: usize, +} + +impl fmt::Display for StringTooLong { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "string too long: {} code units, maximum is {}", + self.actual_code_units, self.max_code_units + ) + } +} + +impl core::error::Error for StringTooLong {} + +/// Error returned by [`FixedString::from_utf16le_bytes`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FixedStringBytesError { + /// The byte slice has odd length. UTF-16LE requires exactly 2 bytes per code unit. + OddByteCount, + /// The content is too long for the field after stripping trailing nulls. + StringTooLong(StringTooLong), +} + +impl fmt::Display for FixedStringBytesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::OddByteCount => f.write_str("odd byte count: UTF-16LE requires 2 bytes per code unit"), + Self::StringTooLong(e) => fmt::Display::fmt(e, f), + } + } +} + +impl core::error::Error for FixedStringBytesError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + match self { + Self::OddByteCount => None, + Self::StringTooLong(e) => Some(e), + } + } +} + +impl From for FixedStringBytesError { + fn from(e: StringTooLong) -> Self { + Self::StringTooLong(e) + } +} + +// ── FixedString ──────────────────────────────────────────────────── + +/// A UTF-16LE string occupying exactly `WCHAR_COUNT` code units on the wire, zero-padded +/// if shorter. +/// +/// Strings requiring more than `WCHAR_COUNT - 1` code units are rejected on construction +/// (one slot is reserved for the null terminator). Trailing null terminators and zero +/// padding are stripped on decode. +/// +/// Wire data is accepted as-is with no UTF-16 validation at decode time. Call [`to_native`] +/// to validate and convert to a Rust `str`, or [`to_native_lossy`] to accept any byte +/// sequence with lone-surrogate replacement. +/// +/// The wire byte size is always [`WIRE_SIZE`](FixedString::WIRE_SIZE) = `WCHAR_COUNT * 2`. +/// +/// # Common instantiations +/// +/// | Type alias | `WCHAR_COUNT` | Wire bytes | Spec field | +/// |--------------------------------------|---------------|------------|------------| +/// | `FixedString<16>` | 16 | 32 | `clientName` ([MS-RDPBCGR] §2.2.1.3.2) | +/// | `FixedString<32>` | 32 | 64 | `StandardName`, `DaylightName` ([MS-RDPBCGR] §2.2.1.11.1.1.1) | +/// | `FixedString<260>` | 260 | 520 | `fileName`, `applicationId` | +/// +/// [`to_native`]: FixedString::to_native +/// [`to_native_lossy`]: FixedString::to_native_lossy +/// [MS-RDPBCGR]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/ +pub struct FixedString( + /// INVARIANT: `utf16_code_units` of the stored string is `< WCHAR_COUNT`. + StringRepr, +); + +impl FixedString { + /// Wire byte size: always `WCHAR_COUNT * 2` bytes. + pub const WIRE_SIZE: usize = { + assert!( + WCHAR_COUNT > 0, + "FixedString: WCHAR_COUNT must be > 0 (at least one slot is required for the null terminator)" + ); + WCHAR_COUNT * 2 + }; + + /// Creates a `FixedString` from UTF-16LE content bytes. + /// + /// `bytes` is the string content — it does not need to be padded to `WIRE_SIZE`. + /// Trailing null code units are stripped before the length check. Returns + /// [`FixedStringBytesError::OddByteCount`] if `bytes` has odd length, or + /// [`FixedStringBytesError::StringTooLong`] if the content exceeds `WCHAR_COUNT - 1` + /// code units after stripping. + /// + /// This is a convenience wrapper around [`utf16le_bytes_to_units`] + [`from_wire_units`]. + /// + /// [`utf16le_bytes_to_units`]: crate::utf16le_bytes_to_units + /// [`from_wire_units`]: FixedString::from_wire_units + pub fn from_utf16le_bytes(bytes: &[u8]) -> Result { + let units = crate::utf16le_bytes_to_units(bytes).ok_or(FixedStringBytesError::OddByteCount)?; + Self::from_wire_units(units).map_err(FixedStringBytesError::StringTooLong) + } + + /// Creates a `FixedString` from pre-parsed UTF-16 code units. + /// + /// Trailing null and zero-padding code units are stripped. Returns [`StringTooLong`] + /// if the content exceeds `WCHAR_COUNT - 1` code units after stripping. This is + /// the low-level counterpart to [`decode_owned`] for callers that already have units + /// from [`utf16le_bytes_to_units`]. + /// + /// [`decode_owned`]: ironrdp_core::DecodeOwned::decode_owned + /// [`utf16le_bytes_to_units`]: crate::utf16le_bytes_to_units + pub fn from_wire_units(units: Vec) -> Result { + let mut units = units; + let end = units.iter().rposition(|&u| u != 0).map_or(0, |i| i + 1); + units.truncate(end); + + let actual = units.len(); + + check_invariant(actual < WCHAR_COUNT).ok_or_else(|| StringTooLong { + max_code_units: WCHAR_COUNT.saturating_sub(1), + actual_code_units: actual, + })?; + + Ok(Self(StringRepr::from_wire_units(units))) + } + + /// Creates a `FixedString` from a native Rust string, truncating to + /// `WCHAR_COUNT - 1` UTF-16 code units if the string is too long. + /// + /// If the string fits within the field, this is equivalent to [`new`]. If it is too + /// long, the string is truncated at code-unit boundaries; a dangling high surrogate + /// at the cut point is also removed to preserve valid surrogate pairs. + /// + /// [`new`]: FixedString::new + #[expect( + clippy::missing_panics_doc, + reason = "the expect() is unreachable: truncation to at most WCHAR_COUNT-1 units guarantees from_wire_units succeeds" + )] + pub fn new_truncating(s: impl Into) -> Self { + let s = s.into(); + + let max = WCHAR_COUNT.saturating_sub(1); + + // Fast path: string fits — keep the owned String directly, no Vec needed. + if utf16_code_units(&s) <= max { + return Self(StringRepr::from_native(s)); + } + + // Slow path: truncate at a code-unit boundary, then drop a dangling high surrogate. + let mut units: Vec = s.encode_utf16().take(max).collect(); + if units.last().is_some_and(|&u| (0xD800..=0xDBFF).contains(&u)) { + units.pop(); + } + Self::from_wire_units(units).expect("truncated units cannot exceed WCHAR_COUNT - 1") + } + + /// Creates a `FixedString` from a native Rust string. + /// + /// Returns [`StringTooLong`] if the string requires more than `WCHAR_COUNT - 1` + /// UTF-16 code units (one slot is reserved for the null terminator). + pub fn new(s: impl Into) -> Result { + let s = s.into(); + let actual = utf16_code_units(&s); + + check_invariant(actual < WCHAR_COUNT).ok_or_else(|| StringTooLong { + max_code_units: WCHAR_COUNT.saturating_sub(1), + actual_code_units: actual, + })?; + + Ok(Self(StringRepr::from_native(s))) + } + + /// Tries to return the string content as a Rust `str`. + /// + /// Returns [`InvalidUtf16`] if the wire data contains a lone surrogate. + /// For strings decoded from the wire, this allocates a new `String`. + /// For strings constructed from native Rust code, this is a zero-cost borrow. + pub fn to_native(&self) -> Result, InvalidUtf16> { + self.0.to_native() + } + + /// Returns the string content, replacing any lone surrogates with U+FFFD. + /// + /// For strings decoded from the wire, this allocates a new `String`. + /// For strings constructed from native Rust code, this is a zero-cost borrow. + pub fn to_native_lossy(&self) -> Cow<'_, str> { + self.0.to_native_lossy() + } + + /// Consumes `self` and returns a validated native `String`. + /// + /// Zero-cost when the value was constructed from a native Rust string. + /// Validates and allocates when the value was decoded from the wire. + /// Returns [`InvalidUtf16`] if the wire data contains a lone surrogate. + pub fn into_native(self) -> Result { + self.0.into_native() + } + + /// Returns the UTF-16 code units of this string. + /// + /// For wire-decoded strings, this is a zero-cost borrow of the stored units. + /// For strings constructed from native Rust code, this encodes and allocates. + /// The returned units do not include a null terminator or zero-padding. + pub fn to_wire_units(&self) -> Cow<'_, [u16]> { + self.0.to_wire_units() + } + + /// Consumes `self` and returns the UTF-16 code units of this string. + /// + /// Zero-cost when the value was decoded from the wire (moves the internal buffer). + /// Encodes and allocates when the value was constructed from a native string. + /// The returned units do not include a null terminator or zero-padding. + pub fn into_wire_units(self) -> Vec { + self.0.into_wire_units() + } + + /// Consumes `self` and returns the raw UTF-16LE bytes of the string content. + /// + /// Zero-cost when the value was decoded from the wire (moves the internal buffer). + /// Encodes to UTF-16LE and allocates when the value was constructed from a native string. + /// The returned bytes do not include a null terminator or zero-padding. + pub fn into_wire(self) -> Vec { + self.0.into_wire() + } +} + +impl TryFrom> for String { + type Error = InvalidUtf16; + + fn try_from(s: FixedString) -> Result { + s.0.into_native() + } +} + +impl fmt::Display for FixedString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.to_native_lossy(), f) + } +} + +impl fmt::Debug for FixedString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "FixedString<{WCHAR_COUNT}>({:?})", self.0) + } +} + +impl Clone for FixedString { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl PartialEq for FixedString { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for FixedString {} + +impl Default for FixedString { + fn default() -> Self { + Self(StringRepr::from_native(String::new())) + } +} + +// ── Encode / DecodeOwned ────────────────────────────────────────────────────── + +impl Encode for FixedString { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: Self::WIRE_SIZE); + + let wire_bytes = self.0.as_wire_bytes(); + dst.write_slice(&wire_bytes); + let written_units = wire_bytes.len() / 2; + + // Zero-pad remaining slots (null terminator + any additional padding). + for _ in written_units..WCHAR_COUNT { + dst.write_u16(0); + } + + Ok(()) + } + + fn name(&self) -> &'static str { + "FixedString" + } + + fn size(&self) -> usize { + Self::WIRE_SIZE + } +} + +impl DecodeOwned for FixedString { + fn decode_owned(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: Self::WIRE_SIZE); + + let slice = src.read_slice(Self::WIRE_SIZE); + let units = crate::repr::le_bytes_to_units_strip_nulls(slice); + + // After stripping trailing nulls from WCHAR_COUNT units, the result must be + // strictly shorter — if no null was present the field is malformed. + if units.len() >= WCHAR_COUNT { + return Err(ironrdp_core::invalid_field_err!( + "content", + "fixed-size string field is missing its null terminator" + )); + } + + Ok(Self(StringRepr::from_wire_units(units))) + } +} diff --git a/crates/ironrdp-str/src/lib.rs b/crates/ironrdp-str/src/lib.rs new file mode 100644 index 0000000000..ed48ba82d5 --- /dev/null +++ b/crates/ironrdp-str/src/lib.rs @@ -0,0 +1,132 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] +#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] +#![cfg_attr(not(feature = "std"), no_std)] +#![warn(clippy::std_instead_of_alloc)] +#![warn(clippy::std_instead_of_core)] +#![cfg_attr(doc, warn(missing_docs))] + +#[cfg(feature = "alloc")] +extern crate alloc; + +#[cfg(feature = "alloc")] +use alloc::borrow::Cow; +#[cfg(all(feature = "alloc", not(feature = "std")))] +use alloc::vec::Vec; + +#[cfg(feature = "alloc")] +mod repr; + +#[cfg(feature = "alloc")] +pub mod fixed; + +#[cfg(feature = "alloc")] +pub mod prefixed; + +#[cfg(feature = "alloc")] +pub mod multi_sz; + +#[cfg(feature = "alloc")] +pub mod unframed; + +/// Error returned when a wire string contains an invalid UTF-16 sequence (lone surrogate). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidUtf16; + +/// Error returned when a string passed to a `MULTI_SZ` constructor contains an embedded +/// `NUL` (`\0` / U+0000 / `0x0000`). +/// +/// `MULTI_SZ` uses null as a segment delimiter, so an embedded null would corrupt segment +/// boundaries and break round-trip semantics. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EmbeddedNul; + +impl core::fmt::Display for InvalidUtf16 { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("invalid utf-16: lone surrogate in wire data") + } +} + +impl core::error::Error for InvalidUtf16 {} + +impl core::fmt::Display for EmbeddedNul { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("embedded nul: MULTI_SZ segment contains a U+0000 code unit") + } +} + +impl core::error::Error for EmbeddedNul {} + +/// Converts a slice of UTF-16LE wire bytes into a `Vec` of UTF-16 code unit values. +/// +/// Each consecutive pair of bytes is interpreted as one little-endian `u16` code unit. +/// Returns `None` if `bytes` has odd length (which is always a malformed UTF-16LE sequence). +/// +/// This is the correct way to hand off raw wire bytes to APIs that work with `&[u16]` or +/// `Vec`. +#[cfg(feature = "alloc")] +#[inline] +#[must_use] +pub fn utf16le_bytes_to_units(bytes: &[u8]) -> Option> { + bytes.len().is_multiple_of(2).then(|| repr::le_bytes_to_units(bytes)) +} + +/// Converts a slice of UTF-16 code unit values to a UTF-16LE byte representation. +/// +/// On **little-endian** targets this is a **zero-cost borrow**: the returned [`Cow`] points +/// directly into `units` without any allocation or copying. +/// On big-endian targets the bytes are swapped and a new `Vec` is allocated. +/// +/// The `bytemuck` crate is used internally for the zero-copy path. +/// +/// [`Cow`]: alloc::borrow::Cow +#[cfg(feature = "alloc")] +#[inline] +#[must_use] +pub fn utf16_units_to_le_bytes(units: &[u16]) -> Cow<'_, [u8]> { + #[cfg(target_endian = "little")] + { + Cow::Borrowed(bytemuck::cast_slice(units)) + } + #[cfg(not(target_endian = "little"))] + { + Cow::Owned(units.iter().flat_map(|u| u.to_le_bytes()).collect()) + } +} + +/// Number of UTF-16 code units (WCHARs) required to encode `s`, without null terminator. +/// +/// This is what every `cch`-prefixed RDP field counts. +/// For non-BMP characters (U+10000+), each encodes as a surrogate pair and counts as 2. +/// +/// **Never substitute `s.chars().count()` or `s.len()` for this.** +#[inline] +#[must_use] +pub fn utf16_code_units(s: &str) -> usize { + s.encode_utf16().count() +} + +/// Byte length of the UTF-16LE wire encoding of `s`, without null terminator. +/// +/// This is what every `cb`-prefixed RDP field counts when the null is excluded. +#[inline] +#[must_use] +pub fn utf16_byte_len(s: &str) -> usize { + s.encode_utf16().count() * 2 +} + +/// Byte length of the UTF-16LE wire encoding of `s`, including a UTF-16 null terminator +/// (2 bytes: `0x00 0x00`). +/// +/// This is what every `cb`-prefixed RDP field counts when the null is included. +#[inline] +#[must_use] +pub fn utf16_byte_len_with_null(s: &str) -> usize { + (s.encode_utf16().count() + 1) * 2 +} + +/// Use this when establishing invariants. +#[inline] +#[must_use] +fn check_invariant(condition: bool) -> Option<()> { + condition.then_some(()) +} diff --git a/crates/ironrdp-str/src/multi_sz.rs b/crates/ironrdp-str/src/multi_sz.rs new file mode 100644 index 0000000000..bae1557286 --- /dev/null +++ b/crates/ironrdp-str/src/multi_sz.rs @@ -0,0 +1,578 @@ +//! `MULTI_SZ` string list. +//! +//! Used for fields like `HardwareIds` and `CompatibilityIds` in MS-RDPEUSB §2.2.4.2. +//! +//! # Internal representation +//! +//! [`MultiSzString`] uses a dual-representation design analogous to `StringRepr`: +//! +//! - **`Wire`**: stores the raw UTF-16 code units for all string segments flat in one +//! `Vec`. Each segment ends with its null terminator (`0x0000`), but the final +//! sentinel null is **not** stored — it is always written by [`Encode`] and stripped +//! by [`DecodeOwned`]. This means decode is a single allocation (`memcpy`) plus a +//! one-slot `truncate`, and re-encode is a single bulk bytemuck write plus one +//! `write_u16(0)` for the sentinel. No per-segment scanning or allocation is needed +//! until the caller actually iterates the segments. +//! +//! - **`Native`**: stores a `Vec` of Rust strings. UTF-16 encoding is deferred +//! entirely to encode time. +//! +//! Wire layout for `["foo", "bar"]` (stored units in the `Wire` variant): +//! +//! ```text +//! stored: [f, o, o, 0x0000, b, a, r, 0x0000] (sentinel excluded) +//! wire: [u32 cch=9][f,o,o][0x0000][b,a,r][0x0000][0x0000 sentinel] +//! ``` +//! +//! [`Encode`]: ironrdp_core::Encode +//! [`DecodeOwned`]: ironrdp_core::DecodeOwned + +use alloc::borrow::Cow; +#[cfg(not(feature = "std"))] +use alloc::string::String; +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; +use core::fmt; + +use ironrdp_core::{ + DecodeOwned, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_size, + invalid_field_err, +}; + +use crate::{EmbeddedNul, InvalidUtf16}; + +// ── MultiSzSegmentError ─────────────────────────────────────────────────────── + +/// Error returned by [`MultiSzString::from_utf16le_byte_strings`] when a byte-slice segment +/// is malformed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MultiSzSegmentError { + /// The byte slice has odd length. UTF-16LE requires exactly 2 bytes per code unit. + OddByteCount, + /// The segment contains an embedded `0x0000` (U+0000) code unit, which would corrupt + /// `MULTI_SZ` segment boundaries and break round-trip semantics. + EmbeddedNul, +} + +impl fmt::Display for MultiSzSegmentError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::OddByteCount => f.write_str("odd byte count: UTF-16LE requires 2 bytes per code unit"), + Self::EmbeddedNul => f.write_str("embedded nul: MULTI_SZ segment contains a U+0000 code unit"), + } + } +} + +impl core::error::Error for MultiSzSegmentError {} + +// ── MultiSzFlatError ────────────────────────────────────────────────────────── + +/// Error returned by [`MultiSzString::from_utf16le_flat`] and +/// [`MultiSzString::from_wire_units_flat`] when the flat buffer is malformed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MultiSzFlatError { + /// The byte slice has odd length. UTF-16LE requires exactly 2 bytes per code unit. + /// + /// Only returned by [`MultiSzString::from_utf16le_flat`]. + OddByteCount, + /// The buffer does not end with the required sentinel null (`0x0000`). + MissingSentinel, + /// After stripping the sentinel, the remaining content is non-empty but does not end + /// with a per-string null terminator. The last segment would be silently dropped by + /// iteration. + UnterminatedLastSegment, +} + +impl fmt::Display for MultiSzFlatError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::OddByteCount => f.write_str("odd byte count: UTF-16LE requires 2 bytes per code unit"), + Self::MissingSentinel => f.write_str("MULTI_SZ flat buffer is missing the sentinel null"), + Self::UnterminatedLastSegment => f.write_str("MULTI_SZ last segment is missing its null terminator"), + } + } +} + +impl core::error::Error for MultiSzFlatError {} + +// ── Internal representation ─────────────────────────────────────────────────── + +/// Internal representation of a [`MultiSzString`]. +/// +/// | Logical value | `Wire` stored units | +/// |--------------------|-----------------------------------------------| +/// | `[]` | `[]` | +/// | `["foo"]` | `[f, o, o, 0x0000]` | +/// | `["foo", "bar"]` | `[f, o, o, 0x0000, b, a, r, 0x0000]` | +/// +/// Each segment ends with its per-string null terminator. The final sentinel null is +/// **not** included — it is implicit and always written by [`Encode`] / stripped by +/// [`DecodeOwned`]. +/// +/// [`Encode`]: ironrdp_core::Encode +/// [`DecodeOwned`]: ironrdp_core::DecodeOwned +enum MultiSzStringRepr { + /// Raw UTF-16 code units: all segments, each null-terminated; sentinel excluded. + Wire(Vec), + /// Validated native Rust strings, one per segment. + Native(Vec), +} + +// ── MultiSzString ───────────────────────────────────────────────────────────── + +/// A `MULTI_SZ`: a list of UTF-16LE strings, each null-terminated, followed by an extra +/// null, with the whole block prefixed by a `u32` WCHAR count that includes all null +/// terminators. +/// +/// Wire layout: `[u32 cch][str1 WCHARs][0x0000][str2 WCHARs][0x0000]...[0x0000]` +/// +/// The `u32 cch` counts **all** code units including all null terminators +/// (both per-string and the final sentinel). The minimum valid `cch` for an empty +/// list is 1 (just the final sentinel null). +/// +/// Wire data is accepted as-is with no UTF-16 validation at decode time. Call [`iter_native`] +/// for validated conversion, or [`iter_native_lossy`] to accept any byte sequence with +/// lone-surrogate replacement. +/// +/// [MS-RDPEUSB] §2.2.4.2 +/// +/// [MS-RDPEUSB]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/ +/// [`iter_native`]: MultiSzString::iter_native +/// [`iter_native_lossy`]: MultiSzString::iter_native_lossy +pub struct MultiSzString(MultiSzStringRepr); + +impl MultiSzString { + /// Creates a `MultiSzString` from an iterator of native Rust strings. + /// + /// Returns [`EmbeddedNul`] if any string contains an embedded `\0` (U+0000). MULTI_SZ + /// uses null as a segment delimiter, so an embedded null would corrupt segment boundaries + /// and break round-trip semantics. + pub fn new(strings: impl IntoIterator>) -> Result { + let strings: Vec = strings.into_iter().map(Into::into).collect(); + if strings.iter().any(|s| s.contains('\0')) { + return Err(EmbeddedNul); + } + Ok(Self(MultiSzStringRepr::Native(strings))) + } + + /// Creates a `MultiSzString` from an iterator of raw UTF-16LE byte slices, one per + /// string segment. + /// + /// Each byte slice is converted to `u16` code units and null-terminated; the resulting + /// segments are stored as a flat `Wire` buffer. + /// + /// Returns [`MultiSzSegmentError::OddByteCount`] if any slice has odd length, or + /// [`MultiSzSegmentError::EmbeddedNul`] if any decoded segment contains a `0x0000` + /// code unit. An embedded null would split the segment into multiple segments on iteration, + /// breaking the API contract of one byte slice per string. + #[expect( + single_use_lifetimes, + reason = "`'a` is required here because anonymous lifetimes in `impl Trait` are unstable; rustc incorrectly suggests eliding it" + )] + pub fn from_utf16le_byte_strings<'a>( + byte_strings: impl IntoIterator, + ) -> Result { + let mut units: Vec = Vec::new(); + for bytes in byte_strings { + if !bytes.len().is_multiple_of(2) { + return Err(MultiSzSegmentError::OddByteCount); + } + let segment = crate::repr::le_bytes_to_units(bytes); + if segment.contains(&0) { + return Err(MultiSzSegmentError::EmbeddedNul); + } + units.extend_from_slice(&segment); + units.push(0); + } + Ok(Self(MultiSzStringRepr::Wire(units))) + } + + /// Creates a `MultiSzString` from a flat UTF-16LE byte slice containing the complete + /// `MULTI_SZ` content: all string segments with their per-string null terminators, + /// followed by the final sentinel null. + /// + /// This is the flat-buffer counterpart to [`from_utf16le_byte_strings`]: instead of + /// one `&[u8]` per segment, the entire content arrives as a single contiguous slice + /// (e.g. straight from a registry value). The sentinel null is required and stripped + /// before storage; per-string nulls are retained. + /// + /// # Errors + /// + /// - [`MultiSzFlatError::OddByteCount`] — `bytes` has odd length. + /// - [`MultiSzFlatError::MissingSentinel`] — `bytes` does not end with `0x0000`. + /// - [`MultiSzFlatError::UnterminatedLastSegment`] — after stripping the sentinel, the + /// remaining content is non-empty but does not end with a per-string null terminator. + /// + /// [`from_utf16le_byte_strings`]: MultiSzString::from_utf16le_byte_strings + pub fn from_utf16le_flat(bytes: &[u8]) -> Result { + if !bytes.len().is_multiple_of(2) { + return Err(MultiSzFlatError::OddByteCount); + } + + let mut units = crate::repr::le_bytes_to_units(bytes); + + // Require and strip the sentinel null. + if units.last() != Some(&0) { + return Err(MultiSzFlatError::MissingSentinel); + } + + units.truncate(units.len() - 1); + + // After stripping the sentinel, the remaining content must either be empty + // (empty list) or end with a per-string null (last segment is properly terminated). + if !units.is_empty() && units.last() != Some(&0) { + return Err(MultiSzFlatError::UnterminatedLastSegment); + } + + Ok(Self(MultiSzStringRepr::Wire(units))) + } + + /// Creates a `MultiSzString` from a flat `Vec` of UTF-16 code units containing + /// the complete `MULTI_SZ` content: all string segments with their per-string null + /// terminators, followed by the final sentinel null. + /// + /// This is the flat-buffer counterpart to [`from_unit_strings`]: instead of one + /// `Vec` per segment, all segments arrive in a single pre-parsed vector. The + /// sentinel null is required and stripped before storage; per-string nulls are retained. + /// + /// # Errors + /// + /// - [`MultiSzFlatError::MissingSentinel`] — `units` does not end with `0x0000` + /// (including the empty-vector case). + /// - [`MultiSzFlatError::UnterminatedLastSegment`] — after stripping the sentinel, the + /// remaining content is non-empty but does not end with a per-string null terminator. + /// + /// [`from_unit_strings`]: MultiSzString::from_unit_strings + pub fn from_wire_units_flat(units: Vec) -> Result { + // Require and strip the sentinel null. + // If `units` is empty, `last()` returns `None` which != Some(&0), so we error. + if units.last() != Some(&0) { + return Err(MultiSzFlatError::MissingSentinel); + } + + let mut units = units; + units.truncate(units.len() - 1); + + // After stripping the sentinel, the remaining content must either be empty + // (empty list) or end with a per-string null (last segment is properly terminated). + if !units.is_empty() && units.last() != Some(&0) { + return Err(MultiSzFlatError::UnterminatedLastSegment); + } + + Ok(Self(MultiSzStringRepr::Wire(units))) + } + + /// Creates a `MultiSzString` from an iterator of pre-parsed UTF-16 code unit vectors, + /// one `Vec` per string segment. + /// + /// A single trailing `0x0000` in each segment is stripped. The per-segment null + /// terminator and the final sentinel are always written by [`Encode`] and consumed by + /// [`DecodeOwned`]. This is the low-level counterpart to [`DecodeOwned`] for callers + /// that already have units from [`utf16le_bytes_to_units`]. + /// + /// Returns [`EmbeddedNul`] if any segment contains an **interior** `0x0000` unit (i.e., + /// a null that is not the trailing terminator). MULTI_SZ uses null as a segment delimiter, + /// so an interior null would corrupt segment boundaries and break round-trip semantics. + /// + /// [`Encode`]: ironrdp_core::Encode + /// [`DecodeOwned`]: ironrdp_core::DecodeOwned + /// [`utf16le_bytes_to_units`]: crate::utf16le_bytes_to_units + pub fn from_unit_strings(unit_strings: impl IntoIterator>) -> Result { + let mut units: Vec = Vec::new(); + + for mut segment in unit_strings { + // Strip a single trailing null. + if segment.last() == Some(&0) { + segment.pop(); + } + + // Reject interior nulls — they would silently split this segment into multiple ones. + if segment.contains(&0) { + return Err(EmbeddedNul); + } + + units.extend_from_slice(&segment); + units.push(0); // per-segment null terminator + } + + Ok(Self(MultiSzStringRepr::Wire(units))) + } + + /// Returns an iterator over the string values. + /// + /// Returns [`InvalidUtf16`] per entry if the wire data for that entry contains + /// a lone surrogate. For wire-decoded strings, each successful entry allocates + /// a `String`. + pub fn iter_native(&self) -> impl Iterator, InvalidUtf16>> + '_ { + MultiSzNativeIter(match &self.0 { + MultiSzStringRepr::Wire(units) => MultiSzNativeIterInner::Wire(units.as_slice()), + MultiSzStringRepr::Native(strings) => MultiSzNativeIterInner::Native(strings.iter()), + }) + } + + /// Returns an iterator over the string values, replacing any lone surrogates with U+FFFD. + /// + /// For strings decoded from the wire, each entry allocates a `String`. + /// For strings constructed from native Rust code, each entry is a zero-cost borrow. + pub fn iter_native_lossy(&self) -> impl Iterator> + '_ { + MultiSzLossyIter(match &self.0 { + MultiSzStringRepr::Wire(units) => MultiSzLossyIterInner::Wire(units.as_slice()), + MultiSzStringRepr::Native(strings) => MultiSzLossyIterInner::Native(strings.iter()), + }) + } + + /// Consumes `self` and returns each string as a validated native `String`. + /// + /// Returns [`InvalidUtf16`] if any segment contains a lone surrogate. + /// Zero-cost per segment when the value was constructed from native Rust strings. + pub fn into_native(self) -> Result, InvalidUtf16> { + match self.0 { + MultiSzStringRepr::Wire(units) => { + let mut result: Vec = Vec::new(); + let mut remaining = units.as_slice(); + + while !remaining.is_empty() { + let Some(null_pos) = remaining.iter().position(|&u| u == 0) else { + break; + }; + + result.push(String::from_utf16(&remaining[..null_pos]).map_err(|_| InvalidUtf16)?); + remaining = &remaining[null_pos + 1..]; + } + + Ok(result) + } + MultiSzStringRepr::Native(strings) => Ok(strings), + } + } + + /// Returns the total number of UTF-16 code units on the wire, including all null + /// terminators and the final sentinel null. This is the value written as the `u32 cch` + /// prefix. + /// + /// # Panics + /// + /// Panics on arithmetic overflow (requires a pathologically large input that cannot + /// be represented on this platform). + pub fn total_cch(&self) -> usize { + self.checked_total_cch().expect("MULTI_SZ total length overflow") + } + + /// Like [`total_cch`], but returns `None` on `usize` overflow. + /// + /// [`total_cch`]: MultiSzString::total_cch + fn checked_total_cch(&self) -> Option { + match &self.0 { + // Stored units already include per-segment nulls; add 1 for the sentinel. + MultiSzStringRepr::Wire(units) => units.len().checked_add(1), + // Each string contributes its code units + 1 null; start from 1 for the sentinel. + MultiSzStringRepr::Native(strings) => strings.iter().try_fold(1usize, |acc, s| { + acc.checked_add(crate::utf16_code_units(s)) + .and_then(|n| n.checked_add(1)) + }), + } + } +} + +// ── Iterators ───────────────────────────────────────────────────────────────── + +/// Advances `remaining` past the next null-terminated segment and returns that segment. +/// +/// Returns `None` when `remaining` is empty (all segments consumed). +fn wire_next_segment<'a>(remaining: &mut &'a [u16]) -> Option<&'a [u16]> { + if remaining.is_empty() { + return None; + } + let null_pos = remaining.iter().position(|&u| u == 0)?; + let segment = &remaining[..null_pos]; + *remaining = &remaining[null_pos + 1..]; + Some(segment) +} + +struct MultiSzNativeIter<'a>(MultiSzNativeIterInner<'a>); + +enum MultiSzNativeIterInner<'a> { + Wire(&'a [u16]), + Native(core::slice::Iter<'a, String>), +} + +impl<'a> Iterator for MultiSzNativeIter<'a> { + type Item = Result, InvalidUtf16>; + + fn next(&mut self) -> Option { + match &mut self.0 { + MultiSzNativeIterInner::Wire(remaining) => wire_next_segment(remaining) + .map(|seg| String::from_utf16(seg).map(Cow::Owned).map_err(|_| InvalidUtf16)), + MultiSzNativeIterInner::Native(iter) => iter.next().map(|s| Ok(Cow::Borrowed(s.as_str()))), + } + } +} + +struct MultiSzLossyIter<'a>(MultiSzLossyIterInner<'a>); + +enum MultiSzLossyIterInner<'a> { + Wire(&'a [u16]), + Native(core::slice::Iter<'a, String>), +} + +impl<'a> Iterator for MultiSzLossyIter<'a> { + type Item = Cow<'a, str>; + + fn next(&mut self) -> Option { + match &mut self.0 { + MultiSzLossyIterInner::Wire(remaining) => { + wire_next_segment(remaining).map(|seg| Cow::Owned(String::from_utf16_lossy(seg))) + } + MultiSzLossyIterInner::Native(iter) => iter.next().map(|s| Cow::Borrowed(s.as_str())), + } + } +} + +// ── TryFrom, Debug, Clone, PartialEq, Eq ────────────────────────────────────── + +impl TryFrom for Vec { + type Error = InvalidUtf16; + + fn try_from(m: MultiSzString) -> Result { + m.into_native() + } +} + +impl fmt::Debug for MultiSzString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let strings: Vec> = self.iter_native_lossy().collect(); + write!(f, "MultiSzString({strings:?})") + } +} + +impl Clone for MultiSzString { + fn clone(&self) -> Self { + Self(match &self.0 { + MultiSzStringRepr::Wire(units) => MultiSzStringRepr::Wire(units.clone()), + MultiSzStringRepr::Native(strings) => MultiSzStringRepr::Native(strings.clone()), + }) + } +} + +impl PartialEq for MultiSzString { + fn eq(&self, other: &Self) -> bool { + match (&self.0, &other.0) { + (MultiSzStringRepr::Wire(a), MultiSzStringRepr::Wire(b)) => a == b, + (MultiSzStringRepr::Native(a), MultiSzStringRepr::Native(b)) => a == b, + (MultiSzStringRepr::Wire(units), MultiSzStringRepr::Native(strings)) + | (MultiSzStringRepr::Native(strings), MultiSzStringRepr::Wire(units)) => { + let native_iter = strings + .iter() + .flat_map(|s| s.encode_utf16().chain(core::iter::once(0u16))); + units.iter().copied().eq(native_iter) + } + } + } +} + +impl Eq for MultiSzString {} + +// ── Encode / DecodeOwned ────────────────────────────────────────────────────── + +impl Encode for MultiSzString { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + // Checked arithmetic first: overflow → error, not a silent wrap. + let total_cch: u32 = cast_length!( + "cch", + self.checked_total_cch() + .ok_or_else(|| invalid_field_err!("cch", "MULTI_SZ total length overflow"))? + )?; + + ensure_size!(in: dst, size: self.size()); + dst.write_u32(total_cch); + + match &self.0 { + MultiSzStringRepr::Wire(units) => { + // Write flat unit buffer as UTF-16LE bytes. + #[cfg(target_endian = "little")] + { + dst.write_slice(bytemuck::cast_slice(units.as_slice())); + } + #[cfg(not(target_endian = "little"))] + { + for &u in units { + dst.write_u16(u); + } + } + } + MultiSzStringRepr::Native(strings) => { + for s in strings { + for unit in s.encode_utf16() { + dst.write_u16(unit); + } + dst.write_u16(0); // per-string null terminator + } + } + } + + dst.write_u16(0); // final sentinel null + + Ok(()) + } + + fn name(&self) -> &'static str { + "MultiSzString" + } + + fn size(&self) -> usize { + // Use checked arithmetic so overflow panics here rather than silently producing + // usize::MAX, which would cause encode_vec() to attempt a huge allocation. + let total_cch = self + .checked_total_cch() + .expect("MULTI_SZ total length overflow when computing size()"); + + total_cch + .checked_mul(2) + .and_then(|bytes_for_units| bytes_for_units.checked_add(4)) + .expect("MULTI_SZ encoded size overflow") + } +} + +impl DecodeOwned for MultiSzString { + fn decode_owned(src: &mut ReadCursor<'_>) -> DecodeResult { + ensure_size!(in: src, size: 4); + let total_cch: usize = cast_length!("cch", src.read_u32())?; + + // The minimum valid total_cch is 1 (just the final sentinel null). + if total_cch == 0 { + return Err(invalid_field_err!("cch", "zero cch for MULTI_SZ is invalid")); + } + + let byte_count = total_cch + .checked_mul(2) + .ok_or_else(|| invalid_field_err!("cch", "MULTI_SZ byte length overflow"))?; + ensure_size!(in: src, size: byte_count); + + // One allocation: read all bytes and reinterpret as u16 code units. + let all_bytes = src.read_slice(byte_count); + let mut all_units = crate::repr::le_bytes_to_units(all_bytes); + + // The last code unit must be the final sentinel null (0x0000). + if let Some(&unit) = all_units.last() + && unit != 0 + { + return Err(invalid_field_err!("content", "MULTI_SZ must end with a null sentinel")); + } + + // Strip the sentinel null; per-string null terminators are retained in storage. + all_units.truncate(all_units.len() - 1); + + // After stripping the sentinel, the remaining content must be empty (empty list) + // or end with a per-string null (the last segment is properly terminated). + // Without this check, a last segment without its own null would be silently dropped + // by the null-scanning iterators. + if !all_units.is_empty() && all_units.last() != Some(&0) { + return Err(invalid_field_err!( + "content", + "MULTI_SZ last segment is missing its null terminator" + )); + } + + Ok(Self(MultiSzStringRepr::Wire(all_units))) + } +} diff --git a/crates/ironrdp-str/src/prefixed.rs b/crates/ironrdp-str/src/prefixed.rs new file mode 100644 index 0000000000..d9faaa29a9 --- /dev/null +++ b/crates/ironrdp-str/src/prefixed.rs @@ -0,0 +1,545 @@ +//! Length-prefixed Unicode string fields. +//! +//! The two axes — length prefix type and null terminator policy — are encoded as +//! zero-sized marker types, with the actual encode/decode logic driven by sealed traits. +//! Concrete type aliases are provided for every field shape that appears in the RDP specs. + +use alloc::borrow::Cow; +#[cfg(not(feature = "std"))] +use alloc::borrow::ToOwned; +#[cfg(not(feature = "std"))] +use alloc::string::String; +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; +use core::fmt; +use core::marker::PhantomData; + +use ironrdp_core::{ + DecodeOwned, DecodeResult, Encode, EncodeResult, ReadCursor, WriteCursor, cast_length, ensure_size, + invalid_field_err, +}; + +use crate::InvalidUtf16; +use crate::repr::StringRepr; + +// ── Sealed trait machinery ──────────────────────────────────────────────────── + +mod sealed { + pub trait Sealed {} +} + +// ── Length prefix markers ───────────────────────────────────────────────────── + +/// Marker: `u16` WCHAR count prefix (`cch` fields, e.g. `cchPCB` in MS-RDPEPS). +pub struct CchU16; +/// Marker: `u32` WCHAR count prefix (`cch` fields, e.g. `cchDeviceInstanceId` in MS-RDPEUSB). +pub struct CchU32; +/// Marker: `u16` byte count prefix (`cb` fields, e.g. `cbDomain` in MS-RDPBCGR). +pub struct CbU16; +/// Marker: `u32` byte count prefix. +pub struct CbU32; + +impl sealed::Sealed for CchU16 {} +impl sealed::Sealed for CchU32 {} +impl sealed::Sealed for CbU16 {} +impl sealed::Sealed for CbU32 {} + +/// Sealed trait implemented by length-prefix marker types. +/// +/// This trait is sealed: only the marker types in this crate ([`CchU16`], [`CchU32`], +/// [`CbU16`], [`CbU32`]) implement it, and no external implementation is possible. +/// It is `pub` so callers can write generic code bounded on it. +pub trait LengthPrefix: sealed::Sealed { + #[doc(hidden)] + const WIRE_SIZE: usize; + + #[doc(hidden)] + const IS_BYTE_COUNT: bool; + + #[doc(hidden)] + fn read_raw(src: &mut ReadCursor<'_>) -> DecodeResult; + + #[doc(hidden)] + fn write_raw(value: usize, dst: &mut WriteCursor<'_>) -> EncodeResult<()>; +} + +impl LengthPrefix for CchU16 { + const WIRE_SIZE: usize = 2; + + const IS_BYTE_COUNT: bool = false; + + fn read_raw(src: &mut ReadCursor<'_>) -> DecodeResult { + Ok(usize::from(src.read_u16())) + } + + fn write_raw(value: usize, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + let v: u16 = cast_length!("length prefix", value)?; + dst.write_u16(v); + Ok(()) + } +} + +impl LengthPrefix for CchU32 { + const WIRE_SIZE: usize = 4; + + const IS_BYTE_COUNT: bool = false; + + fn read_raw(src: &mut ReadCursor<'_>) -> DecodeResult { + cast_length!("length prefix", src.read_u32()) + } + + fn write_raw(value: usize, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + let v: u32 = cast_length!("length prefix", value)?; + dst.write_u32(v); + Ok(()) + } +} + +impl LengthPrefix for CbU16 { + const WIRE_SIZE: usize = 2; + + const IS_BYTE_COUNT: bool = true; + + fn read_raw(src: &mut ReadCursor<'_>) -> DecodeResult { + Ok(usize::from(src.read_u16())) + } + + fn write_raw(value: usize, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + let v: u16 = cast_length!("length prefix", value)?; + dst.write_u16(v); + Ok(()) + } +} + +impl LengthPrefix for CbU32 { + const WIRE_SIZE: usize = 4; + + const IS_BYTE_COUNT: bool = true; + + fn read_raw(src: &mut ReadCursor<'_>) -> DecodeResult { + cast_length!("length prefix", src.read_u32()) + } + + fn write_raw(value: usize, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + let v: u32 = cast_length!("length prefix", value)?; + dst.write_u32(v); + Ok(()) + } +} + +// ── Null terminator markers ─────────────────────────────────────────────────── + +/// Marker: null terminator is present on the wire **and** counted in the length prefix. +/// +/// Used for: `cchPCB`/`wszPCB` ([MS-RDPEPS] §2.2.1.2), `cchDeviceInstanceId` ([MS-RDPEUSB] §2.2.4.2), +/// `cbClientAddress`, `cbClientDir` ([MS-RDPBCGR] §2.2.1.11.1.1). +/// +/// [MS-RDPEPS]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeps/ +/// [MS-RDPEUSB]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/ +/// [MS-RDPBCGR]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/ +pub struct NullCounted; + +/// Marker: null terminator is present on the wire but **not** counted in the length prefix. +/// +/// Used for: `cbDomain`, `cbUserName`, `cbPassword`, `cbAlternateShell`, `cbWorkingDir` +/// ([MS-RDPBCGR] §2.2.1.11.1.1). Spec: "excludes the length of the mandatory null terminator." +/// +/// [MS-RDPBCGR]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/ +pub struct NullUncounted; + +/// Marker: no null terminator on the wire at all. +/// +/// Used for: `UNICODE_STRING.String` ([MS-RDPERP] §2.2.1.2.1), +/// `dynamicDSTTimeZoneKeyName` ([MS-RDPBCGR] §2.2.1.11.1.1). +/// Spec: "a non-null-terminated Unicode character string." +/// +/// [MS-RDPERP]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdperp/ +/// [MS-RDPBCGR]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/ +pub struct NoNull; + +impl sealed::Sealed for NullCounted {} +impl sealed::Sealed for NullUncounted {} +impl sealed::Sealed for NoNull {} + +/// Sealed trait implemented by null-terminator policy marker types. +/// +/// This trait is sealed: only the marker types in this crate ([`NullCounted`], +/// [`NullUncounted`], [`NoNull`]) implement it. It is `pub` so callers can write +/// generic code bounded on it. +pub trait NullTerminatorPolicy: sealed::Sealed { + #[doc(hidden)] + const HAS_NULL_ON_WIRE: bool; + + #[doc(hidden)] + const NULL_COUNTED_IN_PREFIX: bool; +} + +impl NullTerminatorPolicy for NullCounted { + const HAS_NULL_ON_WIRE: bool = true; + const NULL_COUNTED_IN_PREFIX: bool = true; +} + +impl NullTerminatorPolicy for NullUncounted { + const HAS_NULL_ON_WIRE: bool = true; + const NULL_COUNTED_IN_PREFIX: bool = false; +} + +impl NullTerminatorPolicy for NoNull { + const HAS_NULL_ON_WIRE: bool = false; + const NULL_COUNTED_IN_PREFIX: bool = false; +} + +// ── PrefixedString ──────────────────────────────────────────────────────── + +/// A variable-length UTF-16LE string with a self-describing length prefix. +/// +/// The two type parameters encode the wire format: +/// - `Prefix`: one of [`CchU16`], [`CchU32`], [`CbU16`], [`CbU32`]. +/// - `Null`: one of [`NullCounted`], [`NullUncounted`], [`NoNull`]. +/// +/// Use the provided type aliases ([`CchString`], [`CbStringNullExcluded`], etc.) +/// rather than naming this type directly. +pub struct PrefixedString(StringRepr, PhantomData<(Prefix, Null)>); + +impl PrefixedString { + /// Creates a `PrefixedString` from a native Rust string. + pub fn new(s: impl Into) -> Self { + Self(StringRepr::from_native(s.into()), PhantomData) + } + + /// Creates a `PrefixedString` from raw UTF-16LE wire bytes. + /// + /// Returns `None` if `bytes` has odd length. This is a convenience wrapper around + /// [`utf16le_bytes_to_units`] + [`from_wire_units`]. + /// + /// [`utf16le_bytes_to_units`]: crate::utf16le_bytes_to_units + /// [`from_wire_units`]: PrefixedString::from_wire_units + pub fn from_utf16le_bytes(bytes: &[u8]) -> Option { + crate::utf16le_bytes_to_units(bytes).map(Self::from_wire_units) + } + + /// Creates a `PrefixedString` from pre-parsed UTF-16 code units. + /// + /// Trailing null code units are stripped; the null terminator is a wire-level concern + /// handled by the `N` type parameter during [`Encode`]. This is the low-level + /// counterpart to [`decode_owned`] for callers that already have units from + /// [`utf16le_bytes_to_units`]. + /// + /// [`Encode`]: ironrdp_core::Encode + /// [`decode_owned`]: ironrdp_core::DecodeOwned::decode_owned + /// [`utf16le_bytes_to_units`]: crate::utf16le_bytes_to_units + pub fn from_wire_units(units: Vec) -> Self { + let mut units = units; + let end = units.iter().rposition(|&u| u != 0).map_or(0, |i| i + 1); + units.truncate(end); + Self(StringRepr::from_wire_units(units), PhantomData) + } + + /// Tries to return the string content as a Rust `str`. + /// + /// Returns [`InvalidUtf16`] if the wire data contains a lone surrogate. + /// For strings decoded from the wire, this allocates a new `String`. + /// For strings constructed from native Rust code, this is a zero-cost borrow. + pub fn to_native(&self) -> Result, InvalidUtf16> { + self.0.to_native() + } + + /// Returns the string content, replacing any lone surrogates with U+FFFD. + /// + /// For strings decoded from the wire, this allocates a new `String`. + /// For strings constructed from native Rust code, this is a zero-cost borrow. + pub fn to_native_lossy(&self) -> Cow<'_, str> { + self.0.to_native_lossy() + } + + /// Returns the number of UTF-16 code units (WCHARs) in this string. + /// + /// O(1) for wire-decoded strings, O(n) for natively-constructed strings. + pub fn utf16_len(&self) -> usize { + self.0.utf16_len() + } + + /// Consumes `self` and returns a validated native `String`. + /// + /// Zero-cost when the value was constructed from a native Rust string. + /// Validates and allocates when the value was decoded from the wire. + /// Returns [`InvalidUtf16`] if the wire data contains a lone surrogate. + pub fn into_native(self) -> Result { + self.0.into_native() + } + + /// Returns the UTF-16 code units of this string. + /// + /// For wire-decoded strings, this is a zero-cost borrow of the stored units. + /// For strings constructed from native Rust code, this encodes and allocates. + /// The returned units do not include a null terminator or length prefix. + pub fn to_wire_units(&self) -> Cow<'_, [u16]> { + self.0.to_wire_units() + } + + /// Consumes `self` and returns the UTF-16 code units of this string. + /// + /// Zero-cost when the value was decoded from the wire (moves the internal buffer). + /// Encodes and allocates when the value was constructed from a native string. + /// The returned units do not include a null terminator or length prefix. + pub fn into_wire_units(self) -> Vec { + self.0.into_wire_units() + } + + /// Consumes `self` and returns the raw UTF-16LE bytes of the string content. + /// + /// Zero-cost when the value was decoded from the wire (moves the internal buffer). + /// Encodes to UTF-16LE and allocates when the value was constructed from a native string. + /// The returned bytes do not include a null terminator or length prefix. + pub fn into_wire(self) -> Vec { + self.0.into_wire() + } +} + +impl From for PrefixedString { + fn from(s: String) -> Self { + Self::new(s) + } +} + +impl From<&str> for PrefixedString { + fn from(s: &str) -> Self { + Self::new(s.to_owned()) + } +} + +impl TryFrom> for String { + type Error = InvalidUtf16; + + fn try_from(f: PrefixedString) -> Result { + f.0.into_native() + } +} + +impl fmt::Display for PrefixedString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.to_native_lossy(), f) + } +} + +impl fmt::Debug for PrefixedString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "PrefixedString({:?})", self.0) + } +} + +impl Clone for PrefixedString { + fn clone(&self) -> Self { + Self(self.0.clone(), PhantomData) + } +} + +impl PartialEq for PrefixedString { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for PrefixedString {} + +impl core::hash::Hash for PrefixedString { + fn hash(&self, state: &mut H) { + self.0.hash(state); + } +} + +// ── Encode ──────────────────────────────────────────────────────────────────── + +impl Encode for PrefixedString { + fn encode(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.size()); + + let content_cch = self.0.utf16_len(); + + // The prefix value counts either code units or bytes, with or without the null. + let counted_cch = if N::NULL_COUNTED_IN_PREFIX { + content_cch + .checked_add(1) + .ok_or_else(|| invalid_field_err!("length prefix", "content length overflow"))? + } else { + content_cch + }; + let prefix_value = if P::IS_BYTE_COUNT { + counted_cch + .checked_mul(2) + .ok_or_else(|| invalid_field_err!("length prefix", "byte length overflow"))? + } else { + counted_cch + }; + + P::write_raw(prefix_value, dst)?; + + let wire_bytes = self.0.as_wire_bytes(); + dst.write_slice(&wire_bytes); + + if N::HAS_NULL_ON_WIRE { + dst.write_u16(0); + } + + Ok(()) + } + + fn name(&self) -> &'static str { + "PrefixedString" + } + + fn size(&self) -> usize { + P::WIRE_SIZE // length prefix + + self.0.utf16_byte_len() // content + + if N::HAS_NULL_ON_WIRE { 2 } else { 0 } // null terminator + } +} + +// ── DecodeOwned ─────────────────────────────────────────────────────────────── + +impl DecodeOwned for PrefixedString { + fn decode_owned(src: &mut ReadCursor<'_>) -> DecodeResult { + // Step 1: Read the raw prefix value. + ensure_size!(in: src, size: P::WIRE_SIZE); + let raw = P::read_raw(src)?; + + // Step 2: Convert the raw prefix to a code-unit count on the wire. + let cch_on_wire = if P::IS_BYTE_COUNT { + if raw % 2 != 0 { + return Err(invalid_field_err!( + "length prefix", + "odd byte count for utf-16 string field" + )); + } + raw / 2 + } else { + raw + }; + + // Step 3: Determine content length (code units of actual string content, excluding null). + // + // NullCounted: prefix counts content + null, so cch_on_wire == 0 is invalid + // (minimum is 1 for an empty string). Reject here before the subtraction. + // NullUncounted / NoNull: cch_on_wire is the content length directly. + let content_cch = if N::NULL_COUNTED_IN_PREFIX { + if cch_on_wire == 0 { + return Err(invalid_field_err!( + "length prefix", + "NullCounted prefix of 0 is invalid; minimum is 1 (empty string with null)" + )); + } + cch_on_wire - 1 + } else { + cch_on_wire + }; + + // Step 4: Read content code units (bulk copy, convert LE bytes to u16 values). + let content_byte_count = content_cch + .checked_mul(2) + .ok_or_else(|| invalid_field_err!("length prefix", "byte length overflow"))?; + ensure_size!(in: src, size: content_byte_count); + let slice = src.read_slice(content_byte_count); + let units = crate::repr::le_bytes_to_units(slice); + + // Step 5: Read and validate the null terminator if the format requires one on the wire. + // + // NullCounted: we just read `content_cch` units; the next unit must be 0x0000. + // NullUncounted: the null follows the content (even for zero-length content). + // NoNull: skip entirely. + if N::HAS_NULL_ON_WIRE { + ensure_size!(in: src, size: 2); + let null = src.read_u16(); + if null != 0 { + return Err(invalid_field_err!("null terminator", "expected 0x0000 null terminator")); + } + } + + Ok(Self(StringRepr::from_wire_units(units), PhantomData)) + } +} + +// ── Type aliases ────────────────────────────────────────────────────────────── + +/// UTF-16 string with a `u16` WCHAR count prefix, null terminator counted in the prefix. +/// +/// Used for `cchPCB`/`wszPCB` in the Preconnection Blob. +/// +/// Wire layout: `[u16 cch][cch WCHARs including null]` +/// +/// [MS-RDPEPS] §2.2.1.2 +/// +/// [MS-RDPEPS]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeps/ +pub type CchString = PrefixedString; + +/// UTF-16 string with a `u32` WCHAR count prefix, null terminator counted in the prefix. +/// +/// Used for `cchDeviceInstanceId`, `cchContainerId`, `cchHwIds`, `cchCompatIds` in the +/// USB device descriptor. +/// +/// Wire layout: `[u32 cch][cch WCHARs including null]` +/// +/// [MS-RDPEUSB] §2.2.4.2 +/// +/// [MS-RDPEUSB]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/ +pub type Cch32String = PrefixedString; + +/// UTF-16 string with a `u16` byte count prefix, null terminator **not** counted in the prefix. +/// +/// Used for `cbDomain`, `cbUserName`, `cbPassword`, `cbAlternateShell`, `cbWorkingDir` +/// in the Info Packet. Spec: "excludes the length of the mandatory null terminator." +/// +/// Wire layout: `[u16 cb][cb/2 WCHARs][null WCHAR]` +/// +/// [MS-RDPBCGR] §2.2.1.11.1.1 +/// +/// [MS-RDPBCGR]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/ +pub type CbStringNullExcluded = PrefixedString; + +/// UTF-16 string with a `u16` byte count prefix, null terminator counted in the prefix. +/// +/// Used for `cbClientAddress`, `cbClientDir` in the Extended Info Packet. +/// Spec: "includes the length of the mandatory null terminator." +/// +/// Wire layout: `[u16 cb][cb/2 WCHARs including null]` +/// +/// [MS-RDPBCGR] §2.2.1.11.1.1 +/// +/// [MS-RDPBCGR]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/ +pub type CbStringNullIncluded = PrefixedString; + +/// Non-null-terminated UTF-16 string with a `u16` byte count prefix. +/// +/// Used for `UNICODE_STRING.String` in Remote Programs (RAIL). +/// Spec: "A non-null-terminated Unicode character string." +/// +/// Wire layout: `[u16 cb][cb/2 WCHARs]` +/// +/// [MS-RDPERP] §2.2.1.2.1 +/// +/// [MS-RDPERP]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdperp/ +pub type RailString = PrefixedString; + +/// Non-null-terminated UTF-16 string with a `u16` byte count prefix. +/// +/// Used for `dynamicDSTTimeZoneKeyName`. Spec: "A variable-length array of Unicode +/// characters with no terminating null character." +/// +/// Wire layout: `[u16 cb][cb/2 WCHARs]` +/// +/// [MS-RDPBCGR] §2.2.1.11.1.1 +/// +/// [MS-RDPBCGR]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/ +pub type CbStringNoNull = PrefixedString; + +/// UTF-16 string with a `u32` byte count prefix, null terminator counted in the prefix. +/// +/// Used for `cbCompanyName`, `cbProductId` in the Product Info structure and +/// `LicenseInformation`. Spec: "A 32-bit unsigned integer that contains the number of +/// bytes in the pbCompanyName field, including the terminating null character." +/// +/// Wire layout: `[u32 cb][cb/2 - 1 WCHARs][null WCHAR]` +/// +/// [MS-RDPELE] §2.2.2.1.1, §2.2.2.6.1 +/// +/// [MS-RDPELE]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpele/ +pub type CbU32StringNullIncluded = PrefixedString; diff --git a/crates/ironrdp-str/src/repr.rs b/crates/ironrdp-str/src/repr.rs new file mode 100644 index 0000000000..d05a8863d0 --- /dev/null +++ b/crates/ironrdp-str/src/repr.rs @@ -0,0 +1,294 @@ +//! Internal dual-representation string value. +//! +//! This module contains `StringRepr`, the common backing store for all string types in +//! this crate. + +use alloc::borrow::Cow; +#[cfg(not(feature = "std"))] +use alloc::string::String; +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; +use core::fmt; + +use crate::InvalidUtf16; + +// ── StringRepr ──────────────────────────────────────────────────────────────── + +/// The internal representation of an RDP string field value. +/// +/// All string types in this crate use `StringRepr` as their internal storage. +/// The choice of variant is an implementation detail invisible to callers. +/// +/// - `Wire`: produced by decoding from the wire. Stores UTF-16 code unit values as `u16`; +/// re-encoding to wire bytes is zero-cost on little-endian targets via `bytemuck`. +/// - `Native`: produced by construction from a Rust `String` or `&str`. +#[derive(Clone)] +pub(crate) enum StringRepr { + /// UTF-16 code unit values decoded from the wire, stored as `u16`. + /// + /// INVARIANT: null terminators are never stored here (callers strip them on decode). + /// NOTE: code units are NOT validated; lone surrogates may be present. + Wire(Vec), + + /// Native UTF-8 string from Rust caller code. + Native(String), +} + +impl StringRepr { + /// Creates a `Wire` variant directly from UTF-16 code units. + /// + /// The caller must ensure that null terminators have already been stripped. + pub(crate) fn from_wire_units(units: Vec) -> Self { + Self::Wire(units) + } + + /// Creates a `Native` variant from a Rust string. + pub(crate) fn from_native(s: String) -> Self { + Self::Native(s) + } + + /// Tries to return the string content as a Rust `str`. + /// + /// Returns [`InvalidUtf16`] if the wire data contains a lone surrogate. + /// For `Wire` this allocates a new `String`. For `Native` this is a zero-cost borrow. + pub(crate) fn to_native(&self) -> Result, InvalidUtf16> { + match self { + Self::Wire(units) => String::from_utf16(units).map(Cow::Owned).map_err(|_| InvalidUtf16), + Self::Native(s) => Ok(Cow::Borrowed(s.as_str())), + } + } + + /// Returns the string content, replacing any lone surrogates with U+FFFD. + /// + /// For `Wire` this allocates a new `String`. For `Native` this is a zero-cost borrow. + pub(crate) fn to_native_lossy(&self) -> Cow<'_, str> { + match self { + Self::Wire(units) => Cow::Owned(String::from_utf16_lossy(units)), + Self::Native(s) => Cow::Borrowed(s.as_str()), + } + } + + /// Returns an iterator over the UTF-16 code units of this string. + /// + /// Zero-allocation for both variants. + pub(crate) fn utf16_units(&self) -> Utf16Units<'_> { + match self { + Self::Wire(units) => Utf16Units(Utf16UnitsInner::Wire(units.iter().copied())), + Self::Native(s) => Utf16Units(Utf16UnitsInner::Native(s.encode_utf16())), + } + } + + /// Returns the number of UTF-16 code units (WCHARs) in this string. + /// + /// O(1) for the `Wire` variant, O(n) for the `Native` variant. + pub(crate) fn utf16_len(&self) -> usize { + match self { + Self::Wire(units) => units.len(), + Self::Native(s) => s.encode_utf16().count(), + } + } + + /// Returns the wire byte length of the UTF-16LE encoding (`utf16_len() * 2`). + pub(crate) fn utf16_byte_len(&self) -> usize { + match self { + Self::Wire(units) => units.len() * 2, + Self::Native(s) => s.encode_utf16().count() * 2, + } + } + + /// Returns the raw bytes of the wire representation. + /// + /// For `Wire` on little-endian targets, this is a zero-cost borrow via `bytemuck`. + /// For `Native`, or on big-endian targets, this encodes to UTF-16LE and allocates. + pub(crate) fn as_wire_bytes(&self) -> Cow<'_, [u8]> { + match self { + Self::Wire(units) => { + #[cfg(target_endian = "little")] + { + Cow::Borrowed(bytemuck::cast_slice(units.as_slice())) + } + #[cfg(not(target_endian = "little"))] + { + Cow::Owned(units.iter().flat_map(|u| u.to_le_bytes()).collect()) + } + } + Self::Native(s) => Cow::Owned(s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect()), + } + } + + /// Returns the UTF-16 code units of this string. + /// + /// For `Wire`, this is a zero-cost borrow of the stored code units. + /// For `Native`, this encodes the string to UTF-16 and allocates a `Vec`. + pub(crate) fn to_wire_units(&self) -> Cow<'_, [u16]> { + match self { + Self::Wire(units) => Cow::Borrowed(units.as_slice()), + Self::Native(s) => Cow::Owned(s.encode_utf16().collect()), + } + } + + /// Consumes `self` and returns the UTF-16 code units. + /// + /// For `Wire`, this is a zero-cost move of the stored `Vec`. + /// For `Native`, this encodes the string to UTF-16 and allocates a `Vec`. + pub(crate) fn into_wire_units(self) -> Vec { + match self { + Self::Wire(units) => units, + Self::Native(s) => s.encode_utf16().collect(), + } + } + + /// Consumes `self` and returns a validated native `String`. + /// + /// For `Native`, this is a zero-cost unwrap of the stored `String`. + /// For `Wire`, this validates the UTF-16 sequence and allocates a new `String`. + /// Returns [`InvalidUtf16`] if the wire data contains a lone surrogate. + pub(crate) fn into_native(self) -> Result { + match self { + Self::Native(s) => Ok(s), + Self::Wire(units) => String::from_utf16(&units).map_err(|_| InvalidUtf16), + } + } + + /// Consumes `self` and returns the raw UTF-16LE bytes. + /// + /// For `Wire` on little-endian targets, this is a zero-cost move via `bytemuck::cast_vec`. + /// For `Native`, or on big-endian targets, this encodes to UTF-16LE and allocates. + pub(crate) fn into_wire(self) -> Vec { + match self { + Self::Wire(units) => { + #[cfg(target_endian = "little")] + { + bytemuck::cast_vec(units) + } + #[cfg(not(target_endian = "little"))] + { + units.iter().flat_map(|u| u.to_le_bytes()).collect() + } + } + Self::Native(s) => s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect(), + } + } +} + +impl PartialEq for StringRepr { + fn eq(&self, other: &Self) -> bool { + // Compare by UTF-16 code unit sequence. This is correct because two strings + // with identical code unit sequences represent the same wire bytes. + self.utf16_units().eq(other.utf16_units()) + } +} + +impl Eq for StringRepr {} + +impl core::hash::Hash for StringRepr { + fn hash(&self, state: &mut H) { + // Must be consistent with PartialEq: hash the UTF-16 code unit sequence. + self.utf16_units().for_each(|u| u.hash(state)); + } +} + +impl fmt::Debug for StringRepr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Wire(units) => match String::from_utf16(units) { + Ok(s) => write!(f, "Wire({s:?})"), + Err(_) => write!(f, "Wire()"), + }, + Self::Native(s) => write!(f, "Native({s:?})"), + } + } +} + +// ── Utf16Units iterator ─────────────────────────────────────────────────────── + +/// Zero-allocation iterator over UTF-16 code units. +pub(crate) struct Utf16Units<'a>(Utf16UnitsInner<'a>); + +enum Utf16UnitsInner<'a> { + Wire(core::iter::Copied>), + Native(core::str::EncodeUtf16<'a>), +} + +impl Iterator for Utf16Units<'_> { + type Item = u16; + + fn next(&mut self) -> Option { + match &mut self.0 { + Utf16UnitsInner::Wire(it) => it.next(), + Utf16UnitsInner::Native(it) => it.next(), + } + } + + fn size_hint(&self) -> (usize, Option) { + match &self.0 { + Utf16UnitsInner::Wire(it) => it.size_hint(), + Utf16UnitsInner::Native(it) => it.size_hint(), + } + } +} + +// ── Wire-byte conversion helpers ────────────────────────────────────────────── + +/// Converts a slice of UTF-16LE bytes into a `Vec` of code unit values. +/// +/// On little-endian targets, uses `bytemuck::try_cast_slice` to reinterpret the bytes as +/// `u16` values without per-element endian conversion, then bulk-copies into a `Vec`. +/// If the input is misaligned the bytes are copied per element as a fallback. +/// On big-endian targets the bytes are always byte-swapped per element. +/// +/// In all cases an allocation is performed; the optimization on little-endian targets is +/// avoiding per-element byte-swapping rather than eliminating the allocation. +/// +/// # Panics +/// +/// Panics in debug builds if `bytes.len()` is odd. +pub(crate) fn le_bytes_to_units(bytes: &[u8]) -> Vec { + debug_assert!(bytes.len().is_multiple_of(2), "le_bytes_to_units: odd byte count"); + + #[cfg(target_endian = "little")] + if let Ok(units) = bytemuck::try_cast_slice::(bytes) { + return units.to_vec(); + } + + bytes + .chunks_exact(2) + .map(|b| u16::from_le_bytes([b[0], b[1]])) + .collect() +} + +/// Converts a slice of UTF-16LE bytes into a `Vec`, stripping trailing null code units. +/// +/// On little-endian targets, uses `bytemuck::try_cast_slice` to reinterpret the bytes as +/// `u16` values without per-element endian conversion, then bulk-copies into a `Vec`. +/// If the input is misaligned the bytes are copied per element as a fallback. +/// On big-endian targets the bytes are always byte-swapped per element. +/// In all cases, trailing `0x0000` code units are stripped before returning. +/// +/// In all cases an allocation is performed; the optimization on little-endian targets is +/// avoiding per-element byte-swapping rather than eliminating the allocation. +/// +/// # Panics +/// +/// Panics in debug builds if `bytes.len()` is odd. +pub(crate) fn le_bytes_to_units_strip_nulls(bytes: &[u8]) -> Vec { + debug_assert!( + bytes.len().is_multiple_of(2), + "le_bytes_to_units_strip_nulls: odd byte count" + ); + + #[cfg(target_endian = "little")] + if let Ok(units) = bytemuck::try_cast_slice::(bytes) { + let end = units.iter().rposition(|&u| u != 0).map_or(0, |i| i + 1); + return units[..end].to_vec(); + } + + let mut units: Vec = bytes + .chunks_exact(2) + .map(|b| u16::from_le_bytes([b[0], b[1]])) + .collect(); + + let end = units.iter().rposition(|&u| u != 0).map_or(0, |i| i + 1); + units.truncate(end); + units +} diff --git a/crates/ironrdp-str/src/unframed.rs b/crates/ironrdp-str/src/unframed.rs new file mode 100644 index 0000000000..8e94f7a1ae --- /dev/null +++ b/crates/ironrdp-str/src/unframed.rs @@ -0,0 +1,221 @@ +//! Externally-lengthed Unicode string fields. +//! +//! Used for strings whose wire length is given by a sibling field, not adjacent to +//! the string itself. The length is provided externally at decode time, either as a +//! WCHAR count (via [`UnframedString::decode`]) or as a byte length +//! (via [`UnframedString::decode_from_byte_len`]). + +use alloc::borrow::Cow; +#[cfg(not(feature = "std"))] +use alloc::borrow::ToOwned; +#[cfg(not(feature = "std"))] +use alloc::string::String; +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; +use core::fmt; + +use ironrdp_core::{DecodeResult, EncodeResult, ReadCursor, WriteCursor, ensure_size, invalid_field_err}; + +use crate::InvalidUtf16; +use crate::repr::StringRepr; + +/// A UTF-16LE string with no self-describing length prefix on the wire. +/// +/// The length must be provided externally (typically from a sibling field in the same PDU). +/// Trailing null code units are stripped on decode. +/// +/// Wire data is accepted as-is with no UTF-16 validation at decode time. Call [`to_native`] +/// to validate and convert to a Rust `str`, or [`to_native_lossy`] to accept any byte +/// sequence with lone-surrogate replacement. +/// +/// Use [`UnframedString::decode`] or [`UnframedString::decode_from_byte_len`] +/// to decode, and [`UnframedString::encode_into`] / [`UnframedString::wire_size`] +/// to encode. +/// +/// This type intentionally does not implement [`ironrdp_core::Encode`] or +/// [`ironrdp_core::DecodeOwned`]: there is no self-describing length, so the standard +/// encode/decode interface does not apply. +/// +/// [`to_native`]: UnframedString::to_native +/// [`to_native_lossy`]: UnframedString::to_native_lossy +pub struct UnframedString(StringRepr); + +impl UnframedString { + /// Creates an `UnframedString` from a native Rust string. + pub fn new(s: impl Into) -> Self { + Self(StringRepr::from_native(s.into())) + } + + /// Creates an `UnframedString` from raw UTF-16LE wire bytes. + /// + /// Returns `None` if `bytes` has odd length. Trailing null code units are stripped. + /// This is a convenience wrapper around [`utf16le_bytes_to_units`] + [`from_wire_units`]. + /// + /// [`utf16le_bytes_to_units`]: crate::utf16le_bytes_to_units + /// [`from_wire_units`]: UnframedString::from_wire_units + pub fn from_utf16le_bytes(bytes: &[u8]) -> Option { + crate::utf16le_bytes_to_units(bytes).map(Self::from_wire_units) + } + + /// Creates an `UnframedString` from pre-parsed UTF-16 code units. + /// + /// Trailing null code units are stripped. This is the low-level counterpart to + /// [`decode`] for callers that already have units from [`utf16le_bytes_to_units`]. + /// + /// [`decode`]: UnframedString::decode + /// [`utf16le_bytes_to_units`]: crate::utf16le_bytes_to_units + pub fn from_wire_units(units: Vec) -> Self { + let mut units = units; + let end = units.iter().rposition(|&u| u != 0).map_or(0, |i| i + 1); + units.truncate(end); + Self(StringRepr::from_wire_units(units)) + } + + /// Tries to return the string content as a Rust `str`. + /// + /// Returns [`InvalidUtf16`] if the wire data contains a lone surrogate. + /// For strings decoded from the wire, this allocates a new `String`. + /// For strings constructed from native Rust code, this is a zero-cost borrow. + pub fn to_native(&self) -> Result, InvalidUtf16> { + self.0.to_native() + } + + /// Returns the string content, replacing any lone surrogates with U+FFFD. + /// + /// For strings decoded from the wire, this allocates a new `String`. + /// For strings constructed from native Rust code, this is a zero-cost borrow. + pub fn to_native_lossy(&self) -> Cow<'_, str> { + self.0.to_native_lossy() + } + + /// Returns the number of UTF-16 code units (WCHARs) in this string. + /// + /// O(1) for wire-decoded strings, O(n) for natively-constructed strings. + pub fn utf16_len(&self) -> usize { + self.0.utf16_len() + } + + /// Returns the wire byte length of this string (`utf16_len() * 2`). + /// + /// Does **not** include a null terminator or any length prefix. + /// The caller is responsible for tracking this value alongside the string. + pub fn wire_size(&self) -> usize { + self.0.utf16_byte_len() + } + + /// Consumes `self` and returns a validated native `String`. + /// + /// Zero-cost when the value was constructed from a native Rust string. + /// Validates and allocates when the value was decoded from the wire. + /// Returns [`InvalidUtf16`] if the wire data contains a lone surrogate. + pub fn into_native(self) -> Result { + self.0.into_native() + } + + /// Returns the UTF-16 code units of this string. + /// + /// For wire-decoded strings, this is a zero-cost borrow of the stored units. + /// For strings constructed from native Rust code, this encodes and allocates. + pub fn to_wire_units(&self) -> Cow<'_, [u16]> { + self.0.to_wire_units() + } + + /// Consumes `self` and returns the UTF-16 code units of this string. + /// + /// Zero-cost when the value was decoded from the wire (moves the internal buffer). + /// Encodes and allocates when the value was constructed from a native string. + pub fn into_wire_units(self) -> Vec { + self.0.into_wire_units() + } + + /// Consumes `self` and returns the raw UTF-16LE bytes of the string content. + /// + /// Zero-cost when the value was decoded from the wire (moves the internal buffer). + /// Encodes to UTF-16LE and allocates when the value was constructed from a native string. + pub fn into_wire(self) -> Vec { + self.0.into_wire() + } + + /// Decodes a UTF-16LE string from the next `wchar_count` code units in `src`. + /// + /// Trailing null code units are stripped. Returns a `DecodeError` if the source + /// contains fewer than `wchar_count * 2` bytes. + pub fn decode(src: &mut ReadCursor<'_>, wchar_count: usize) -> DecodeResult { + let byte_count = wchar_count + .checked_mul(2) + .ok_or_else(|| invalid_field_err!("wchar_count", "character count overflow"))?; + ensure_size!(in: src, size: byte_count); + + let slice = src.read_slice(byte_count); + let units = crate::repr::le_bytes_to_units_strip_nulls(slice); + Ok(Self(StringRepr::from_wire_units(units))) + } + + /// Decodes a UTF-16LE string from the next `byte_len` bytes in `src`. + /// + /// Returns a `DecodeError` if `byte_len` is odd (UTF-16LE is always 2 bytes per code unit). + /// Otherwise equivalent to `decode(src, byte_len / 2)`. + pub fn decode_from_byte_len(src: &mut ReadCursor<'_>, byte_len: usize) -> DecodeResult { + if byte_len % 2 != 0 { + return Err(invalid_field_err!("byte_len", "odd byte count for utf-16 string field")); + } + Self::decode(src, byte_len / 2) + } + + /// Encodes the string content into `dst` as UTF-16LE code units. + /// + /// Does **not** write a null terminator or any length prefix. + /// Returns `EncodeResult` for consistency with the rest of the crate. + pub fn encode_into(&self, dst: &mut WriteCursor<'_>) -> EncodeResult<()> { + ensure_size!(in: dst, size: self.wire_size()); + let wire_bytes = self.0.as_wire_bytes(); + dst.write_slice(&wire_bytes); + Ok(()) + } +} + +impl From for UnframedString { + fn from(s: String) -> Self { + Self::new(s) + } +} + +impl From<&str> for UnframedString { + fn from(s: &str) -> Self { + Self::new(s.to_owned()) + } +} + +impl TryFrom for String { + type Error = InvalidUtf16; + + fn try_from(s: UnframedString) -> Result { + s.0.into_native() + } +} + +impl fmt::Display for UnframedString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.to_native_lossy(), f) + } +} + +impl fmt::Debug for UnframedString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "UnframedString({:?})", self.0) + } +} + +impl Clone for UnframedString { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl PartialEq for UnframedString { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for UnframedString {} diff --git a/crates/ironrdp-svc/CHANGELOG.md b/crates/ironrdp-svc/CHANGELOG.md index 7e650f78ca..4aa33691e9 100644 --- a/crates/ironrdp-svc/CHANGELOG.md +++ b/crates/ironrdp-svc/CHANGELOG.md @@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-svc-v0.7.0...ironrdp-svc-v0.8.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + +## [[0.7.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-svc-v0.6.0...ironrdp-svc-v0.7.0)] - 2026-05-27 + +### Features + +- Add SvcMessage::encode_unframed_pdu for headerless encoding ([#1093](https://github.com/Devolutions/IronRDP/issues/1093)) ([a21378e16a](https://github.com/Devolutions/IronRDP/commit/a21378e16a3a5af36428ba9a226b08acc5113eb6)) + +### Build + +- Bump the patch group across 1 directory with 2 updates ([#1222](https://github.com/Devolutions/IronRDP/issues/1222)) ([3fe6d157e0](https://github.com/Devolutions/IronRDP/commit/3fe6d157e0b55bddfdac20af290a6cfa6e550576)) + + ## [[0.4.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-svc-v0.4.0...ironrdp-svc-v0.4.1)] - 2025-06-27 ### Features @@ -19,7 +38,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump bitflags from 2.9.0 to 2.9.1 in the patch group across 1 directory (#792) ([87ed315bc2](https://github.com/Devolutions/IronRDP/commit/87ed315bc28fdd2dcfea89b052fa620a7e346e5a)) - ## [[0.3.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-svc-v0.2.0...ironrdp-svc-v0.3.0)] - 2025-03-12 ### Build @@ -27,7 +45,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump ironrdp-pdu - ## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-svc-v0.1.3...ironrdp-svc-v0.2.0)] - 2025-03-12 ### Build @@ -41,7 +58,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-svc-v0.1.1...ironrdp-svc-v0.1.2)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-svc/Cargo.toml b/crates/ironrdp-svc/Cargo.toml index f78b38cf08..88ba8e9546 100644 --- a/crates/ironrdp-svc/Cargo.toml +++ b/crates/ironrdp-svc/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-svc" -version = "0.4.1" +version = "0.8.0" readme = "README.md" description = "IronRDP traits to implement RDP static virtual channels" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -20,9 +21,9 @@ default = [] std = [] [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1" } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5", features = ["alloc", "std"] } # public -bitflags = "2.9" +ironrdp-core = { path = "../ironrdp-core", version = "0.2" } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", features = ["alloc", "std"] } # public +bitflags = "2.11" [lints] workspace = true diff --git a/crates/ironrdp-svc/src/lib.rs b/crates/ironrdp-svc/src/lib.rs index e1ef1b5a67..ced08b06ee 100644 --- a/crates/ironrdp-svc/src/lib.rs +++ b/crates/ironrdp-svc/src/lib.rs @@ -13,13 +13,13 @@ use std::borrow::Cow; use bitflags::bitflags; use ironrdp_core::{ - assert_obj_safe, decode_cursor, encode_buf, AsAny, DecodeResult, Encode, EncodeResult, ReadCursor, WriteBuf, - WriteCursor, + AsAny, DecodeResult, Encode, EncodeResult, ReadCursor, WriteBuf, WriteCursor, assert_obj_safe, decode_cursor, + encode_buf, }; use ironrdp_pdu::gcc::{ChannelDef, ChannelName, ChannelOptions}; use ironrdp_pdu::rdp::vc::ChannelControlFlags; use ironrdp_pdu::x224::X224; -use ironrdp_pdu::{decode_err, mcs, PduResult}; +use ironrdp_pdu::{PduResult, decode_err, mcs}; // Re-export ironrdp_pdu crate for convenience #[rustfmt::skip] // Do not re-order this pub use. @@ -98,6 +98,14 @@ impl SvcMessage { self.flags |= flags; self } + + /// Encodes the inner PDU without SVC channel framing headers. + /// + /// Returns the raw PDU bytes for transports that handle their own framing + /// (e.g. RDPEMT tunnel data for UDP transport). + pub fn encode_unframed_pdu(&self) -> EncodeResult> { + ironrdp_core::encode_vec(self.pdu.as_ref()) + } } impl From for SvcMessage diff --git a/crates/ironrdp-testsuite-core/Cargo.toml b/crates/ironrdp-testsuite-core/Cargo.toml index 96b0339340..689da1ef34 100644 --- a/crates/ironrdp-testsuite-core/Cargo.toml +++ b/crates/ironrdp-testsuite-core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ironrdp-testsuite-core" version = "0.0.0" -edition = "2021" +edition = "2024" description = "IronRDP test suite" publish = false autotests = false @@ -15,6 +15,7 @@ test = false # Don't rely on these whatsoever. They may disappear at any time. # Added here because it includes/link to some files from other crates __bench = ["dep:visibility"] +openh264-bundled = ["ironrdp-egfx/openh264-bundled", "dep:openh264"] [[test]] name = "integration_tests_core" @@ -25,32 +26,43 @@ harness = true array-concat = "0.5" expect-test = "1" ironrdp-core.path = "../ironrdp-core" +ironrdp-egfx.path = "../ironrdp-egfx" ironrdp-pdu.path = "../ironrdp-pdu" -lazy_static.workspace = true # TODO: remove in favor of https://doc.rust-lang.org/std/sync/struct.OnceLock.html paste = "1" +openh264 = { version = "0.9", optional = true, default-features = false, features = ["source"] } visibility = { version = "0.1", optional = true } [dev-dependencies] anyhow = "1" +async-trait = "0.1" expect-test.workspace = true hex = "0.4" ironrdp-cliprdr-format.path = "../ironrdp-cliprdr-format" -ironrdp-cliprdr.path = "../ironrdp-cliprdr" +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", features = ["__test"] } +ironrdp-acceptor.path = "../ironrdp-acceptor" ironrdp-connector.path = "../ironrdp-connector" ironrdp-displaycontrol.path = "../ironrdp-displaycontrol" ironrdp-dvc.path = "../ironrdp-dvc" +ironrdp-echo.path = "../ironrdp-echo" ironrdp-fuzzing.path = "../ironrdp-fuzzing" ironrdp-graphics.path = "../ironrdp-graphics" +ironrdp-str.path = "../ironrdp-str" +ironrdp-svc.path = "../ironrdp-svc" ironrdp-input.path = "../ironrdp-input" ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath" -ironrdp-rdpsnd.path = "../ironrdp-rdpsnd" +ironrdp-rdpdr.path = "../ironrdp-rdpdr" +ironrdp-rdpeusb.path = "../ironrdp-rdpeusb" +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", features = ["__test"] } +ironrdp-server.path = "../ironrdp-server" ironrdp-session = { path = "../ironrdp-session", features = ["qoi"] } +ironrdp-cfg.path = "../ironrdp-cfg" ironrdp-propertyset.path = "../ironrdp-propertyset" ironrdp-rdpfile.path = "../ironrdp-rdpfile" -png = "0.17" +png = "0.18" pretty_assertions = "1.4" proptest.workspace = true rstest.workspace = true +tokio = { version = "1", features = ["macros", "rt"] } [lints] workspace = true diff --git a/crates/ironrdp-testsuite-core/src/capsets.rs b/crates/ironrdp-testsuite-core/src/capsets.rs index be4c8b5605..77619844c8 100644 --- a/crates/ironrdp-testsuite-core/src/capsets.rs +++ b/crates/ironrdp-testsuite-core/src/capsets.rs @@ -1,8 +1,9 @@ +use std::sync::LazyLock; + use ironrdp_core::decode; use ironrdp_pdu::rdp::capability_sets::{ - CapabilitySet, ClientConfirmActive, DemandActive, ServerDemandActive, SERVER_CHANNEL_ID, + CapabilitySet, ClientConfirmActive, DemandActive, SERVER_CHANNEL_ID, ServerDemandActive, }; -use lazy_static::lazy_static; pub const SERVER_DEMAND_ACTIVE_BUFFER: [u8; 357] = [ 0x04, 0x00, // source descriptor length @@ -264,28 +265,29 @@ pub const CLIENT_MULTI_FRAGMENT_UPDATE_CAPABILITY_SET: [u8; 4] = [0x0, 0x0, 0x0, pub const CLIENT_WINDOW_LIST_CAPABILITY_SET: [u8; 7] = [0x1, 0x0, 0x0, 0x0, 0x3, 0xc, 0x0]; -lazy_static! { - pub static ref SERVER_DEMAND_ACTIVE: ServerDemandActive = ServerDemandActive { - pdu: DemandActive { - source_descriptor: String::from("RDP"), - capability_sets: vec![ - CapabilitySet::Share(SERVER_SHARE_CAPABILITY_SET.to_vec()), - CapabilitySet::General(decode(SERVER_GENERAL_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::VirtualChannel(decode(SERVER_VIRTUAL_CHANNEL_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::DrawGdiPlus(SERVER_DRAW_GDI_PLUS_CAPABILITY_SET.to_vec()), - CapabilitySet::Font(SERVER_FONT_CAPABILITY_SET.to_vec()), - CapabilitySet::Bitmap(decode(SERVER_BITMAP_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::Order(decode(SERVER_ORDER_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::ColorCache(SERVER_COLOR_CACHE_CAPABILITY_SET.to_vec()), - CapabilitySet::BitmapCacheHostSupport(SERVER_BITMAP_CACHE_HOST_SUPPORT_CAPABILITY_SET.to_vec()), - CapabilitySet::Pointer(decode(SERVER_POINTER_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::Input(decode(SERVER_INPUT_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::Rail(SERVER_RAIL_CAPABILITY_SET.to_vec()), - CapabilitySet::WindowList(SERVER_WINDOW_LIST_CAPABILITY_SET.to_vec()), - ], - } - }; - pub static ref CLIENT_DEMAND_ACTIVE_WITH_INCOMPLETE_CAPABILITY_SET: ClientConfirmActive = ClientConfirmActive { +pub static SERVER_DEMAND_ACTIVE: LazyLock = LazyLock::new(|| ServerDemandActive { + pdu: DemandActive { + source_descriptor: String::from("RDP"), + capability_sets: vec![ + CapabilitySet::Share(SERVER_SHARE_CAPABILITY_SET.to_vec()), + CapabilitySet::General(decode(SERVER_GENERAL_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::VirtualChannel(decode(SERVER_VIRTUAL_CHANNEL_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::DrawGdiPlus(SERVER_DRAW_GDI_PLUS_CAPABILITY_SET.to_vec()), + CapabilitySet::Font(SERVER_FONT_CAPABILITY_SET.to_vec()), + CapabilitySet::Bitmap(decode(SERVER_BITMAP_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::Order(decode(SERVER_ORDER_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::ColorCache(SERVER_COLOR_CACHE_CAPABILITY_SET.to_vec()), + CapabilitySet::BitmapCacheHostSupport(SERVER_BITMAP_CACHE_HOST_SUPPORT_CAPABILITY_SET.to_vec()), + CapabilitySet::Pointer(decode(SERVER_POINTER_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::Input(decode(SERVER_INPUT_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::Rail(SERVER_RAIL_CAPABILITY_SET.to_vec()), + CapabilitySet::WindowList(SERVER_WINDOW_LIST_CAPABILITY_SET.to_vec()), + ], + }, +}); + +pub static CLIENT_DEMAND_ACTIVE_WITH_INCOMPLETE_CAPABILITY_SET: LazyLock = + LazyLock::new(|| ClientConfirmActive { originator_id: SERVER_CHANNEL_ID, pdu: DemandActive { source_descriptor: String::from("MSTSC"), @@ -306,41 +308,41 @@ lazy_static! { CapabilitySet::Brush(decode(CLIENT_BRUSH_CAPABILITY_SET.as_ref()).unwrap()), CapabilitySet::OffscreenBitmapCache(decode(CLIENT_OFFSCREEN_BITMAP_CAPABILITY_SET.as_ref()).unwrap()), CapabilitySet::VirtualChannel( - decode(CLIENT_VIRTUAL_CHANNEL_CAPABILITY_SET_INCOMPLETE.as_ref()).unwrap() + decode(CLIENT_VIRTUAL_CHANNEL_CAPABILITY_SET_INCOMPLETE.as_ref()).unwrap(), ), CapabilitySet::DrawNineGridCache(CLIENT_DRAW_NINE_GRID_CACHE_CAPABILITY_SET.to_vec()), CapabilitySet::DrawGdiPlus(CLIENT_DRAW_GDI_PLUS_CAPABILITY_SET.to_vec()), CapabilitySet::MultiFragmentUpdate( - decode(CLIENT_MULTI_FRAGMENT_UPDATE_CAPABILITY_SET.as_ref()).unwrap() + decode(CLIENT_MULTI_FRAGMENT_UPDATE_CAPABILITY_SET.as_ref()).unwrap(), ), CapabilitySet::WindowList(CLIENT_WINDOW_LIST_CAPABILITY_SET.to_vec()), ], - } - }; - pub static ref CLIENT_DEMAND_ACTIVE: ClientConfirmActive = ClientConfirmActive { - originator_id: SERVER_CHANNEL_ID, - pdu: DemandActive { - source_descriptor: String::from("MSTSC"), - capability_sets: vec![ - CapabilitySet::General(decode(CLIENT_GENERAL_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::Bitmap(decode(CLIENT_BITMAP_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::Order(decode(CLIENT_ORDER_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::BitmapCacheRev2(decode(CLIENT_BITMAP_CACHE_REV_2_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::ColorCache(CLIENT_COLOR_CACHE_CAPABILITY_SET.to_vec()), - CapabilitySet::WindowActivation(CLIENT_WINDOW_ACTIVATION_CAPABILITY_SET.to_vec()), - CapabilitySet::Control(CLIENT_CONTROL_CAPABILITY_SET.to_vec()), - CapabilitySet::Pointer(decode(CLIENT_POINTER_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::Share(CLIENT_SHARE_CAPABILITY_SET.to_vec()), - CapabilitySet::Input(decode(CLIENT_INPUT_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::Sound(decode(CLIENT_SOUND_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::Font(CLIENT_FONT_CAPABILITY_SET.to_vec()), - CapabilitySet::GlyphCache(decode(CLIENT_GLYPH_CACHE_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::Brush(decode(CLIENT_BRUSH_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::OffscreenBitmapCache(decode(CLIENT_OFFSCREEN_BITMAP_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::VirtualChannel(decode(CLIENT_VIRTUAL_CHANNEL_CAPABILITY_SET.as_ref()).unwrap()), - CapabilitySet::DrawNineGridCache(CLIENT_DRAW_NINE_GRID_CACHE_CAPABILITY_SET.to_vec()), - CapabilitySet::DrawGdiPlus(CLIENT_DRAW_GDI_PLUS_CAPABILITY_SET.to_vec()), - ], - } - }; -} + }, + }); + +pub static CLIENT_DEMAND_ACTIVE: LazyLock = LazyLock::new(|| ClientConfirmActive { + originator_id: SERVER_CHANNEL_ID, + pdu: DemandActive { + source_descriptor: String::from("MSTSC"), + capability_sets: vec![ + CapabilitySet::General(decode(CLIENT_GENERAL_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::Bitmap(decode(CLIENT_BITMAP_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::Order(decode(CLIENT_ORDER_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::BitmapCacheRev2(decode(CLIENT_BITMAP_CACHE_REV_2_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::ColorCache(CLIENT_COLOR_CACHE_CAPABILITY_SET.to_vec()), + CapabilitySet::WindowActivation(CLIENT_WINDOW_ACTIVATION_CAPABILITY_SET.to_vec()), + CapabilitySet::Control(CLIENT_CONTROL_CAPABILITY_SET.to_vec()), + CapabilitySet::Pointer(decode(CLIENT_POINTER_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::Share(CLIENT_SHARE_CAPABILITY_SET.to_vec()), + CapabilitySet::Input(decode(CLIENT_INPUT_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::Sound(decode(CLIENT_SOUND_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::Font(CLIENT_FONT_CAPABILITY_SET.to_vec()), + CapabilitySet::GlyphCache(decode(CLIENT_GLYPH_CACHE_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::Brush(decode(CLIENT_BRUSH_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::OffscreenBitmapCache(decode(CLIENT_OFFSCREEN_BITMAP_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::VirtualChannel(decode(CLIENT_VIRTUAL_CHANNEL_CAPABILITY_SET.as_ref()).unwrap()), + CapabilitySet::DrawNineGridCache(CLIENT_DRAW_NINE_GRID_CACHE_CAPABILITY_SET.to_vec()), + CapabilitySet::DrawGdiPlus(CLIENT_DRAW_GDI_PLUS_CAPABILITY_SET.to_vec()), + ], + }, +}); diff --git a/crates/ironrdp-testsuite-core/src/client_info.rs b/crates/ironrdp-testsuite-core/src/client_info.rs index 2db443c66f..12c21b1127 100644 --- a/crates/ironrdp-testsuite-core/src/client_info.rs +++ b/crates/ironrdp-testsuite-core/src/client_info.rs @@ -1,3 +1,5 @@ +use std::sync::LazyLock; + use ironrdp_pdu::rdp::client_info::{ AddressFamily, ClientInfo, ClientInfoFlags, CompressionType, Credentials, DayOfWeek, DayOfWeekOccurrence, ExtendedClientInfo, ExtendedClientOptionalInfo, Month, OptionalSystemTime, PerformanceFlags, SystemTime, @@ -81,75 +83,73 @@ pub const CLIENT_INFO_BUFFER_ANSI: [u8; 301] = [ 0x01, 0x00, 0x00, 0x00, // performance flags ]; -lazy_static::lazy_static! { - pub static ref CLIENT_INFO_UNICODE: ClientInfo = ClientInfo { - code_page: 0x0409_0409, - flags: ClientInfoFlags::MOUSE - | ClientInfoFlags::DISABLE_CTRL_ALT_DEL - | ClientInfoFlags::UNICODE - | ClientInfoFlags::MAXIMIZE_SHELL - | ClientInfoFlags::COMPRESSION - | ClientInfoFlags::ENABLE_WINDOWS_KEY - | ClientInfoFlags::FORCE_ENCRYPTED_CS_PDU, - compression_type: CompressionType::K64, - credentials: Credentials { - username: String::from("eltons"), - password: String::from(""), - domain: Some(String::from("NTDEV")) - }, - alternate_shell: String::from(""), - work_dir: String::from(""), - extra_info: ExtendedClientInfo { - address_family: AddressFamily::INET, - address: String::from("157.59.242.156"), - dir: String::from("C:\\depots\\w2k3_1\\termsrv\\newclient\\lib\\win32\\obj\\i386\\mstscax.dll"), - optional_data: ExtendedClientOptionalInfo::builder() - .timezone(TimezoneInfo { - bias: 480, - standard_name: String::from("Pacific Standard Time"), - standard_date: OptionalSystemTime(Some(SystemTime { - month: Month::October, - day_of_week: DayOfWeek::Sunday, - day: DayOfWeekOccurrence::Last, - hour: 2, - minute: 0, - second: 0, - milliseconds: 0, - })), - standard_bias: 0, - daylight_name: String::from("Pacific Daylight Time"), - daylight_date: OptionalSystemTime(Some(SystemTime { - month: Month::April, - day_of_week: DayOfWeek::Sunday, - day: DayOfWeekOccurrence::First, - hour: 2, - minute: 0, - second: 0, - milliseconds: 0, - })), - daylight_bias: -60, - }) - .session_id(0) - .performance_flags(PerformanceFlags::DISABLE_WALLPAPER) - .build(), - }, - }; +pub static CLIENT_INFO_UNICODE: LazyLock = LazyLock::new(|| ClientInfo { + code_page: 0x0409_0409, + flags: ClientInfoFlags::MOUSE + | ClientInfoFlags::DISABLE_CTRL_ALT_DEL + | ClientInfoFlags::UNICODE + | ClientInfoFlags::MAXIMIZE_SHELL + | ClientInfoFlags::COMPRESSION + | ClientInfoFlags::ENABLE_WINDOWS_KEY + | ClientInfoFlags::FORCE_ENCRYPTED_CS_PDU, + compression_type: CompressionType::K64, + credentials: Credentials { + username: String::from("eltons"), + password: String::from(""), + domain: Some(String::from("NTDEV")), + }, + alternate_shell: String::from(""), + work_dir: String::from(""), + extra_info: ExtendedClientInfo { + address_family: AddressFamily::INET, + address: String::from("157.59.242.156"), + dir: String::from("C:\\depots\\w2k3_1\\termsrv\\newclient\\lib\\win32\\obj\\i386\\mstscax.dll"), + optional_data: ExtendedClientOptionalInfo::builder() + .timezone(TimezoneInfo { + bias: 480, + standard_name: String::from("Pacific Standard Time"), + standard_date: OptionalSystemTime(Some(SystemTime { + month: Month::October, + day_of_week: DayOfWeek::Sunday, + day: DayOfWeekOccurrence::Last, + hour: 2, + minute: 0, + second: 0, + milliseconds: 0, + })), + standard_bias: 0, + daylight_name: String::from("Pacific Daylight Time"), + daylight_date: OptionalSystemTime(Some(SystemTime { + month: Month::April, + day_of_week: DayOfWeek::Sunday, + day: DayOfWeekOccurrence::First, + hour: 2, + minute: 0, + second: 0, + milliseconds: 0, + })), + daylight_bias: -60, + }) + .session_id(0) + .performance_flags(PerformanceFlags::DISABLE_WALLPAPER) + .build(), + }, +}); - pub static ref CLIENT_INFO_ANSI: ClientInfo = { - let mut client_info = CLIENT_INFO_UNICODE.clone(); - client_info.flags -= ClientInfoFlags::UNICODE; - client_info - }; +pub static CLIENT_INFO_ANSI: LazyLock = LazyLock::new(|| { + let mut client_info = CLIENT_INFO_UNICODE.clone(); + client_info.flags -= ClientInfoFlags::UNICODE; + client_info +}); - pub static ref CLIENT_INFO_UNICODE_WITHOUT_OPTIONAL_FIELDS: ClientInfo = { - let mut client_info = CLIENT_INFO_UNICODE.clone(); - client_info.extra_info.optional_data = ExtendedClientOptionalInfo::default(); - client_info - }; +pub static CLIENT_INFO_UNICODE_WITHOUT_OPTIONAL_FIELDS: LazyLock = LazyLock::new(|| { + let mut client_info = CLIENT_INFO_UNICODE.clone(); + client_info.extra_info.optional_data = ExtendedClientOptionalInfo::default(); + client_info +}); - pub static ref CLIENT_INFO_BUFFER_UNICODE_WITHOUT_OPTIONAL_FIELDS: Vec = { - let mut buffer = CLIENT_INFO_BUFFER_UNICODE.to_vec(); - buffer.truncate(CLIENT_INFO_BUFFER_UNICODE_WITHOUT_OPTIONAL_FIELDS_LEN); - buffer - }; -} +pub static CLIENT_INFO_BUFFER_UNICODE_WITHOUT_OPTIONAL_FIELDS: LazyLock> = LazyLock::new(|| { + let mut buffer = CLIENT_INFO_BUFFER_UNICODE.to_vec(); + buffer.truncate(CLIENT_INFO_BUFFER_UNICODE_WITHOUT_OPTIONAL_FIELDS_LEN); + buffer +}); diff --git a/crates/ironrdp-testsuite-core/src/cluster_data.rs b/crates/ironrdp-testsuite-core/src/cluster_data.rs index 7729c00fdb..165430a999 100644 --- a/crates/ironrdp-testsuite-core/src/cluster_data.rs +++ b/crates/ironrdp-testsuite-core/src/cluster_data.rs @@ -1,12 +1,11 @@ +use std::sync::LazyLock; + use ironrdp_pdu::gcc::{ClientClusterData, RedirectionFlags, RedirectionVersion}; -use lazy_static::lazy_static; pub const CLUSTER_DATA_BUFFER: [u8; 8] = [0x0d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; -lazy_static! { - pub static ref CLUSTER_DATA: ClientClusterData = ClientClusterData { - flags: RedirectionFlags::REDIRECTION_SUPPORTED, - redirection_version: RedirectionVersion::V4, - redirected_session_id: 0, - }; -} +pub static CLUSTER_DATA: LazyLock = LazyLock::new(|| ClientClusterData { + flags: RedirectionFlags::REDIRECTION_SUPPORTED, + redirection_version: RedirectionVersion::V4, + redirected_session_id: 0, +}); diff --git a/crates/ironrdp-testsuite-core/src/conference_create.rs b/crates/ironrdp-testsuite-core/src/conference_create.rs index 8b3763ec81..92ab3344ab 100644 --- a/crates/ironrdp-testsuite-core/src/conference_create.rs +++ b/crates/ironrdp-testsuite-core/src/conference_create.rs @@ -1,6 +1,7 @@ +use std::sync::LazyLock; + use array_concat::{concat_arrays, concat_arrays_size}; use ironrdp_pdu::gcc::{ConferenceCreateRequest, ConferenceCreateResponse}; -use lazy_static::lazy_static; use crate::gcc; @@ -14,15 +15,12 @@ pub const CONFERENCE_CREATE_RESPONSE_PREFIX_BUFFER: [u8; 24] = [ 0x63, 0x44, 0x6e, 0x81, 0x08, ]; -lazy_static! { - pub static ref CONFERENCE_CREATE_REQUEST: ConferenceCreateRequest = ConferenceCreateRequest { - gcc_blocks: gcc::CLIENT_GCC_WITH_CLUSTER_OPTIONAL_FIELD.clone(), - }; - pub static ref CONFERENCE_CREATE_RESPONSE: ConferenceCreateResponse = ConferenceCreateResponse { - user_id: 0x79f3, - gcc_blocks: gcc::SERVER_GCC_WITHOUT_OPTIONAL_FIELDS.clone(), - }; -} +pub static CONFERENCE_CREATE_REQUEST: LazyLock = LazyLock::new(|| { + ConferenceCreateRequest::new(gcc::CLIENT_GCC_WITH_CLUSTER_OPTIONAL_FIELD.clone()).expect("should not fail") +}); +pub static CONFERENCE_CREATE_RESPONSE: LazyLock = LazyLock::new(|| { + ConferenceCreateResponse::new(0x79f3, gcc::SERVER_GCC_WITHOUT_OPTIONAL_FIELDS.clone()).expect("should not fail") +}); pub const CONFERENCE_CREATE_REQUEST_BUFFER: [u8; concat_arrays_size!( CONFERENCE_CREATE_REQUEST_PREFIX_BUFFER, diff --git a/crates/ironrdp-testsuite-core/src/core_data.rs b/crates/ironrdp-testsuite-core/src/core_data.rs index e552727b13..78a08d8fb4 100644 --- a/crates/ironrdp-testsuite-core/src/core_data.rs +++ b/crates/ironrdp-testsuite-core/src/core_data.rs @@ -1,3 +1,5 @@ +use std::sync::LazyLock; + use array_concat::{concat_arrays, concat_arrays_size}; use ironrdp_pdu::gcc::{ ClientCoreData, ClientCoreOptionalData, ClientEarlyCapabilityFlags, ColorDepth, ConnectionType, HighColorDepth, @@ -5,7 +7,6 @@ use ironrdp_pdu::gcc::{ SupportedColorDepths, }; use ironrdp_pdu::nego::SecurityProtocol; -use lazy_static::lazy_static; pub const CLIENT_CORE_DATA_BUFFER: [u8; 128] = [ 0x04, 0x00, 0x08, 0x00, // version @@ -57,55 +58,59 @@ pub const CLIENT_OPTIONAL_CORE_DATA_FROM_DESKTOP_PHYSICAL_WIDTH_TO_DEVICE_SCALE_ 0x8c, 0x00, 0x00, 0x00, // device scale factor ]; -lazy_static! { - pub static ref CLIENT_CORE_DATA_WITHOUT_OPTIONAL_FIELDS: ClientCoreData = ClientCoreData { - version: RdpVersion::V5_PLUS, - desktop_width: 1280, - desktop_height: 1024, - color_depth: ColorDepth::Bpp4, - sec_access_sequence: SecureAccessSequence::Del, - keyboard_layout: 1033, - client_build: 3790, - client_name: String::from("ELTONS-DEV2"), - keyboard_type: KeyboardType::IbmEnhanced, - keyboard_subtype: 0, - keyboard_functional_keys_count: 12, - ime_file_name: String::new(), - optional_data: ClientCoreOptionalData::default(), - }; - pub static ref CLIENT_OPTIONAL_CORE_DATA_TO_HIGH_COLOR_DEPTH: ClientCoreData = { - let mut data = CLIENT_CORE_DATA_WITHOUT_OPTIONAL_FIELDS.clone(); - data.optional_data.post_beta2_color_depth = Some(ColorDepth::Bpp8); - data.optional_data.client_product_id = Some(1); - data.optional_data.serial_number = Some(0); - data - }; - pub static ref CLIENT_OPTIONAL_CORE_DATA_TO_SERVER_SELECTED_PROTOCOL: ClientCoreData = { - let mut data = CLIENT_OPTIONAL_CORE_DATA_TO_HIGH_COLOR_DEPTH.clone(); - data.optional_data.high_color_depth = Some(HighColorDepth::Bpp24); - data.optional_data.supported_color_depths = - Some(SupportedColorDepths::BPP24 | SupportedColorDepths::BPP16 | SupportedColorDepths::BPP15); - data.optional_data.early_capability_flags = Some(ClientEarlyCapabilityFlags::SUPPORT_ERR_INFO_PDU); - data.optional_data.dig_product_id = Some(String::from("69712-783-0357974-42714")); - data.optional_data.connection_type = Some(ConnectionType::NotUsed); - data.optional_data.server_selected_protocol = Some(SecurityProtocol::empty()); - data - }; - pub static ref CLIENT_CORE_DATA_WITH_ALL_OPTIONAL_FIELDS: ClientCoreData = { - let mut data = CLIENT_OPTIONAL_CORE_DATA_TO_SERVER_SELECTED_PROTOCOL.clone(); - data.optional_data.desktop_physical_width = Some(5000); - data.optional_data.desktop_physical_height = Some(3000); - data.optional_data.desktop_orientation = Some(90); - data.optional_data.desktop_scale_factor = Some(200); - data.optional_data.device_scale_factor = Some(140); - data - }; - pub static ref CLIENT_CORE_DATA_WITH_ALL_OPTIONAL_FIELDS_WITH_WANT_32_BPP_EARLY_FLAG: ClientCoreData = { +pub static CLIENT_CORE_DATA_WITHOUT_OPTIONAL_FIELDS: LazyLock = LazyLock::new(|| ClientCoreData { + version: RdpVersion::V5_PLUS, + desktop_width: 1280, + desktop_height: 1024, + color_depth: ColorDepth::Bpp4, + sec_access_sequence: SecureAccessSequence::Del, + keyboard_layout: 1033, + client_build: 3790, + client_name: String::from("ELTONS-DEV2"), + keyboard_type: KeyboardType::IbmEnhanced, + keyboard_subtype: 0, + keyboard_functional_keys_count: 12, + ime_file_name: String::new(), + optional_data: ClientCoreOptionalData::default(), +}); + +pub static CLIENT_OPTIONAL_CORE_DATA_TO_HIGH_COLOR_DEPTH: LazyLock = LazyLock::new(|| { + let mut data = CLIENT_CORE_DATA_WITHOUT_OPTIONAL_FIELDS.clone(); + data.optional_data.post_beta2_color_depth = Some(ColorDepth::Bpp8); + data.optional_data.client_product_id = Some(1); + data.optional_data.serial_number = Some(0); + data +}); + +pub static CLIENT_OPTIONAL_CORE_DATA_TO_SERVER_SELECTED_PROTOCOL: LazyLock = LazyLock::new(|| { + let mut data = CLIENT_OPTIONAL_CORE_DATA_TO_HIGH_COLOR_DEPTH.clone(); + data.optional_data.high_color_depth = Some(HighColorDepth::Bpp24); + data.optional_data.supported_color_depths = + Some(SupportedColorDepths::BPP24 | SupportedColorDepths::BPP16 | SupportedColorDepths::BPP15); + data.optional_data.early_capability_flags = Some(ClientEarlyCapabilityFlags::SUPPORT_ERR_INFO_PDU); + data.optional_data.dig_product_id = Some(String::from("69712-783-0357974-42714")); + data.optional_data.connection_type = Some(ConnectionType::NotUsed); + data.optional_data.server_selected_protocol = Some(SecurityProtocol::empty()); + data +}); + +pub static CLIENT_CORE_DATA_WITH_ALL_OPTIONAL_FIELDS: LazyLock = LazyLock::new(|| { + let mut data = CLIENT_OPTIONAL_CORE_DATA_TO_SERVER_SELECTED_PROTOCOL.clone(); + data.optional_data.desktop_physical_width = Some(5000); + data.optional_data.desktop_physical_height = Some(3000); + data.optional_data.desktop_orientation = Some(90); + data.optional_data.desktop_scale_factor = Some(200); + data.optional_data.device_scale_factor = Some(140); + data +}); +pub static CLIENT_CORE_DATA_WITH_ALL_OPTIONAL_FIELDS_WITH_WANT_32_BPP_EARLY_FLAG: LazyLock = + LazyLock::new(|| { let mut data = CLIENT_CORE_DATA_WITH_ALL_OPTIONAL_FIELDS.clone(); data.optional_data.early_capability_flags = Some(ClientEarlyCapabilityFlags::WANT_32_BPP_SESSION); data - }; - pub static ref CLIENT_CORE_DATA_WITH_ALL_OPTIONAL_FIELDS_WITH_WANT_32_BPP_EARLY_FLAG_BUFFER: Vec = { + }); +pub static CLIENT_CORE_DATA_WITH_ALL_OPTIONAL_FIELDS_WITH_WANT_32_BPP_EARLY_FLAG_BUFFER: LazyLock> = + LazyLock::new(|| { let early_capability_flags = ClientEarlyCapabilityFlags::WANT_32_BPP_SESSION.bits().to_le_bytes(); let mut from_high_color_to_server_protocol = @@ -119,8 +124,7 @@ lazy_static! { buffer.extend(CLIENT_OPTIONAL_CORE_DATA_FROM_DESKTOP_PHYSICAL_WIDTH_TO_DEVICE_SCALE_FACTOR_BUFFER.as_ref()); buffer - }; -} + }); pub const CLIENT_OPTIONAL_CORE_DATA_TO_HIGH_COLOR_DEPTH_BUFFER_BUFFER: [u8; concat_arrays_size!( CLIENT_CORE_DATA_BUFFER, @@ -158,29 +162,28 @@ pub const FLAGS_BUFFER: [u8; 4] = [ 0x01, 0x00, 0x00, 0x00, // early capability flags ]; -lazy_static! { - pub static ref SERVER_CORE_DATA: ServerCoreData = ServerCoreData { - version: RdpVersion::V5_PLUS, - optional_data: ServerCoreOptionalData { - client_requested_protocols: None, - early_capability_flags: None, - }, - }; - pub static ref SERVER_CORE_DATA_TO_FLAGS: ServerCoreData = ServerCoreData { - version: RdpVersion::V5_PLUS, - optional_data: ServerCoreOptionalData { - client_requested_protocols: Some(SecurityProtocol::empty()), - early_capability_flags: None, - }, - }; - pub static ref SERVER_CORE_DATA_WITH_ALL_OPTIONAL_FIELDS: ServerCoreData = ServerCoreData { - version: RdpVersion::V5_PLUS, - optional_data: ServerCoreOptionalData { - client_requested_protocols: Some(SecurityProtocol::empty()), - early_capability_flags: Some(ServerEarlyCapabilityFlags::EDGE_ACTIONS_SUPPORTED_V1), - }, - }; -} +pub static SERVER_CORE_DATA: LazyLock = LazyLock::new(|| ServerCoreData { + version: RdpVersion::V5_PLUS, + optional_data: ServerCoreOptionalData { + client_requested_protocols: None, + early_capability_flags: None, + }, +}); +pub static SERVER_CORE_DATA_TO_FLAGS: LazyLock = LazyLock::new(|| ServerCoreData { + version: RdpVersion::V5_PLUS, + optional_data: ServerCoreOptionalData { + client_requested_protocols: Some(SecurityProtocol::empty()), + early_capability_flags: None, + }, +}); + +pub static SERVER_CORE_DATA_WITH_ALL_OPTIONAL_FIELDS: LazyLock = LazyLock::new(|| ServerCoreData { + version: RdpVersion::V5_PLUS, + optional_data: ServerCoreOptionalData { + client_requested_protocols: Some(SecurityProtocol::empty()), + early_capability_flags: Some(ServerEarlyCapabilityFlags::EDGE_ACTIONS_SUPPORTED_V1), + }, +}); pub const SERVER_CORE_DATA_TO_REQUESTED_PROTOCOL_BUFFER: [u8; concat_arrays_size!( SERVER_CORE_DATA_BUFFER, diff --git a/crates/ironrdp-testsuite-core/src/gcc.rs b/crates/ironrdp-testsuite-core/src/gcc.rs index 6c9a0466bd..1ebe270e1f 100644 --- a/crates/ironrdp-testsuite-core/src/gcc.rs +++ b/crates/ironrdp-testsuite-core/src/gcc.rs @@ -1,6 +1,7 @@ +use std::sync::LazyLock; + use array_concat::{concat_arrays, concat_arrays_size}; use ironrdp_pdu::gcc::{ClientGccBlocks, ClientGccType, ServerGccBlocks, ServerGccType}; -use lazy_static::lazy_static; use crate::cluster_data::{CLUSTER_DATA, CLUSTER_DATA_BUFFER}; use crate::core_data::{ @@ -41,6 +42,7 @@ const fn make_gcc_block_buffer(data_type: u16, buffer: &[u8]) -> let array = copy_slice(&data_type.to_le_bytes(), [0; N], 0); + #[expect(clippy::as_conversions, reason = "must be const casts")] let length = (buffer.len() + USER_HEADER_LEN) as u16; let array = copy_slice(&length.to_le_bytes(), array, 2); @@ -125,43 +127,42 @@ pub const SERVER_GCC_WITH_OPTIONAL_FIELDS_IN_DIFFERENT_ORDER_BUFFER: [u8; concat SERVER_GCC_MULTI_TRANSPORT_CHANNEL_BLOCK_BUFFER ); -lazy_static! { - pub static ref CLIENT_GCC_WITHOUT_OPTIONAL_FIELDS: ClientGccBlocks = ClientGccBlocks { - core: CLIENT_OPTIONAL_CORE_DATA_TO_SERVER_SELECTED_PROTOCOL.clone(), - security: CLIENT_SECURITY_DATA.clone(), - network: Some(CLIENT_NETWORK_DATA_WITH_CHANNELS.clone()), - cluster: None, - monitor: None, - message_channel: None, - multi_transport_channel: None, - monitor_extended: None, - }; - pub static ref CLIENT_GCC_WITH_CLUSTER_OPTIONAL_FIELD: ClientGccBlocks = { - let mut data = CLIENT_GCC_WITHOUT_OPTIONAL_FIELDS.clone(); - data.cluster = Some(CLUSTER_DATA.clone()); - data - }; - pub static ref CLIENT_GCC_WITH_ALL_OPTIONAL_FIELDS: ClientGccBlocks = { - let mut data = CLIENT_GCC_WITH_CLUSTER_OPTIONAL_FIELD.clone(); - data.monitor = Some(crate::monitor_data::MONITOR_DATA_WITH_MONITORS.clone()); - data.monitor_extended = Some(crate::monitor_extended_data::MONITOR_DATA_WITH_MONITORS.clone()); - data - }; - pub static ref SERVER_GCC_WITHOUT_OPTIONAL_FIELDS: ServerGccBlocks = ServerGccBlocks { - core: SERVER_CORE_DATA_TO_FLAGS.clone(), - network: SERVER_NETWORK_DATA_WITH_CHANNELS_ID.clone(), - security: SERVER_SECURITY_DATA_WITH_OPTIONAL_FIELDS.clone(), - message_channel: None, - multi_transport_channel: None, - }; - pub static ref SERVER_GCC_WITH_OPTIONAL_FIELDS: ServerGccBlocks = { - let mut data = SERVER_GCC_WITHOUT_OPTIONAL_FIELDS.clone(); - data.message_channel = Some(SERVER_GCC_MESSAGE_CHANNEL_BLOCK.clone()); - data.multi_transport_channel = Some(SERVER_GCC_MULTI_TRANSPORT_CHANNEL_BLOCK.clone()); - data - }; -} - +pub static CLIENT_GCC_WITHOUT_OPTIONAL_FIELDS: LazyLock = LazyLock::new(|| ClientGccBlocks { + core: CLIENT_OPTIONAL_CORE_DATA_TO_SERVER_SELECTED_PROTOCOL.clone(), + security: CLIENT_SECURITY_DATA.clone(), + network: Some(CLIENT_NETWORK_DATA_WITH_CHANNELS.clone()), + cluster: None, + monitor: None, + message_channel: None, + multi_transport_channel: None, + monitor_extended: None, +}); +pub static CLIENT_GCC_WITH_CLUSTER_OPTIONAL_FIELD: LazyLock = LazyLock::new(|| { + let mut data = CLIENT_GCC_WITHOUT_OPTIONAL_FIELDS.clone(); + data.cluster = Some(CLUSTER_DATA.clone()); + data +}); +pub static CLIENT_GCC_WITH_ALL_OPTIONAL_FIELDS: LazyLock = LazyLock::new(|| { + let mut data = CLIENT_GCC_WITH_CLUSTER_OPTIONAL_FIELD.clone(); + data.monitor = Some(crate::monitor_data::MONITOR_DATA_WITH_MONITORS.clone()); + data.monitor_extended = Some(crate::monitor_extended_data::MONITOR_DATA_WITH_MONITORS.clone()); + data +}); +pub static SERVER_GCC_WITHOUT_OPTIONAL_FIELDS: LazyLock = LazyLock::new(|| ServerGccBlocks { + core: SERVER_CORE_DATA_TO_FLAGS.clone(), + network: SERVER_NETWORK_DATA_WITH_CHANNELS_ID.clone(), + security: SERVER_SECURITY_DATA_WITH_OPTIONAL_FIELDS.clone(), + message_channel: None, + multi_transport_channel: None, +}); +pub static SERVER_GCC_WITH_OPTIONAL_FIELDS: LazyLock = LazyLock::new(|| { + let mut data = SERVER_GCC_WITHOUT_OPTIONAL_FIELDS.clone(); + data.message_channel = Some(SERVER_GCC_MESSAGE_CHANNEL_BLOCK.clone()); + data.multi_transport_channel = Some(SERVER_GCC_MULTI_TRANSPORT_CHANNEL_BLOCK.clone()); + data +}); + +#[expect(clippy::as_conversions, reason = "must be const casts")] pub const CLIENT_GCC_CORE_BLOCK_BUFFER: [u8; gcc_block_size( CLIENT_OPTIONAL_CORE_DATA_TO_SERVER_SELECTED_PROTOCOL_BUFFER, )] = make_gcc_block_buffer( @@ -169,18 +170,22 @@ pub const CLIENT_GCC_CORE_BLOCK_BUFFER: [u8; gcc_block_size( &CLIENT_OPTIONAL_CORE_DATA_TO_SERVER_SELECTED_PROTOCOL_BUFFER, ); +#[expect(clippy::as_conversions, reason = "must be const casts")] pub const CLIENT_GCC_SECURITY_BLOCK_BUFFER: [u8; gcc_block_size(CLIENT_SECURITY_DATA_BUFFER)] = make_gcc_block_buffer(ClientGccType::SecurityData as u16, &CLIENT_SECURITY_DATA_BUFFER); +#[expect(clippy::as_conversions, reason = "must be const casts")] pub const CLIENT_GCC_NETWORK_BLOCK_BUFFER: [u8; gcc_block_size(CLIENT_NETWORK_DATA_WITH_CHANNELS_BUFFER)] = make_gcc_block_buffer( ClientGccType::NetworkData as u16, &CLIENT_NETWORK_DATA_WITH_CHANNELS_BUFFER, ); +#[expect(clippy::as_conversions, reason = "must be const casts")] pub const CLIENT_GCC_CLUSTER_BLOCK_BUFFER: [u8; gcc_block_size(CLUSTER_DATA_BUFFER)] = make_gcc_block_buffer(ClientGccType::ClusterData as u16, &CLUSTER_DATA_BUFFER); +#[expect(clippy::as_conversions, reason = "must be const casts")] pub const CLIENT_GCC_MONITOR_BLOCK_BUFFER: [u8; gcc_block_size( crate::monitor_data::MONITOR_DATA_WITH_MONITORS_BUFFER, )] = make_gcc_block_buffer( @@ -188,6 +193,7 @@ pub const CLIENT_GCC_MONITOR_BLOCK_BUFFER: [u8; gcc_block_size( &crate::monitor_data::MONITOR_DATA_WITH_MONITORS_BUFFER, ); +#[expect(clippy::as_conversions, reason = "must be const casts")] pub const CLIENT_GCC_MONITOR_EXTENDED_BLOCK_BUFFER: [u8; gcc_block_size( crate::monitor_extended_data::MONITOR_DATA_WITH_MONITORS_BUFFER, )] = make_gcc_block_buffer( @@ -195,24 +201,28 @@ pub const CLIENT_GCC_MONITOR_EXTENDED_BLOCK_BUFFER: [u8; gcc_block_size( &crate::monitor_extended_data::MONITOR_DATA_WITH_MONITORS_BUFFER, ); +#[expect(clippy::as_conversions, reason = "must be const casts")] pub const SERVER_GCC_CORE_BLOCK_BUFFER: [u8; gcc_block_size(SERVER_CORE_DATA_TO_REQUESTED_PROTOCOL_BUFFER)] = make_gcc_block_buffer( ServerGccType::CoreData as u16, &SERVER_CORE_DATA_TO_REQUESTED_PROTOCOL_BUFFER, ); +#[expect(clippy::as_conversions, reason = "must be const casts")] pub const SERVER_GCC_NETWORK_BLOCK_BUFFER: [u8; gcc_block_size(SERVER_NETWORK_DATA_WITH_CHANNELS_ID_BUFFER)] = make_gcc_block_buffer( ServerGccType::NetworkData as u16, &SERVER_NETWORK_DATA_WITH_CHANNELS_ID_BUFFER, ); +#[expect(clippy::as_conversions, reason = "must be const casts")] pub const SERVER_GCC_SECURITY_BLOCK_BUFFER: [u8; gcc_block_size(SERVER_SECURITY_DATA_WITH_OPTIONAL_FIELDS_BUFFER)] = make_gcc_block_buffer( ServerGccType::SecurityData as u16, &SERVER_SECURITY_DATA_WITH_OPTIONAL_FIELDS_BUFFER, ); +#[expect(clippy::as_conversions, reason = "must be const casts")] pub const SERVER_GCC_MESSAGE_CHANNEL_BLOCK_BUFFER: [u8; gcc_block_size( crate::message_channel_data::SERVER_GCC_MESSAGE_CHANNEL_BLOCK_BUFFER, )] = make_gcc_block_buffer( @@ -220,6 +230,7 @@ pub const SERVER_GCC_MESSAGE_CHANNEL_BLOCK_BUFFER: [u8; gcc_block_size( &crate::message_channel_data::SERVER_GCC_MESSAGE_CHANNEL_BLOCK_BUFFER, ); +#[expect(clippy::as_conversions, reason = "must be const casts")] pub const SERVER_GCC_MULTI_TRANSPORT_CHANNEL_BLOCK_BUFFER: [u8; gcc_block_size( crate::multi_transport_channel_data::SERVER_GCC_MULTI_TRANSPORT_CHANNEL_BLOCK_BUFFER, )] = make_gcc_block_buffer( diff --git a/crates/ironrdp-testsuite-core/src/gfx.rs b/crates/ironrdp-testsuite-core/src/gfx.rs index 9adf797f20..9e9a4589de 100644 --- a/crates/ironrdp-testsuite-core/src/gfx.rs +++ b/crates/ironrdp-testsuite-core/src/gfx.rs @@ -1,5 +1,6 @@ -use ironrdp_pdu::rdp::vc::dvc::gfx::{ClientPdu, ServerPdu}; -use lazy_static::lazy_static; +use std::sync::LazyLock; + +use ironrdp_egfx::pdu::GfxPdu; use crate::graphics_messages::{ FRAME_ACKNOWLEDGE, FRAME_ACKNOWLEDGE_BUFFER, WIRE_TO_SURFACE_1, WIRE_TO_SURFACE_1_BUFFER, @@ -8,11 +9,11 @@ use crate::graphics_messages::{ pub const WIRE_TO_SURFACE_1_HEADER_BUFFER: [u8; 8] = [0x01, 0x00, 0x00, 0x00, 0xe2, 0x00, 0x00, 0x00]; pub const FRAME_ACKNOWLEDGE_HEADER_BUFFER: [u8; 8] = [0x0d, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00]; -lazy_static! { - pub static ref HEADER_WITH_WIRE_TO_SURFACE_1_BUFFER: Vec = - [&WIRE_TO_SURFACE_1_HEADER_BUFFER[..], &WIRE_TO_SURFACE_1_BUFFER[..],].concat(); - pub static ref HEADER_WITH_FRAME_ACKNOWLEDGE_BUFFER: Vec = - [&FRAME_ACKNOWLEDGE_HEADER_BUFFER[..], &FRAME_ACKNOWLEDGE_BUFFER[..],].concat(); - pub static ref HEADER_WITH_WIRE_TO_SURFACE_1: ServerPdu = ServerPdu::WireToSurface1(WIRE_TO_SURFACE_1.clone()); - pub static ref HEADER_WITH_FRAME_ACKNOWLEDGE: ClientPdu = ClientPdu::FrameAcknowledge(FRAME_ACKNOWLEDGE.clone()); -} +pub static HEADER_WITH_WIRE_TO_SURFACE_1_BUFFER: LazyLock> = + LazyLock::new(|| [&WIRE_TO_SURFACE_1_HEADER_BUFFER[..], &WIRE_TO_SURFACE_1_BUFFER[..]].concat()); +pub static HEADER_WITH_FRAME_ACKNOWLEDGE_BUFFER: LazyLock> = + LazyLock::new(|| [&FRAME_ACKNOWLEDGE_HEADER_BUFFER[..], &FRAME_ACKNOWLEDGE_BUFFER[..]].concat()); +pub static HEADER_WITH_WIRE_TO_SURFACE_1: LazyLock = + LazyLock::new(|| GfxPdu::WireToSurface1(WIRE_TO_SURFACE_1.clone())); +pub static HEADER_WITH_FRAME_ACKNOWLEDGE: LazyLock = + LazyLock::new(|| GfxPdu::FrameAcknowledge(FRAME_ACKNOWLEDGE.clone())); diff --git a/crates/ironrdp-testsuite-core/src/graphics_messages.rs b/crates/ironrdp-testsuite-core/src/graphics_messages.rs index 395f59c51c..1f7892c1fc 100644 --- a/crates/ironrdp-testsuite-core/src/graphics_messages.rs +++ b/crates/ironrdp-testsuite-core/src/graphics_messages.rs @@ -1,14 +1,15 @@ -use ironrdp_pdu::gcc::{Monitor, MonitorFlags}; -use ironrdp_pdu::geometry::InclusiveRectangle; -use ironrdp_pdu::rdp::vc::dvc::gfx::{ +use std::sync::LazyLock; + +use ironrdp_egfx::pdu::{ Avc420BitmapStream, Avc444BitmapStream, CacheImportReplyPdu, CacheToSurfacePdu, CapabilitiesAdvertisePdu, - CapabilitiesConfirmPdu, CapabilitiesV103Flags, CapabilitiesV104Flags, CapabilitiesV10Flags, CapabilitiesV81Flags, - CapabilitiesV8Flags, CapabilitySet, Codec1Type, Codec2Type, Color, CreateSurfacePdu, DeleteEncodingContextPdu, + CapabilitiesConfirmPdu, CapabilitiesV8Flags, CapabilitiesV10Flags, CapabilitiesV81Flags, CapabilitiesV103Flags, + CapabilitiesV104Flags, CapabilitySet, Codec1Type, Codec2Type, Color, CreateSurfacePdu, DeleteEncodingContextPdu, DeleteSurfacePdu, Encoding, EndFramePdu, EvictCacheEntryPdu, FrameAcknowledgePdu, MapSurfaceToOutputPdu, PixelFormat, Point, QuantQuality, QueueDepth, ResetGraphicsPdu, SolidFillPdu, StartFramePdu, SurfaceToCachePdu, SurfaceToSurfacePdu, Timestamp, WireToSurface1Pdu, WireToSurface2Pdu, }; -use lazy_static::lazy_static; +use ironrdp_pdu::gcc::{Monitor, MonitorFlags}; +use ironrdp_pdu::geometry::{ExclusiveRectangle, InclusiveRectangle}; pub const WIRE_TO_SURFACE_1_BUFFER: [u8; 218] = [ 0x00, 0x00, 0x08, 0x00, 0x20, 0xa5, 0x03, 0xde, 0x02, 0xab, 0x03, 0xe7, 0x02, 0xc9, 0x00, 0x00, 0x00, 0x01, 0x0e, @@ -234,222 +235,221 @@ pub const AVC_444_MESSAGE_CORRECT_LEN: [u8; 88] = [ 0x1d, 0xe7, 0x97, 0xab, 0x80, 0x80, 0x80, ]; -lazy_static! { - pub static ref WIRE_TO_SURFACE_1: WireToSurface1Pdu = WireToSurface1Pdu { - surface_id: 0, - codec_id: Codec1Type::ClearCodec, - pixel_format: PixelFormat::XRgb, - destination_rectangle: InclusiveRectangle { - left: 933, - top: 734, - right: 939, - bottom: 743 - }, - bitmap_data: WIRE_TO_SURFACE_1_BUFFER[17..].to_vec(), - }; - pub static ref WIRE_TO_SURFACE_1_BITMAP_DATA: Vec = WIRE_TO_SURFACE_1_BUFFER[17..].to_vec(); - pub static ref WIRE_TO_SURFACE_2: WireToSurface2Pdu = WireToSurface2Pdu { - surface_id: 0, - codec_id: Codec2Type::RemoteFxProgressive, - codec_context_id: 4, - pixel_format: PixelFormat::XRgb, - bitmap_data: WIRE_TO_SURFACE_2_BUFFER[13..].to_vec(), - }; - pub static ref WIRE_TO_SURFACE_2_BITMAP_DATA: Vec = WIRE_TO_SURFACE_2_BUFFER[13..].to_vec(); - pub static ref DELETE_ENCODING_CONTEXT: DeleteEncodingContextPdu = DeleteEncodingContextPdu { - surface_id: 0, - codec_context_id: 1, - }; - pub static ref SOLID_FILL: SolidFillPdu = SolidFillPdu { - surface_id: 0, - fill_pixel: Color { - b: 0, - g: 0, - r: 0, - xa: 0, - }, - rectangles: vec![InclusiveRectangle { - left: 0, - top: 0, - right: 64, - bottom: 64 - }], - }; - pub static ref SURFACE_TO_SURFACE: SurfaceToSurfacePdu = SurfaceToSurfacePdu { - source_surface_id: 0, - destination_surface_id: 0, - source_rectangle: InclusiveRectangle { - left: 200, - top: 60, - right: 676, - bottom: 148 - }, - destination_points: vec![Point { x: 128, y: 60 }], - }; - pub static ref SURFACE_TO_CACHE: SurfaceToCachePdu = SurfaceToCachePdu { - surface_id: 0, - cache_key: 0x113D_86DA_A6A3_7FB7, - cache_slot: 14, - source_rectangle: InclusiveRectangle { - left: 640, - top: 0, - right: 704, - bottom: 64 - }, - }; - pub static ref CACHE_TO_SURFACE: CacheToSurfacePdu = CacheToSurfacePdu { - cache_slot: 2, - surface_id: 0, - destination_points: vec![Point { x: 768, y: 320 }], - }; - pub static ref CREATE_SURFACE: CreateSurfacePdu = CreateSurfacePdu { - surface_id: 0, - width: 1024, - height: 768, - pixel_format: PixelFormat::ARgb, - }; - pub static ref DELETE_SURFACE: DeleteSurfacePdu = DeleteSurfacePdu { surface_id: 0 }; - pub static ref RESET_GRAPHICS: ResetGraphicsPdu = ResetGraphicsPdu { - width: 1024, - height: 768, - monitors: vec![Monitor { - left: 0, - top: 0, - right: 1023, - bottom: 767, - flags: MonitorFlags::PRIMARY, - }], - }; - pub static ref MAP_SURFACE_TO_OUTPUT: MapSurfaceToOutputPdu = MapSurfaceToOutputPdu { - surface_id: 0, - output_origin_x: 1, - output_origin_y: 2, - }; - pub static ref EVICT_CACHE_ENTRY: EvictCacheEntryPdu = EvictCacheEntryPdu { cache_slot: 0 }; - pub static ref START_FRAME: StartFramePdu = StartFramePdu { - timestamp: Timestamp { - milliseconds: 247, - seconds: 58, - minutes: 27, - hours: 22, - }, - frame_id: 5 - }; - pub static ref END_FRAME: EndFramePdu = EndFramePdu { frame_id: 1 }; - pub static ref CAPABILITIES_CONFIRM: CapabilitiesConfirmPdu = CapabilitiesConfirmPdu(CapabilitySet::V10_5 { +pub static WIRE_TO_SURFACE_1: LazyLock = LazyLock::new(|| WireToSurface1Pdu { + surface_id: 0, + codec_id: Codec1Type::ClearCodec, + pixel_format: PixelFormat::XRgb, + destination_rectangle: ExclusiveRectangle { + left: 933, + top: 734, + right: 939, + bottom: 743, + }, + bitmap_data: WIRE_TO_SURFACE_1_BUFFER[17..].to_vec(), +}); +pub static WIRE_TO_SURFACE_1_BITMAP_DATA: LazyLock> = LazyLock::new(|| WIRE_TO_SURFACE_1_BUFFER[17..].to_vec()); +pub static WIRE_TO_SURFACE_2: LazyLock = LazyLock::new(|| WireToSurface2Pdu { + surface_id: 0, + codec_id: Codec2Type::RemoteFxProgressive, + codec_context_id: 4, + pixel_format: PixelFormat::XRgb, + bitmap_data: WIRE_TO_SURFACE_2_BUFFER[13..].to_vec(), +}); +pub static WIRE_TO_SURFACE_2_BITMAP_DATA: LazyLock> = LazyLock::new(|| WIRE_TO_SURFACE_2_BUFFER[13..].to_vec()); +pub static DELETE_ENCODING_CONTEXT: LazyLock = LazyLock::new(|| DeleteEncodingContextPdu { + surface_id: 0, + codec_context_id: 1, +}); +pub static SOLID_FILL: LazyLock = LazyLock::new(|| SolidFillPdu { + surface_id: 0, + fill_pixel: Color { + b: 0, + g: 0, + r: 0, + xa: 0, + }, + rectangles: vec![ExclusiveRectangle { + left: 0, + top: 0, + right: 64, + bottom: 64, + }], +}); +pub static SURFACE_TO_SURFACE: LazyLock = LazyLock::new(|| SurfaceToSurfacePdu { + source_surface_id: 0, + destination_surface_id: 0, + source_rectangle: ExclusiveRectangle { + left: 200, + top: 60, + right: 676, + bottom: 148, + }, + destination_points: vec![Point { x: 128, y: 60 }], +}); +pub static SURFACE_TO_CACHE: LazyLock = LazyLock::new(|| SurfaceToCachePdu { + surface_id: 0, + cache_key: 0x113D_86DA_A6A3_7FB7, + cache_slot: 14, + source_rectangle: ExclusiveRectangle { + left: 640, + top: 0, + right: 704, + bottom: 64, + }, +}); +pub static CACHE_TO_SURFACE: LazyLock = LazyLock::new(|| CacheToSurfacePdu { + cache_slot: 2, + surface_id: 0, + destination_points: vec![Point { x: 768, y: 320 }], +}); +pub static CREATE_SURFACE: LazyLock = LazyLock::new(|| CreateSurfacePdu { + surface_id: 0, + width: 1024, + height: 768, + pixel_format: PixelFormat::ARgb, +}); +pub static DELETE_SURFACE: LazyLock = LazyLock::new(|| DeleteSurfacePdu { surface_id: 0 }); +pub static RESET_GRAPHICS: LazyLock = LazyLock::new(|| ResetGraphicsPdu { + width: 1024, + height: 768, + monitors: vec![Monitor { + left: 0, + top: 0, + right: 1023, + bottom: 767, + flags: MonitorFlags::PRIMARY, + }], +}); +pub static MAP_SURFACE_TO_OUTPUT: LazyLock = LazyLock::new(|| MapSurfaceToOutputPdu { + surface_id: 0, + output_origin_x: 1, + output_origin_y: 2, +}); +pub static EVICT_CACHE_ENTRY: LazyLock = LazyLock::new(|| EvictCacheEntryPdu { cache_slot: 0 }); +pub static START_FRAME: LazyLock = LazyLock::new(|| StartFramePdu { + timestamp: Timestamp { + milliseconds: 247, + seconds: 58, + minutes: 27, + hours: 22, + }, + frame_id: 5, +}); +pub static END_FRAME: LazyLock = LazyLock::new(|| EndFramePdu { frame_id: 1 }); +pub static CAPABILITIES_CONFIRM: LazyLock = LazyLock::new(|| { + CapabilitiesConfirmPdu::from_typed(&CapabilitySet::V10_5 { flags: CapabilitiesV104Flags::AVC_DISABLED, - }); - pub static ref CAPABILITIES_ADVERTISE: CapabilitiesAdvertisePdu = CapabilitiesAdvertisePdu(vec![ + }) +}); +pub static CAPABILITIES_ADVERTISE: LazyLock = LazyLock::new(|| { + CapabilitiesAdvertisePdu::from_typed(&[ CapabilitySet::V8 { - flags: CapabilitiesV8Flags::THIN_CLIENT + flags: CapabilitiesV8Flags::THIN_CLIENT, }, CapabilitySet::V8_1 { - flags: CapabilitiesV81Flags::THIN_CLIENT + flags: CapabilitiesV81Flags::THIN_CLIENT, }, CapabilitySet::V10 { - flags: CapabilitiesV10Flags::AVC_DISABLED + flags: CapabilitiesV10Flags::AVC_DISABLED, }, CapabilitySet::V10_1, CapabilitySet::V10_2 { - flags: CapabilitiesV10Flags::AVC_DISABLED + flags: CapabilitiesV10Flags::AVC_DISABLED, }, CapabilitySet::V10_3 { - flags: CapabilitiesV103Flags::AVC_DISABLED + flags: CapabilitiesV103Flags::AVC_DISABLED, }, CapabilitySet::V10_4 { - flags: CapabilitiesV104Flags::AVC_DISABLED + flags: CapabilitiesV104Flags::AVC_DISABLED, }, CapabilitySet::V10_5 { - flags: CapabilitiesV104Flags::AVC_DISABLED + flags: CapabilitiesV104Flags::AVC_DISABLED, }, CapabilitySet::V10_6 { - flags: CapabilitiesV104Flags::AVC_DISABLED - } - ]); - pub static ref FRAME_ACKNOWLEDGE: FrameAcknowledgePdu = FrameAcknowledgePdu { - queue_depth: QueueDepth::Unavailable, - frame_id: 1, - total_frames_decoded: 1 - }; - pub static ref CACHE_IMPORT_REPLY: CacheImportReplyPdu = CacheImportReplyPdu { - cache_slots: vec![ - 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, - 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, - 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, - 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x4b, - 0x4c, 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, - 0x5e, 0x5f, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, - 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f, 0x80, 0x81, - 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, 0x90, 0x91, 0x92, 0x93, - 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f, 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, - 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, 0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, - 0xb8, 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf, 0xc0, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, - 0xca, 0xcb, 0xcc, 0xcd, 0xce, 0xcf, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xdb, - 0xdc, 0xdd, 0xde, 0xdf, 0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xeb, 0xec, 0xed, - 0xee, 0xef, 0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff, - 0x100, 0x101, 0x102, 0x103, 0x104, 0x105, 0x106, 0x107, 0x108, 0x109, 0x10a, 0x10b, 0x10c, 0x10d, 0x10e, - 0x10f, 0x110, 0x111, 0x112, 0x113, 0x114, 0x115, 0x116, 0x117, 0x118, 0x119, 0x11a, 0x11b, 0x11c, 0x11d, - 0x11e, 0x11f, 0x120, 0x121, 0x122, 0x123, 0x124, 0x125, 0x126, 0x127, 0x128, 0x129, 0x12a, 0x12b, 0x12c, - 0x12d, 0x12e, 0x12f, 0x130, 0x131, 0x132, 0x133, 0x134, 0x135, 0x136, 0x137, 0x138, 0x139, 0x13a, 0x13b, - 0x13c, 0x13d, 0x13e, 0x13f, 0x140, 0x141, 0x142, 0x143, 0x144, 0x145, 0x146, 0x147, 0x148, 0x149, 0x14a, - 0x14b, 0x14c, 0x14d, 0x14e, 0x14f, 0x150, 0x151, 0x152, 0x153, 0x154, 0x155, 0x156, 0x157, 0x158, 0x159, - 0x15a, 0x15b, 0x15c, 0x15d, 0x15e, 0x15f, 0x160, 0x161, 0x162, 0x163, 0x164, 0x165, 0x166, 0x167, 0x168, - 0x169, 0x16a, 0x16b, 0x16c, 0x16d, 0x16e, 0x16f, 0x170, 0x171, 0x172, 0x173, 0x174, 0x175, 0x176, 0x177, - 0x178, 0x179, 0x17a, 0x17b, 0x17c, 0x17d, 0x17e, 0x17f, 0x180, 0x181, 0x182, 0x183, 0x184, 0x185, 0x186, - 0x187, 0x188, 0x189, 0x18a, 0x18b, 0x18c, 0x18d, 0x18e, 0x18f, 0x190, 0x191, 0x192, 0x193, 0x194, 0x195, - 0x196, 0x197, 0x198, 0x199, 0x19a, 0x19b, 0x19c, 0x19d, 0x19e, 0x19f, 0x1a0, 0x1a1, 0x1a2, 0x1a3, 0x1a4, - 0x1a5, 0x1a6, 0x1a7, 0x1a8, 0x1a9, 0x1aa, 0x1ab, 0x1ac, 0x1ad, 0x1ae, 0x1af, 0x1b0, 0x1b1, 0x1b2, 0x1b3, - 0x1b4, 0x1b5, 0x1b6, 0x1b7, 0x1b8, 0x1b9, 0x1ba, 0x1bb, 0x1bc, 0x1bd, 0x1be, 0x1bf, 0x1c0, 0x1c1, 0x1c2, - 0x1c3, 0x1c4, 0x1c5, 0x1c6, 0x1c7, 0x1c8, 0x1c9, 0x1ca, 0x1cb, 0x1cc, 0x1cd, 0x1ce, 0x1cf, 0x1d0, 0x1d1, - 0x1d2, 0x1d3, 0x1d4, 0x1d5, 0x1d6, 0x1d7, 0x1d8, 0x1d9, 0x1da, 0x1db, 0x1dc, 0x1dd, 0x1de, 0x1df, 0x1e0, - 0x1e1, 0x1e2, 0x1e3, 0x1e4, 0x1e5, 0x1e6, 0x1e7, 0x1e8, 0x1e9, 0x1ea, 0x1eb, 0x1ec, 0x1ed, 0x1ee, 0x1ef, - 0x1f0, 0x1f1, 0x1f2, 0x1f3, 0x1f4, 0x1f5, 0x1f6, 0x1f7, 0x1f8, 0x1f9, 0x1fa, 0x1fb, 0x1fc, 0x1fd, 0x1fe, - 0x1ff, 0x200, 0x201, 0x202, 0x203, 0x204, 0x205, 0x206, 0x207, 0x208, 0x209, 0x20a, 0x20b, 0x20c, 0x20d, - 0x20e, 0x20f, 0x210, 0x211, 0x212, 0x213, 0x214, 0x215, 0x216, 0x217, 0x218, 0x219, 0x21a, 0x21b, 0x21c, - 0x21d, 0x21e, 0x21f, 0x220, 0x221, 0x222, 0x223, 0x224, 0x225, 0x226, 0x227, 0x228, 0x229, 0x22a, 0x22b, - 0x22c, 0x22d, 0x22e, 0x22f, 0x230, 0x231, 0x232, 0x233, 0x234, 0x235, 0x236, 0x237, 0x238, 0x239, 0x23a, - 0x23b, 0x23c, 0x23d, 0x23e, 0x23f, 0x240, 0x241, 0x242, 0x243, 0x244, 0x245, 0x246, 0x247, 0x248, 0x249, - 0x24a, 0x24b, 0x24c, 0x24d, 0x24e, 0x24f, 0x250, 0x251, 0x252, 0x253, 0x254, 0x255, 0x256, 0x257, 0x258, - 0x259, 0x25a, 0x25b, 0x25c, 0x25d, 0x25e, 0x25f, 0x260, 0x261, 0x262, 0x263, 0x264, 0x265, 0x266, 0x267, - 0x268, 0x269, 0x26a, 0x26b, 0x26c, 0x26d, 0x26e, 0x26f, 0x270, 0x271, 0x272, 0x273, 0x274, 0x275, 0x276, - 0x277, 0x278, 0x279, 0x27a, 0x27b, 0x27c, 0x27d, 0x27e, 0x27f, 0x280, 0x281, 0x282, 0x283, 0x284, 0x285, - 0x286, 0x287, 0x288, 0x289, 0x28a, 0x28b, 0x28c, 0x28d, 0x28e, 0x28f, 0x290, 0x291, 0x292, 0x293, 0x294, - 0x295, 0x296, 0x297, 0x298, 0x299, 0x29a, 0x29b, 0x29c, 0x29d, 0x29e, 0x29f, 0x2a0, 0x2a1, 0x2a2, 0x2a3, - 0x2a4, 0x2a5, 0x2a6, 0x2a7, 0x2a8, 0x2a9, 0x2aa, 0x2ab, 0x2ac, 0x2ad, 0x2ae, 0x2af, 0x2b0, 0x2b1, 0x2b2, - 0x2b3, 0x2b4, 0x2b5, 0x2b6, 0x2b7, 0x2b8, 0x2b9, 0x2ba, 0x2bb, 0x2bc, 0x2bd, 0x2be, 0x2bf, 0x2c0, 0x2c1, - 0x2c2, 0x2c3, 0x2c4, 0x2c5, 0x2c6, 0x2c7, 0x2c8, 0x2c9, 0x2ca, 0x2cb, 0x2cc, 0x2cd, 0x2ce, 0x2cf, 0x2d0, - 0x2d1, 0x2d2, 0x2d3, 0x2d4, 0x2d5, 0x2d6, 0x2d7, 0x2d8, 0x2d9, 0x2da, 0x2db, 0x2dc, 0x2dd, 0x2de, 0x2df, - 0x2e0, 0x2e1, 0x2e2, 0x2e3, 0x2e4, 0x2e5, 0x2e6, 0x2e7, 0x2e8, 0x2e9, 0x2ea, 0x2eb, 0x2ec, 0x2ed, 0x2ee, - 0x2ef, 0x2f0, 0x2f1, 0x2f2, 0x2f3, 0x2f4, 0x2f5, 0x2f6, 0x2f7, 0x2f8, 0x2f9, 0x2fa, 0x2fb, 0x2fc, 0x2fd, - 0x2fe, 0x2ff, 0x300, 0x301, 0x302, 0x303, 0x304, 0x305, 0x306, 0x307, 0x308, 0x309, 0x30a, 0x30b, 0x30c, - 0x30d, 0x30e, 0x30f, 0x310, 0x311, 0x312, 0x313, 0x314, 0x315, 0x316, 0x317, 0x318, 0x319, 0x31a, 0x31b, - 0x31c, 0x31d, 0x31e, 0x31f, 0x320, 0x321, 0x322, 0x323, 0x324, 0x325, 0x326, 0x327, 0x328, 0x329, 0x32a, - 0x32b, 0x32c, 0x32d, 0x32e, 0x32f, 0x330, 0x331, 0x332, 0x333, 0x334, 0x335, 0x336, 0x337, 0x338, 0x339, - 0x33a, 0x33b, 0x33c, 0x33d, 0x33e, 0x33f, 0x340, 0x341, 0x342, 0x343, 0x344, 0x345, 0x346, 0x347, 0x348, - 0x349, 0x34a, 0x34b, 0x34c, 0x34d, 0x34e, 0x34f, 0x350, 0x351, 0x352, 0x353, 0x354, 0x355, 0x356, 0x357, - 0x358, 0x359, 0x35a, 0x35b, 0x35c, 0x35d, 0x35e, 0x35f, 0x360, 0x361, 0x362, 0x363, 0x364, 0x365, 0x366, - 0x367, 0x368, 0x369, 0x36a, 0x36b, 0x36c, 0x36d, 0x36e, 0x36f, 0x370, 0x371, 0x372, 0x373, 0x374, 0x375, - 0x376, 0x377, 0x378, 0x379, 0x37a, 0x37b, 0x37c, 0x37d, 0x37e, 0x37f, 0x380, 0x381, 0x382, 0x383, 0x384, - 0x385, 0x386, 0x387, 0x388, 0x389, 0x38a, 0x38b, 0x38c, 0x38d, 0x38e, 0x38f, 0x390, 0x391, 0x392, 0x393, - 0x394, 0x395, 0x396, 0x397, 0x398 - ] - }; - pub static ref AVC_444_BITMAP: Avc444BitmapStream<'static> = Avc444BitmapStream { - encoding: Encoding::CHROMA, - stream1: Avc420BitmapStream { - rectangles: vec![InclusiveRectangle { - left: 1792, - top: 1056, - right: 1808, - bottom: 1072, - }], - quant_qual_vals: vec![QuantQuality { - quantization_parameter: 22, - progressive: false, - quality: 100, - }], - data: &AVC_444_MESSAGE_CORRECT_LEN[18..] + flags: CapabilitiesV104Flags::AVC_DISABLED, }, - stream2: None - }; -} + ]) +}); +pub static FRAME_ACKNOWLEDGE: LazyLock = LazyLock::new(|| FrameAcknowledgePdu { + queue_depth: QueueDepth::Unavailable, + frame_id: 1, + total_frames_decoded: 1, +}); +pub static CACHE_IMPORT_REPLY: LazyLock = LazyLock::new(|| CacheImportReplyPdu { + cache_slots: vec![ + 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, + 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, + 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, + 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x4b, 0x4c, + 0x4d, 0x4e, 0x4f, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, + 0x5f, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, + 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f, 0x80, 0x81, 0x82, + 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, 0x90, 0x91, 0x92, 0x93, 0x94, + 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f, 0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, + 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf, 0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, + 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf, 0xc0, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, + 0xcb, 0xcc, 0xcd, 0xce, 0xcf, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xdb, 0xdc, + 0xdd, 0xde, 0xdf, 0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xeb, 0xec, 0xed, 0xee, + 0xef, 0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff, 0x100, + 0x101, 0x102, 0x103, 0x104, 0x105, 0x106, 0x107, 0x108, 0x109, 0x10a, 0x10b, 0x10c, 0x10d, 0x10e, 0x10f, 0x110, + 0x111, 0x112, 0x113, 0x114, 0x115, 0x116, 0x117, 0x118, 0x119, 0x11a, 0x11b, 0x11c, 0x11d, 0x11e, 0x11f, 0x120, + 0x121, 0x122, 0x123, 0x124, 0x125, 0x126, 0x127, 0x128, 0x129, 0x12a, 0x12b, 0x12c, 0x12d, 0x12e, 0x12f, 0x130, + 0x131, 0x132, 0x133, 0x134, 0x135, 0x136, 0x137, 0x138, 0x139, 0x13a, 0x13b, 0x13c, 0x13d, 0x13e, 0x13f, 0x140, + 0x141, 0x142, 0x143, 0x144, 0x145, 0x146, 0x147, 0x148, 0x149, 0x14a, 0x14b, 0x14c, 0x14d, 0x14e, 0x14f, 0x150, + 0x151, 0x152, 0x153, 0x154, 0x155, 0x156, 0x157, 0x158, 0x159, 0x15a, 0x15b, 0x15c, 0x15d, 0x15e, 0x15f, 0x160, + 0x161, 0x162, 0x163, 0x164, 0x165, 0x166, 0x167, 0x168, 0x169, 0x16a, 0x16b, 0x16c, 0x16d, 0x16e, 0x16f, 0x170, + 0x171, 0x172, 0x173, 0x174, 0x175, 0x176, 0x177, 0x178, 0x179, 0x17a, 0x17b, 0x17c, 0x17d, 0x17e, 0x17f, 0x180, + 0x181, 0x182, 0x183, 0x184, 0x185, 0x186, 0x187, 0x188, 0x189, 0x18a, 0x18b, 0x18c, 0x18d, 0x18e, 0x18f, 0x190, + 0x191, 0x192, 0x193, 0x194, 0x195, 0x196, 0x197, 0x198, 0x199, 0x19a, 0x19b, 0x19c, 0x19d, 0x19e, 0x19f, 0x1a0, + 0x1a1, 0x1a2, 0x1a3, 0x1a4, 0x1a5, 0x1a6, 0x1a7, 0x1a8, 0x1a9, 0x1aa, 0x1ab, 0x1ac, 0x1ad, 0x1ae, 0x1af, 0x1b0, + 0x1b1, 0x1b2, 0x1b3, 0x1b4, 0x1b5, 0x1b6, 0x1b7, 0x1b8, 0x1b9, 0x1ba, 0x1bb, 0x1bc, 0x1bd, 0x1be, 0x1bf, 0x1c0, + 0x1c1, 0x1c2, 0x1c3, 0x1c4, 0x1c5, 0x1c6, 0x1c7, 0x1c8, 0x1c9, 0x1ca, 0x1cb, 0x1cc, 0x1cd, 0x1ce, 0x1cf, 0x1d0, + 0x1d1, 0x1d2, 0x1d3, 0x1d4, 0x1d5, 0x1d6, 0x1d7, 0x1d8, 0x1d9, 0x1da, 0x1db, 0x1dc, 0x1dd, 0x1de, 0x1df, 0x1e0, + 0x1e1, 0x1e2, 0x1e3, 0x1e4, 0x1e5, 0x1e6, 0x1e7, 0x1e8, 0x1e9, 0x1ea, 0x1eb, 0x1ec, 0x1ed, 0x1ee, 0x1ef, 0x1f0, + 0x1f1, 0x1f2, 0x1f3, 0x1f4, 0x1f5, 0x1f6, 0x1f7, 0x1f8, 0x1f9, 0x1fa, 0x1fb, 0x1fc, 0x1fd, 0x1fe, 0x1ff, 0x200, + 0x201, 0x202, 0x203, 0x204, 0x205, 0x206, 0x207, 0x208, 0x209, 0x20a, 0x20b, 0x20c, 0x20d, 0x20e, 0x20f, 0x210, + 0x211, 0x212, 0x213, 0x214, 0x215, 0x216, 0x217, 0x218, 0x219, 0x21a, 0x21b, 0x21c, 0x21d, 0x21e, 0x21f, 0x220, + 0x221, 0x222, 0x223, 0x224, 0x225, 0x226, 0x227, 0x228, 0x229, 0x22a, 0x22b, 0x22c, 0x22d, 0x22e, 0x22f, 0x230, + 0x231, 0x232, 0x233, 0x234, 0x235, 0x236, 0x237, 0x238, 0x239, 0x23a, 0x23b, 0x23c, 0x23d, 0x23e, 0x23f, 0x240, + 0x241, 0x242, 0x243, 0x244, 0x245, 0x246, 0x247, 0x248, 0x249, 0x24a, 0x24b, 0x24c, 0x24d, 0x24e, 0x24f, 0x250, + 0x251, 0x252, 0x253, 0x254, 0x255, 0x256, 0x257, 0x258, 0x259, 0x25a, 0x25b, 0x25c, 0x25d, 0x25e, 0x25f, 0x260, + 0x261, 0x262, 0x263, 0x264, 0x265, 0x266, 0x267, 0x268, 0x269, 0x26a, 0x26b, 0x26c, 0x26d, 0x26e, 0x26f, 0x270, + 0x271, 0x272, 0x273, 0x274, 0x275, 0x276, 0x277, 0x278, 0x279, 0x27a, 0x27b, 0x27c, 0x27d, 0x27e, 0x27f, 0x280, + 0x281, 0x282, 0x283, 0x284, 0x285, 0x286, 0x287, 0x288, 0x289, 0x28a, 0x28b, 0x28c, 0x28d, 0x28e, 0x28f, 0x290, + 0x291, 0x292, 0x293, 0x294, 0x295, 0x296, 0x297, 0x298, 0x299, 0x29a, 0x29b, 0x29c, 0x29d, 0x29e, 0x29f, 0x2a0, + 0x2a1, 0x2a2, 0x2a3, 0x2a4, 0x2a5, 0x2a6, 0x2a7, 0x2a8, 0x2a9, 0x2aa, 0x2ab, 0x2ac, 0x2ad, 0x2ae, 0x2af, 0x2b0, + 0x2b1, 0x2b2, 0x2b3, 0x2b4, 0x2b5, 0x2b6, 0x2b7, 0x2b8, 0x2b9, 0x2ba, 0x2bb, 0x2bc, 0x2bd, 0x2be, 0x2bf, 0x2c0, + 0x2c1, 0x2c2, 0x2c3, 0x2c4, 0x2c5, 0x2c6, 0x2c7, 0x2c8, 0x2c9, 0x2ca, 0x2cb, 0x2cc, 0x2cd, 0x2ce, 0x2cf, 0x2d0, + 0x2d1, 0x2d2, 0x2d3, 0x2d4, 0x2d5, 0x2d6, 0x2d7, 0x2d8, 0x2d9, 0x2da, 0x2db, 0x2dc, 0x2dd, 0x2de, 0x2df, 0x2e0, + 0x2e1, 0x2e2, 0x2e3, 0x2e4, 0x2e5, 0x2e6, 0x2e7, 0x2e8, 0x2e9, 0x2ea, 0x2eb, 0x2ec, 0x2ed, 0x2ee, 0x2ef, 0x2f0, + 0x2f1, 0x2f2, 0x2f3, 0x2f4, 0x2f5, 0x2f6, 0x2f7, 0x2f8, 0x2f9, 0x2fa, 0x2fb, 0x2fc, 0x2fd, 0x2fe, 0x2ff, 0x300, + 0x301, 0x302, 0x303, 0x304, 0x305, 0x306, 0x307, 0x308, 0x309, 0x30a, 0x30b, 0x30c, 0x30d, 0x30e, 0x30f, 0x310, + 0x311, 0x312, 0x313, 0x314, 0x315, 0x316, 0x317, 0x318, 0x319, 0x31a, 0x31b, 0x31c, 0x31d, 0x31e, 0x31f, 0x320, + 0x321, 0x322, 0x323, 0x324, 0x325, 0x326, 0x327, 0x328, 0x329, 0x32a, 0x32b, 0x32c, 0x32d, 0x32e, 0x32f, 0x330, + 0x331, 0x332, 0x333, 0x334, 0x335, 0x336, 0x337, 0x338, 0x339, 0x33a, 0x33b, 0x33c, 0x33d, 0x33e, 0x33f, 0x340, + 0x341, 0x342, 0x343, 0x344, 0x345, 0x346, 0x347, 0x348, 0x349, 0x34a, 0x34b, 0x34c, 0x34d, 0x34e, 0x34f, 0x350, + 0x351, 0x352, 0x353, 0x354, 0x355, 0x356, 0x357, 0x358, 0x359, 0x35a, 0x35b, 0x35c, 0x35d, 0x35e, 0x35f, 0x360, + 0x361, 0x362, 0x363, 0x364, 0x365, 0x366, 0x367, 0x368, 0x369, 0x36a, 0x36b, 0x36c, 0x36d, 0x36e, 0x36f, 0x370, + 0x371, 0x372, 0x373, 0x374, 0x375, 0x376, 0x377, 0x378, 0x379, 0x37a, 0x37b, 0x37c, 0x37d, 0x37e, 0x37f, 0x380, + 0x381, 0x382, 0x383, 0x384, 0x385, 0x386, 0x387, 0x388, 0x389, 0x38a, 0x38b, 0x38c, 0x38d, 0x38e, 0x38f, 0x390, + 0x391, 0x392, 0x393, 0x394, 0x395, 0x396, 0x397, 0x398, + ], +}); +pub static AVC_444_BITMAP: LazyLock> = LazyLock::new(|| Avc444BitmapStream { + encoding: Encoding::CHROMA, + stream1: Avc420BitmapStream { + rectangles: vec![InclusiveRectangle { + left: 1792, + top: 1056, + right: 1808, + bottom: 1072, + }], + quant_qual_vals: vec![QuantQuality { + quantization_parameter: 22, + progressive: false, + quality: 100, + }], + data: &AVC_444_MESSAGE_CORRECT_LEN[18..], + }, + stream2: None, +}); diff --git a/crates/ironrdp-testsuite-core/src/lib.rs b/crates/ironrdp-testsuite-core/src/lib.rs index aeb08ee537..3318fa4931 100644 --- a/crates/ironrdp-testsuite-core/src/lib.rs +++ b/crates/ironrdp-testsuite-core/src/lib.rs @@ -5,6 +5,7 @@ #![allow(clippy::cast_possible_wrap)] #![allow(clippy::cast_sign_loss)] #![allow(unused_crate_dependencies)] +#![allow(clippy::unwrap_used, reason = "unwrap is fine in tests")] mod macros; diff --git a/crates/ironrdp-testsuite-core/src/mcs.rs b/crates/ironrdp-testsuite-core/src/mcs.rs index 0a23f38a5e..5836945d73 100644 --- a/crates/ironrdp-testsuite-core/src/mcs.rs +++ b/crates/ironrdp-testsuite-core/src/mcs.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::sync::LazyLock; use array_concat::{concat_arrays, concat_arrays_size}; use ironrdp_pdu::mcs::{ @@ -6,7 +7,6 @@ use ironrdp_pdu::mcs::{ DisconnectProviderUltimatum, DisconnectReason, DomainParameters, ErectDomainPdu, OwnedSendDataIndication, OwnedSendDataRequest, SendDataIndication, SendDataRequest, }; -use lazy_static::lazy_static; use crate::conference_create::{ CONFERENCE_CREATE_REQUEST, CONFERENCE_CREATE_REQUEST_BUFFER, CONFERENCE_CREATE_RESPONSE, @@ -105,55 +105,53 @@ pub const CONNECT_RESPONSE_BUFFER: [u8; concat_arrays_size!( CONFERENCE_CREATE_RESPONSE_BUFFER )] = concat_arrays!(CONNECT_RESPONSE_PREFIX_BUFFER, CONFERENCE_CREATE_RESPONSE_BUFFER); -lazy_static! { - pub static ref CONNECT_INITIAL: ConnectInitial = ConnectInitial { - calling_domain_selector: vec![0x01], - called_domain_selector: vec![0x01], - upward_flag: true, - target_parameters: DomainParameters { - max_channel_ids: 34, - max_user_ids: 2, - max_token_ids: 0, - num_priorities: 1, - min_throughput: 0, - max_height: 1, - max_mcs_pdu_size: 65535, - protocol_version: 2, - }, - min_parameters: DomainParameters { - max_channel_ids: 1, - max_user_ids: 1, - max_token_ids: 1, - num_priorities: 1, - min_throughput: 0, - max_height: 1, - max_mcs_pdu_size: 1056, - protocol_version: 2, - }, - max_parameters: DomainParameters { - max_channel_ids: 65535, - max_user_ids: 64535, - max_token_ids: 65535, - num_priorities: 1, - min_throughput: 0, - max_height: 1, - max_mcs_pdu_size: 65535, - protocol_version: 2, - }, - conference_create_request: CONFERENCE_CREATE_REQUEST.clone(), - }; - pub static ref CONNECT_RESPONSE: ConnectResponse = ConnectResponse { - called_connect_id: 0, - domain_parameters: DomainParameters { - max_channel_ids: 34, - max_user_ids: 3, - max_token_ids: 0, - num_priorities: 1, - min_throughput: 0, - max_height: 1, - max_mcs_pdu_size: 65528, - protocol_version: 2, - }, - conference_create_response: CONFERENCE_CREATE_RESPONSE.clone(), - }; -} +pub static CONNECT_INITIAL: LazyLock = LazyLock::new(|| ConnectInitial { + calling_domain_selector: vec![0x01], + called_domain_selector: vec![0x01], + upward_flag: true, + target_parameters: DomainParameters { + max_channel_ids: 34, + max_user_ids: 2, + max_token_ids: 0, + num_priorities: 1, + min_throughput: 0, + max_height: 1, + max_mcs_pdu_size: 65535, + protocol_version: 2, + }, + min_parameters: DomainParameters { + max_channel_ids: 1, + max_user_ids: 1, + max_token_ids: 1, + num_priorities: 1, + min_throughput: 0, + max_height: 1, + max_mcs_pdu_size: 1056, + protocol_version: 2, + }, + max_parameters: DomainParameters { + max_channel_ids: 65535, + max_user_ids: 64535, + max_token_ids: 65535, + num_priorities: 1, + min_throughput: 0, + max_height: 1, + max_mcs_pdu_size: 65535, + protocol_version: 2, + }, + conference_create_request: CONFERENCE_CREATE_REQUEST.clone(), +}); +pub static CONNECT_RESPONSE: LazyLock = LazyLock::new(|| ConnectResponse { + called_connect_id: 0, + domain_parameters: DomainParameters { + max_channel_ids: 34, + max_user_ids: 3, + max_token_ids: 0, + num_priorities: 1, + min_throughput: 0, + max_height: 1, + max_mcs_pdu_size: 65528, + protocol_version: 2, + }, + conference_create_response: CONFERENCE_CREATE_RESPONSE.clone(), +}); diff --git a/crates/ironrdp-testsuite-core/src/monitor_data.rs b/crates/ironrdp-testsuite-core/src/monitor_data.rs index b39aa67efe..1bd15bc0d2 100644 --- a/crates/ironrdp-testsuite-core/src/monitor_data.rs +++ b/crates/ironrdp-testsuite-core/src/monitor_data.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_pdu::gcc::{ClientMonitorData, Monitor, MonitorFlags}; -use lazy_static::lazy_static; pub const MONITOR_DATA_WITHOUT_MONITORS_BUFFER: [u8; 8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; @@ -9,24 +10,23 @@ pub const MONITOR_DATA_WITH_MONITORS_BUFFER: [u8; 48] = [ 0xff, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]; -lazy_static! { - pub static ref MONITOR_DATA_WITHOUT_MONITORS: ClientMonitorData = ClientMonitorData { monitors: Vec::new() }; - pub static ref MONITOR_DATA_WITH_MONITORS: ClientMonitorData = ClientMonitorData { - monitors: vec![ - Monitor { - left: 0, - top: 0, - right: 1919, - bottom: 1079, - flags: MonitorFlags::PRIMARY, - }, - Monitor { - left: -1280, - top: 0, - right: -1, - bottom: 1023, - flags: MonitorFlags::empty(), - } - ] - }; -} +pub static MONITOR_DATA_WITHOUT_MONITORS: LazyLock = + LazyLock::new(|| ClientMonitorData { monitors: Vec::new() }); +pub static MONITOR_DATA_WITH_MONITORS: LazyLock = LazyLock::new(|| ClientMonitorData { + monitors: vec![ + Monitor { + left: 0, + top: 0, + right: 1919, + bottom: 1079, + flags: MonitorFlags::PRIMARY, + }, + Monitor { + left: -1280, + top: 0, + right: -1, + bottom: 1023, + flags: MonitorFlags::empty(), + }, + ], +}); diff --git a/crates/ironrdp-testsuite-core/src/monitor_extended_data.rs b/crates/ironrdp-testsuite-core/src/monitor_extended_data.rs index 31a09e34ec..089f967958 100644 --- a/crates/ironrdp-testsuite-core/src/monitor_extended_data.rs +++ b/crates/ironrdp-testsuite-core/src/monitor_extended_data.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_pdu::gcc::{ClientMonitorExtendedData, ExtendedMonitorInfo, MonitorOrientation}; -use lazy_static::lazy_static; pub const MONITOR_DATA_WITHOUT_MONITORS_BUFFER: [u8; 12] = [0x00, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; @@ -10,11 +11,12 @@ pub const MONITOR_DATA_WITH_MONITORS_BUFFER: [u8; 52] = [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]; -lazy_static! { - pub static ref MONITOR_DATA_WITHOUT_MONITORS: ClientMonitorExtendedData = ClientMonitorExtendedData { - extended_monitors_info: Vec::new() - }; - pub static ref MONITOR_DATA_WITH_MONITORS: ClientMonitorExtendedData = ClientMonitorExtendedData { +pub static MONITOR_DATA_WITHOUT_MONITORS: LazyLock = + LazyLock::new(|| ClientMonitorExtendedData { + extended_monitors_info: Vec::new(), + }); +pub static MONITOR_DATA_WITH_MONITORS: LazyLock = + LazyLock::new(|| ClientMonitorExtendedData { extended_monitors_info: vec![ ExtendedMonitorInfo { physical_width: 0, @@ -29,7 +31,6 @@ lazy_static! { orientation: MonitorOrientation::Landscape, desktop_scale_factor: 0, device_scale_factor: 0, - } - ] - }; -} + }, + ], + }); diff --git a/crates/ironrdp-testsuite-core/src/multi_transport_channel_data.rs b/crates/ironrdp-testsuite-core/src/multi_transport_channel_data.rs index fc48514070..c950d89194 100644 --- a/crates/ironrdp-testsuite-core/src/multi_transport_channel_data.rs +++ b/crates/ironrdp-testsuite-core/src/multi_transport_channel_data.rs @@ -1,12 +1,12 @@ +use std::sync::LazyLock; + use ironrdp_pdu::gcc::{MultiTransportChannelData, MultiTransportFlags}; -use lazy_static::lazy_static; pub const SERVER_GCC_MULTI_TRANSPORT_CHANNEL_BLOCK_BUFFER: [u8; 4] = [0x01, 0x03, 0x00, 0x00]; -lazy_static! { - pub static ref SERVER_GCC_MULTI_TRANSPORT_CHANNEL_BLOCK: MultiTransportChannelData = MultiTransportChannelData { +pub static SERVER_GCC_MULTI_TRANSPORT_CHANNEL_BLOCK: LazyLock = + LazyLock::new(|| MultiTransportChannelData { flags: MultiTransportFlags::TRANSPORT_TYPE_UDP_FECR | MultiTransportFlags::TRANSPORT_TYPE_UDP_PREFERRED | MultiTransportFlags::SOFT_SYNC_TCP_TO_UDP, - }; -} + }); diff --git a/crates/ironrdp-testsuite-core/src/network_data.rs b/crates/ironrdp-testsuite-core/src/network_data.rs index 3c94472c67..ad8b419616 100644 --- a/crates/ironrdp-testsuite-core/src/network_data.rs +++ b/crates/ironrdp-testsuite-core/src/network_data.rs @@ -1,5 +1,6 @@ +use std::sync::LazyLock; + use ironrdp_pdu::gcc::{ChannelDef, ChannelName, ChannelOptions, ClientNetworkData, ServerNetworkData}; -use lazy_static::lazy_static; pub const CLIENT_NETWORK_DATA_WITH_CHANNELS_BUFFER: [u8; 40] = [ 0x03, 0x00, 0x00, 0x00, // channels count @@ -29,33 +30,32 @@ pub const SERVER_NETWORK_DATA_WITHOUT_CHANNELS_ID_BUFFER: [u8; 4] = [ 0x00, 0x00, // channels count ]; -lazy_static! { - pub static ref CLIENT_NETWORK_DATA_WITH_CHANNELS: ClientNetworkData = ClientNetworkData { - channels: vec![ - ChannelDef { - name: ChannelName::from_utf8("rdpdr").unwrap(), - options: ChannelOptions::INITIALIZED | ChannelOptions::COMPRESS_RDP, - }, - ChannelDef { - name: ChannelName::from_utf8("cliprdr").unwrap(), - options: ChannelOptions::INITIALIZED - | ChannelOptions::COMPRESS_RDP - | ChannelOptions::ENCRYPT_RDP - | ChannelOptions::SHOW_PROTOCOL, - }, - ChannelDef { - name: ChannelName::from_utf8("rdpsnd").unwrap(), - options: ChannelOptions::INITIALIZED | ChannelOptions::ENCRYPT_RDP, - }, - ], - }; - pub static ref SERVER_NETWORK_DATA_WITH_CHANNELS_ID: ServerNetworkData = ServerNetworkData { - io_channel: 1003, - channel_ids: vec![1004, 1005, 1006], - }; - pub static ref CLIENT_NETWORK_DATA_WITHOUT_CHANNELS: ClientNetworkData = ClientNetworkData { channels: Vec::new() }; - pub static ref SERVER_NETWORK_DATA_WITHOUT_CHANNELS_ID: ServerNetworkData = ServerNetworkData { - io_channel: 1003, - channel_ids: Vec::new(), - }; -} +pub static CLIENT_NETWORK_DATA_WITH_CHANNELS: LazyLock = LazyLock::new(|| ClientNetworkData { + channels: vec![ + ChannelDef { + name: ChannelName::from_utf8("rdpdr").unwrap(), + options: ChannelOptions::INITIALIZED | ChannelOptions::COMPRESS_RDP, + }, + ChannelDef { + name: ChannelName::from_utf8("cliprdr").unwrap(), + options: ChannelOptions::INITIALIZED + | ChannelOptions::COMPRESS_RDP + | ChannelOptions::ENCRYPT_RDP + | ChannelOptions::SHOW_PROTOCOL, + }, + ChannelDef { + name: ChannelName::from_utf8("rdpsnd").unwrap(), + options: ChannelOptions::INITIALIZED | ChannelOptions::ENCRYPT_RDP, + }, + ], +}); +pub static SERVER_NETWORK_DATA_WITH_CHANNELS_ID: LazyLock = LazyLock::new(|| ServerNetworkData { + io_channel: 1003, + channel_ids: vec![1004, 1005, 1006], +}); +pub static CLIENT_NETWORK_DATA_WITHOUT_CHANNELS: LazyLock = + LazyLock::new(|| ClientNetworkData { channels: Vec::new() }); +pub static SERVER_NETWORK_DATA_WITHOUT_CHANNELS_ID: LazyLock = LazyLock::new(|| ServerNetworkData { + io_channel: 1003, + channel_ids: Vec::new(), +}); diff --git a/crates/ironrdp-testsuite-core/src/rdp.rs b/crates/ironrdp-testsuite-core/src/rdp.rs index 488b2c90e7..d634b63290 100644 --- a/crates/ironrdp-testsuite-core/src/rdp.rs +++ b/crates/ironrdp-testsuite-core/src/rdp.rs @@ -1,3 +1,5 @@ +use std::sync::LazyLock; + use array_concat::{concat_arrays, concat_arrays_size}; use ironrdp_pdu::gcc; use ironrdp_pdu::rdp::finalization_messages::{ @@ -11,8 +13,7 @@ use ironrdp_pdu::rdp::server_license::{ LicenseErrorCode, LicenseHeader, LicensePdu, LicensingErrorMessage, LicensingStateTransition, PreambleFlags, PreambleType, PreambleVersion, }; -use ironrdp_pdu::rdp::{client_info, ClientInfoPdu}; -use lazy_static::lazy_static; +use ironrdp_pdu::rdp::{ClientInfoPdu, client_info}; use crate::capsets::{ CLIENT_DEMAND_ACTIVE, CLIENT_DEMAND_ACTIVE_BUFFER, SERVER_DEMAND_ACTIVE, SERVER_DEMAND_ACTIVE_BUFFER, @@ -46,7 +47,7 @@ pub const MONITOR_LAYOUT_HEADERS_BUFFER: [u8; 18] = [ 0xea, 0x03, 0x01, 0x00, // share id 0x00, // padding 0x01, // stream id - 0x30, 0x00, // uncompressed length + 0x2c, 0x00, // uncompressed length 0x37, // pdu type 0x00, // compression type 0x00, 0x00, // compressed length @@ -59,7 +60,7 @@ pub const CLIENT_SYNCHRONIZE_BUFFER: [u8; 22] = [ 0xea, 0x03, 0x01, 0x00, // share id 0x00, // padding 0x01, // stream id - 0x08, 0x00, // uncompressed length + 0x04, 0x00, // uncompressed length 0x1f, // pdu type 0x00, // compression type 0x00, 0x00, // compressed length @@ -74,7 +75,7 @@ pub const CONTROL_COOPERATE_BUFFER: [u8; 26] = [ 0xea, 0x03, 0x01, 0x00, // share id 0x00, // padding 0x01, // stream id - 0x0c, 0x00, // uncompressed length + 0x08, 0x00, // uncompressed length 0x14, // pdu type 0x00, // compression type 0x00, 0x00, // compressed length @@ -90,7 +91,7 @@ pub const CONTROL_REQUEST_CONTROL_BUFFER: [u8; 26] = [ 0xea, 0x03, 0x01, 0x00, // share id 0x00, // padding 0x01, // stream id - 0x0c, 0x00, // uncompressed length + 0x08, 0x00, // uncompressed length 0x14, // pdu type 0x00, // compression type 0x00, 0x00, // compressed length @@ -106,7 +107,7 @@ pub const SERVER_GRANTED_CONTROL_BUFFER: [u8; 26] = [ 0xea, 0x03, 0x01, 0x00, // share id 0x00, // padding 0x02, // stream id - 0x0c, 0x00, // uncompressed length + 0x08, 0x00, // uncompressed length 0x14, // pdu type 0x00, // compression type 0x00, 0x00, // compressed length @@ -122,7 +123,7 @@ pub const CLIENT_FONT_LIST_BUFFER: [u8; 26] = [ 0xea, 0x03, 0x01, 0x00, // share id 0x00, // padding 0x01, // stream id - 0x0c, 0x00, // uncompressed length + 0x08, 0x00, // uncompressed length 0x27, // pdu type 0x00, // compression type 0x00, 0x00, // compressed length @@ -139,7 +140,7 @@ pub const SERVER_FONT_MAP_BUFFER: [u8; 26] = [ 0xea, 0x03, 0x01, 0x00, // share id 0x00, // padding 0x02, // stream id - 0x0c, 0x00, // uncompressed length + 0x08, 0x00, // uncompressed length 0x28, // pdu type 0x00, // compression type 0x00, 0x00, // compressed length @@ -158,145 +159,143 @@ pub const SERVER_LICENSE_BUFFER: [u8; 20] = [ 0x00, 0x07, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, ]; -lazy_static! { - pub static ref CLIENT_INFO_PDU: ClientInfoPdu = ClientInfoPdu { - security_header: BasicSecurityHeader { - flags: BasicSecurityHeaderFlags::INFO_PKT, - }, - client_info: CLIENT_INFO_UNICODE.clone(), - }; - pub static ref SERVER_LICENSE_PDU: LicensePdu = { - let mut pdu = LicensingErrorMessage { - license_header: LicenseHeader { - security_header: BasicSecurityHeader { - flags: BasicSecurityHeaderFlags::LICENSE_PKT, - }, - preamble_message_type: PreambleType::ErrorAlert, - preamble_flags: PreambleFlags::empty(), - preamble_version: PreambleVersion::V3, - preamble_message_size: 0, +pub static CLIENT_INFO_PDU: LazyLock = LazyLock::new(|| ClientInfoPdu { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::INFO_PKT, + }, + client_info: CLIENT_INFO_UNICODE.clone(), +}); +pub static SERVER_LICENSE_PDU: LazyLock = LazyLock::new(|| { + let mut pdu = LicensingErrorMessage { + license_header: LicenseHeader { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::LICENSE_PKT, }, - error_code: LicenseErrorCode::StatusValidClient, - state_transition: LicensingStateTransition::NoTransition, - error_info: Vec::new(), - }; - pdu.license_header.preamble_message_size = pdu.size() as u16; - pdu.into() - }; - pub static ref SERVER_DEMAND_ACTIVE_PDU: ShareControlHeader = ShareControlHeader { - share_control_pdu: ShareControlPdu::ServerDemandActive(SERVER_DEMAND_ACTIVE.clone()), - pdu_source: 1002, - share_id: 66_538, - }; - pub static ref CLIENT_DEMAND_ACTIVE_PDU: ShareControlHeader = ShareControlHeader { - share_control_pdu: ShareControlPdu::ClientConfirmActive(CLIENT_DEMAND_ACTIVE.clone()), - pdu_source: 1007, - share_id: 66_538, + preamble_message_type: PreambleType::ErrorAlert, + preamble_flags: PreambleFlags::empty(), + preamble_version: PreambleVersion::V3, + preamble_message_size: 0, + }, + error_code: LicenseErrorCode::StatusValidClient, + state_transition: LicensingStateTransition::NoTransition, + error_info: Vec::new(), }; - pub static ref CLIENT_SYNCHRONIZE: ShareControlHeader = ShareControlHeader { - share_control_pdu: ShareControlPdu::Data(ShareDataHeader { - share_data_pdu: ShareDataPdu::Synchronize(SynchronizePdu { target_user_id: 0x03ea }), - stream_priority: StreamPriority::Low, - compression_flags: CompressionFlags::empty(), - compression_type: client_info::CompressionType::K8, + pdu.license_header.preamble_message_size = u16::try_from(pdu.size()).unwrap(); + pdu.into() +}); +pub static SERVER_DEMAND_ACTIVE_PDU: LazyLock = LazyLock::new(|| ShareControlHeader { + share_control_pdu: ShareControlPdu::ServerDemandActive(SERVER_DEMAND_ACTIVE.clone()), + pdu_source: 1002, + share_id: 66_538, +}); +pub static CLIENT_DEMAND_ACTIVE_PDU: LazyLock = LazyLock::new(|| ShareControlHeader { + share_control_pdu: ShareControlPdu::ClientConfirmActive(CLIENT_DEMAND_ACTIVE.clone()), + pdu_source: 1007, + share_id: 66_538, +}); +pub static CLIENT_SYNCHRONIZE: LazyLock = LazyLock::new(|| ShareControlHeader { + share_control_pdu: ShareControlPdu::Data(ShareDataHeader { + share_data_pdu: ShareDataPdu::Synchronize(SynchronizePdu { target_user_id: 0x03ea }), + stream_priority: StreamPriority::Low, + compression_flags: CompressionFlags::empty(), + compression_type: client_info::CompressionType::K8, + }), + pdu_source: 1007, + share_id: 66_538, +}); +pub static CONTROL_COOPERATE: LazyLock = LazyLock::new(|| ShareControlHeader { + share_control_pdu: ShareControlPdu::Data(ShareDataHeader { + share_data_pdu: ShareDataPdu::Control(ControlPdu { + action: ControlAction::Cooperate, + grant_id: 0, + control_id: 0, }), - pdu_source: 1007, - share_id: 66_538, - }; - pub static ref CONTROL_COOPERATE: ShareControlHeader = ShareControlHeader { - share_control_pdu: ShareControlPdu::Data(ShareDataHeader { - share_data_pdu: ShareDataPdu::Control(ControlPdu { - action: ControlAction::Cooperate, - grant_id: 0, - control_id: 0, - }), - stream_priority: StreamPriority::Low, - compression_flags: CompressionFlags::empty(), - compression_type: client_info::CompressionType::K8, + stream_priority: StreamPriority::Low, + compression_flags: CompressionFlags::empty(), + compression_type: client_info::CompressionType::K8, + }), + pdu_source: 1007, + share_id: 66_538, +}); +pub static CONTROL_REQUEST_CONTROL: LazyLock = LazyLock::new(|| ShareControlHeader { + share_control_pdu: ShareControlPdu::Data(ShareDataHeader { + share_data_pdu: ShareDataPdu::Control(ControlPdu { + action: ControlAction::RequestControl, + grant_id: 0, + control_id: 0, }), - pdu_source: 1007, - share_id: 66_538, - }; - pub static ref CONTROL_REQUEST_CONTROL: ShareControlHeader = ShareControlHeader { - share_control_pdu: ShareControlPdu::Data(ShareDataHeader { - share_data_pdu: ShareDataPdu::Control(ControlPdu { - action: ControlAction::RequestControl, - grant_id: 0, - control_id: 0, - }), - stream_priority: StreamPriority::Low, - compression_flags: CompressionFlags::empty(), - compression_type: client_info::CompressionType::K8, - }), - pdu_source: 1007, - share_id: 66_538, - }; - pub static ref SERVER_GRANTED_CONTROL: ShareControlHeader = ShareControlHeader { - share_control_pdu: ShareControlPdu::Data(ShareDataHeader { - share_data_pdu: ShareDataPdu::Control(ControlPdu { - action: ControlAction::GrantedControl, - grant_id: 1007, - control_id: 1002, - }), - stream_priority: StreamPriority::Medium, - compression_flags: CompressionFlags::empty(), - compression_type: client_info::CompressionType::K8, + stream_priority: StreamPriority::Low, + compression_flags: CompressionFlags::empty(), + compression_type: client_info::CompressionType::K8, + }), + pdu_source: 1007, + share_id: 66_538, +}); +pub static SERVER_GRANTED_CONTROL: LazyLock = LazyLock::new(|| ShareControlHeader { + share_control_pdu: ShareControlPdu::Data(ShareDataHeader { + share_data_pdu: ShareDataPdu::Control(ControlPdu { + action: ControlAction::GrantedControl, + grant_id: 1007, + control_id: 1002, }), - pdu_source: 1002, - share_id: 66_538, - }; - pub static ref CLIENT_FONT_LIST: ShareControlHeader = ShareControlHeader { - share_control_pdu: ShareControlPdu::Data(ShareDataHeader { - share_data_pdu: ShareDataPdu::FontList(FontPdu { - number: 0, - total_number: 0, - flags: SequenceFlags::FIRST | SequenceFlags::LAST, - entry_size: 50, - }), - stream_priority: StreamPriority::Low, - compression_flags: CompressionFlags::empty(), - compression_type: client_info::CompressionType::K8, + stream_priority: StreamPriority::Medium, + compression_flags: CompressionFlags::empty(), + compression_type: client_info::CompressionType::K8, + }), + pdu_source: 1002, + share_id: 66_538, +}); +pub static CLIENT_FONT_LIST: LazyLock = LazyLock::new(|| ShareControlHeader { + share_control_pdu: ShareControlPdu::Data(ShareDataHeader { + share_data_pdu: ShareDataPdu::FontList(FontPdu { + number: 0, + total_number: 0, + flags: SequenceFlags::FIRST | SequenceFlags::LAST, + entry_size: 50, }), - pdu_source: 1007, - share_id: 66_538, - }; - pub static ref SERVER_FONT_MAP: ShareControlHeader = ShareControlHeader { - share_control_pdu: ShareControlPdu::Data(ShareDataHeader { - share_data_pdu: ShareDataPdu::FontMap(FontPdu { - number: 0, - total_number: 0, - flags: SequenceFlags::FIRST | SequenceFlags::LAST, - entry_size: 4, - }), - stream_priority: StreamPriority::Medium, - compression_flags: CompressionFlags::empty(), - compression_type: client_info::CompressionType::K8, + stream_priority: StreamPriority::Low, + compression_flags: CompressionFlags::empty(), + compression_type: client_info::CompressionType::K8, + }), + pdu_source: 1007, + share_id: 66_538, +}); +pub static SERVER_FONT_MAP: LazyLock = LazyLock::new(|| ShareControlHeader { + share_control_pdu: ShareControlPdu::Data(ShareDataHeader { + share_data_pdu: ShareDataPdu::FontMap(FontPdu { + number: 0, + total_number: 0, + flags: SequenceFlags::FIRST | SequenceFlags::LAST, + entry_size: 4, }), - pdu_source: 1002, - share_id: 66_538, - }; - pub static ref MONITOR_LAYOUT_PDU: ShareControlHeader = ShareControlHeader { - share_control_pdu: ShareControlPdu::Data(ShareDataHeader { - share_data_pdu: ShareDataPdu::MonitorLayout(MonitorLayoutPdu { - monitors: crate::monitor_data::MONITOR_DATA_WITH_MONITORS.monitors.clone(), - }), - stream_priority: StreamPriority::Low, - compression_flags: CompressionFlags::empty(), - compression_type: client_info::CompressionType::K8, + stream_priority: StreamPriority::Medium, + compression_flags: CompressionFlags::empty(), + compression_type: client_info::CompressionType::K8, + }), + pdu_source: 1002, + share_id: 66_538, +}); +pub static MONITOR_LAYOUT_PDU: LazyLock = LazyLock::new(|| ShareControlHeader { + share_control_pdu: ShareControlPdu::Data(ShareDataHeader { + share_data_pdu: ShareDataPdu::MonitorLayout(MonitorLayoutPdu { + monitors: crate::monitor_data::MONITOR_DATA_WITH_MONITORS.monitors.clone(), }), - pdu_source: 1007, - share_id: 66_538, - }; - pub static ref MONITOR_LAYOUT_PDU_BUFFER: Vec = { - let mut buffer = MONITOR_LAYOUT_HEADERS_BUFFER.to_vec(); - buffer.extend( - MONITOR_DATA_WITH_MONITORS_BUFFER - .to_vec() - .split_off(gcc::MONITOR_FLAGS_SIZE), - ); - buffer - }; -} + stream_priority: StreamPriority::Low, + compression_flags: CompressionFlags::empty(), + compression_type: client_info::CompressionType::K8, + }), + pdu_source: 1007, + share_id: 66_538, +}); +pub static MONITOR_LAYOUT_PDU_BUFFER: LazyLock> = LazyLock::new(|| { + let mut buffer = MONITOR_LAYOUT_HEADERS_BUFFER.to_vec(); + buffer.extend( + MONITOR_DATA_WITH_MONITORS_BUFFER + .to_vec() + .split_off(gcc::MONITOR_FLAGS_SIZE), + ); + buffer +}); pub const CLIENT_INFO_PDU_BUFFER: [u8; concat_arrays_size!( CLIENT_INFO_PDU_SECURITY_HEADER_BUFFER, diff --git a/crates/ironrdp-testsuite-core/src/security_data.rs b/crates/ironrdp-testsuite-core/src/security_data.rs index 96a1a2f40c..87a65c8ea2 100644 --- a/crates/ironrdp-testsuite-core/src/security_data.rs +++ b/crates/ironrdp-testsuite-core/src/security_data.rs @@ -1,6 +1,7 @@ +use std::sync::LazyLock; + use array_concat::concat_arrays; use ironrdp_pdu::gcc::{ClientSecurityData, EncryptionLevel, EncryptionMethod, ServerSecurityData}; -use lazy_static::lazy_static; pub const CLIENT_SECURITY_DATA_BUFFER: [u8; 8] = [ 0x1b, 0x00, 0x00, 0x00, // encryption methods @@ -35,35 +36,36 @@ pub const SERVER_CERT_BUFFER: [u8; 184] = [ 0x6c, 0xd6, 0x76, 0x84, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]; -lazy_static! { - pub static ref CLIENT_SECURITY_DATA: ClientSecurityData = ClientSecurityData { - encryption_methods: EncryptionMethod::BIT_40 - | EncryptionMethod::BIT_128 - | EncryptionMethod::BIT_56 - | EncryptionMethod::FIPS, - ext_encryption_methods: 0, - }; - pub static ref SERVER_SECURITY_DATA_WITHOUT_OPTIONAL_FIELDS: ServerSecurityData = ServerSecurityData { +pub static CLIENT_SECURITY_DATA: LazyLock = LazyLock::new(|| ClientSecurityData { + encryption_methods: EncryptionMethod::BIT_40 + | EncryptionMethod::BIT_128 + | EncryptionMethod::BIT_56 + | EncryptionMethod::FIPS, + ext_encryption_methods: 0, +}); +pub static SERVER_SECURITY_DATA_WITHOUT_OPTIONAL_FIELDS: LazyLock = + LazyLock::new(|| ServerSecurityData { encryption_method: EncryptionMethod::empty(), encryption_level: EncryptionLevel::None, server_random: None, server_cert: Vec::new(), - }; - pub static ref SERVER_SECURITY_DATA_WITH_OPTIONAL_FIELDS: ServerSecurityData = ServerSecurityData { + }); +pub static SERVER_SECURITY_DATA_WITH_OPTIONAL_FIELDS: LazyLock = + LazyLock::new(|| ServerSecurityData { encryption_method: EncryptionMethod::BIT_128, encryption_level: EncryptionLevel::ClientCompatible, server_random: Some(SERVER_RANDOM_BUFFER), server_cert: SERVER_CERT_BUFFER.to_vec(), - }; - pub static ref SERVER_SECURITY_DATA_WITH_MISMATCH_OF_REQUIRED_AND_OPTIONAL_FIELDS: ServerSecurityData = - ServerSecurityData { - encryption_method: EncryptionMethod::empty(), - encryption_level: EncryptionLevel::None, - server_random: Some(SERVER_RANDOM_BUFFER), - server_cert: SERVER_CERT_BUFFER.to_vec(), - }; -} + }); +pub static SERVER_SECURITY_DATA_WITH_MISMATCH_OF_REQUIRED_AND_OPTIONAL_FIELDS: LazyLock = + LazyLock::new(|| ServerSecurityData { + encryption_method: EncryptionMethod::empty(), + encryption_level: EncryptionLevel::None, + server_random: Some(SERVER_RANDOM_BUFFER), + server_cert: SERVER_CERT_BUFFER.to_vec(), + }); +#[expect(clippy::as_conversions, reason = "must be const casts")] pub const SERVER_SECURITY_DATA_WITH_OPTIONAL_FIELDS_BUFFER: [u8; 232] = concat_arrays!( SERVER_SECURITY_DATA_WITH_OPTIONAL_FIELDS_PREFIX_BUFFER, (SERVER_RANDOM_BUFFER.len() as u32).to_le_bytes(), @@ -72,6 +74,7 @@ pub const SERVER_SECURITY_DATA_WITH_OPTIONAL_FIELDS_BUFFER: [u8; 232] = concat_a SERVER_CERT_BUFFER ); +#[expect(clippy::as_conversions, reason = "must be const casts")] pub const SERVER_SECURITY_DATA_WITH_INVALID_SERVER_RANDOM_BUFFER: [u8; 233] = concat_arrays!( SERVER_SECURITY_DATA_WITH_OPTIONAL_FIELDS_PREFIX_BUFFER, (SERVER_RANDOM_BUFFER.len() as u32 + 1).to_le_bytes(), diff --git a/crates/ironrdp-testsuite-core/test_data/fuzz_regression/bulk_decompress_mppc/seed-mode-rdp4-empty.bin b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/bulk_decompress_mppc/seed-mode-rdp4-empty.bin new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/ironrdp-testsuite-core/test_data/fuzz_regression/bulk_decompress_ncrush/seed-empty.bin b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/bulk_decompress_ncrush/seed-empty.bin new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/ironrdp-testsuite-core/test_data/fuzz_regression/bulk_decompress_xcrush/seed-empty.bin b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/bulk_decompress_xcrush/seed-empty.bin new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/ironrdp-testsuite-core/test_data/fuzz_regression/bulk_round_trip/crash-02903c340978ae540ae7fcf77c58669346a0026c b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/bulk_round_trip/crash-02903c340978ae540ae7fcf77c58669346a0026c new file mode 100644 index 0000000000..362323609a Binary files /dev/null and b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/bulk_round_trip/crash-02903c340978ae540ae7fcf77c58669346a0026c differ diff --git a/crates/ironrdp-testsuite-core/test_data/fuzz_regression/bulk_round_trip/seed-empty.bin b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/bulk_round_trip/seed-empty.bin new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/ironrdp-testsuite-core/test_data/fuzz_regression/egfx_round_trip/seed-empty.bin b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/egfx_round_trip/seed-empty.bin new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/ironrdp-testsuite-core/test_data/fuzz_regression/pdu_round_trip/crash-issue-1292-bitmapcache-v3-unreachable.bin b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/pdu_round_trip/crash-issue-1292-bitmapcache-v3-unreachable.bin new file mode 100644 index 0000000000..57fee86353 Binary files /dev/null and b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/pdu_round_trip/crash-issue-1292-bitmapcache-v3-unreachable.bin differ diff --git a/crates/ironrdp-testsuite-core/test_data/fuzz_regression/pdu_round_trip/seed-empty.bin b/crates/ironrdp-testsuite-core/test_data/fuzz_regression/pdu_round_trip/seed-empty.bin new file mode 100644 index 0000000000..e69de29bb2 diff --git a/crates/ironrdp-testsuite-core/tests/cfg.rs b/crates/ironrdp-testsuite-core/tests/cfg.rs new file mode 100644 index 0000000000..16a57b55c4 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/cfg.rs @@ -0,0 +1,55 @@ +use core::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use ironrdp_cfg::{ParseTargetAddrError, TargetAddr, TargetHost}; +use rstest::rstest; + +// -- TargetAddr::from_str ------------------------------------------------------- + +#[rstest] +// hostname +#[case("rdp.example.com", TargetHost::Domain("rdp.example.com".to_owned()), None)] +#[case("rdp.example.com:3389", TargetHost::Domain("rdp.example.com".to_owned()), Some(3389))] +// IPv4 +#[case("192.168.1.1", TargetHost::Ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), None)] +#[case( + "192.168.1.1:3389", + TargetHost::Ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))), + Some(3389) +)] +// IPv6 (always bracketed in .rdp format) +#[case("[::1]", TargetHost::Ip(IpAddr::V6(Ipv6Addr::LOCALHOST)), None)] +#[case("[::1]:3389", TargetHost::Ip(IpAddr::V6(Ipv6Addr::LOCALHOST)), Some(3389))] +#[case("[2001:db8::1]:443", TargetHost::Ip(IpAddr::V6("2001:db8::1".parse().unwrap())), Some(443))] +// Unbracketed IPv6 — no port, must not misparse trailing segment as port +#[case("::1", TargetHost::Ip(IpAddr::V6(Ipv6Addr::LOCALHOST)), None)] +#[case("fe80::1", TargetHost::Ip(IpAddr::V6("fe80::1".parse().unwrap())), None)] +fn parse_valid(#[case] input: &str, #[case] expected_host: TargetHost, #[case] expected_port: Option) { + let addr: TargetAddr = input.parse().unwrap(); + assert_eq!(addr.host, expected_host); + assert_eq!(addr.port, expected_port); +} + +#[rstest] +#[case("[::1", ParseTargetAddrError::UnclosedBracket)] +#[case("[not-ipv6]", ParseTargetAddrError::InvalidIpv6Addr)] +#[case("[127.0.0.1]", ParseTargetAddrError::InvalidIpv6Addr)] +#[case("[::1]:99999", ParseTargetAddrError::InvalidPort)] +#[case("[::1]garbage", ParseTargetAddrError::UnexpectedTrailing)] +#[case("rdp.example.com:99999", ParseTargetAddrError::InvalidPort)] +fn parse_invalid(#[case] input: &str, #[case] expected: ParseTargetAddrError) { + assert_eq!(input.parse::().unwrap_err(), expected); +} + +// -- TargetAddr::fmt ------------------------------------------------------------ + +/// IPv6 hosts must be re-bracketed on display; other hosts are written as-is. +#[rstest] +#[case("[::1]:3389", "[::1]:3389")] +#[case("[::1]", "[::1]")] +#[case("192.168.1.1:3389", "192.168.1.1:3389")] +#[case("192.168.1.1", "192.168.1.1")] +#[case("rdp.example.com", "rdp.example.com")] +fn display_roundtrip(#[case] input: &str, #[case] expected: &str) { + let addr: TargetAddr = input.parse().unwrap(); + assert_eq!(addr.to_string(), expected); +} diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/delayed_rendering.rs b/crates/ironrdp-testsuite-core/tests/clipboard/delayed_rendering.rs new file mode 100644 index 0000000000..d730488bcd --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/clipboard/delayed_rendering.rs @@ -0,0 +1,364 @@ +use std::sync::{Arc, Mutex}; + +use ironrdp_cliprdr::CliprdrClient; +use ironrdp_cliprdr::backend::CliprdrBackend; +use ironrdp_cliprdr::pdu::{ + Capabilities, ClipboardFormat, ClipboardFormatId, ClipboardFormatName, ClipboardGeneralCapabilityFlags, + ClipboardPdu, ClipboardProtocolVersion, FileDescriptor, FormatListResponse, +}; +use ironrdp_core::AsAny; +use ironrdp_svc::SvcProcessor as _; + +/// Tracks callbacks invoked on the backend for verification in tests +#[derive(Debug, Default, Clone)] +struct CallbackTracker { + remote_copy_calls: Vec>, + remote_file_list_calls: Vec>, +} + +/// Mock backend for testing delayed rendering behavior +#[derive(Debug)] +struct MockBackend { + temp_dir: String, + tracker: Arc>, +} + +impl CliprdrBackend for MockBackend { + fn temporary_directory(&self) -> &str { + &self.temp_dir + } + + fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags { + ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES + | ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + | ClipboardGeneralCapabilityFlags::FILECLIP_NO_FILE_PATHS + } + + fn on_ready(&mut self) {} + fn on_request_format_list(&mut self) {} + fn on_process_negotiated_capabilities(&mut self, _capabilities: ClipboardGeneralCapabilityFlags) {} + fn on_remote_copy(&mut self, available_formats: &[ClipboardFormat]) { + self.tracker + .lock() + .unwrap() + .remote_copy_calls + .push(available_formats.to_vec()); + } + fn on_format_data_request(&mut self, _request: ironrdp_cliprdr::pdu::FormatDataRequest) {} + fn on_format_data_response(&mut self, _response: ironrdp_cliprdr::pdu::FormatDataResponse<'_>) {} + fn on_file_contents_request(&mut self, _request: ironrdp_cliprdr::pdu::FileContentsRequest) {} + fn on_file_contents_response(&mut self, _response: ironrdp_cliprdr::pdu::FileContentsResponse<'_>) {} + fn on_lock(&mut self, _data_id: ironrdp_cliprdr::pdu::LockDataId) {} + fn on_unlock(&mut self, _data_id: ironrdp_cliprdr::pdu::LockDataId) {} + fn on_remote_file_list(&mut self, files: &[FileDescriptor], _clip_data_id: Option) { + self.tracker.lock().unwrap().remote_file_list_calls.push(files.to_vec()); + } + + fn now_ms(&self) -> u64 { + // Tests use real time; lock timeouts are not exercised here. + use std::time::Instant; + static EPOCH: std::sync::OnceLock = std::sync::OnceLock::new(); + u64::try_from(EPOCH.get_or_init(Instant::now).elapsed().as_millis()).unwrap_or(u64::MAX) + } + + fn elapsed_ms(&self, since: u64) -> u64 { + self.now_ms().saturating_sub(since) + } +} + +impl AsAny for MockBackend { + fn as_any(&self) -> &dyn core::any::Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { + self + } +} + +/// Drive a CliprdrClient through the initialization handshake to Ready state. +/// +/// Simulates: Server Capabilities -> Monitor Ready -> client initiate_copy -> +/// FormatListResponse::Ok, which transitions the client from Initialization to Ready. +fn drive_to_ready(cliprdr: &mut CliprdrClient) { + // Server sends Capabilities with file transfer support + let caps_pdu = ClipboardPdu::Capabilities(Capabilities::new( + ClipboardProtocolVersion::V2, + ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES + | ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + | ClipboardGeneralCapabilityFlags::FILECLIP_NO_FILE_PATHS, + )); + let caps_bytes = ironrdp_core::encode_vec(&caps_pdu).unwrap(); + cliprdr.process(&caps_bytes).unwrap(); + + // Server sends Monitor Ready (triggers backend.on_request_format_list) + let monitor_pdu = ClipboardPdu::MonitorReady; + let monitor_bytes = ironrdp_core::encode_vec(&monitor_pdu).unwrap(); + cliprdr.process(&monitor_bytes).unwrap(); + + // Client responds with initiate_copy (sends Caps + TempDir + FormatList) + let formats = vec![ClipboardFormat::new(ClipboardFormatId::new(13))]; + cliprdr.initiate_copy(&formats).unwrap(); + + // Server accepts with FormatListResponse::Ok -> transitions to Ready + let resp_pdu = ClipboardPdu::FormatListResponse(FormatListResponse::Ok); + let resp_bytes = ironrdp_core::encode_vec(&resp_pdu).unwrap(); + cliprdr.process(&resp_bytes).unwrap(); +} + +fn new_cliprdr() -> CliprdrClient { + let backend = Box::new(MockBackend { + temp_dir: "/tmp/test".to_owned(), + tracker: Arc::new(Mutex::new(CallbackTracker::default())), + }); + CliprdrClient::new(backend) +} + +fn new_ready_cliprdr() -> CliprdrClient { + let mut cliprdr = new_cliprdr(); + drive_to_ready(&mut cliprdr); + cliprdr +} + +#[test] +fn initiate_file_copy_requires_ready_state() { + // [MS-RDPECLIP] 2.2.5.2 - initiate_file_copy returns Err when not in Ready state + let mut cliprdr = new_cliprdr(); + + let files = vec![ + FileDescriptor::new("test.txt").with_file_size(1024), + FileDescriptor::new("data.bin").with_file_size(2048), + ]; + + let result = cliprdr.initiate_file_copy(files); + assert!(result.is_err(), "Should return Err when not in Ready state"); +} + +#[test] +fn initiate_paste_requires_ready_state() { + // [MS-RDPECLIP] 1.3.1.4 - Verify that initiate_paste requires Ready state + // Per "Delayed Rendering", format data is only requested when user pastes (and state is Ready) + + let mut cliprdr = new_cliprdr(); + + let format_id = ClipboardFormatId::new(0xC0BC); + + let result = cliprdr.initiate_paste(format_id); + assert!(result.is_err(), "Should return Err when not in Ready state"); +} + +#[test] +fn initiate_copy_api() { + // Verify that initiate_copy sends FormatList (possibly with initialization PDUs) + + let mut cliprdr = new_cliprdr(); + + let formats = vec![ClipboardFormat::new(ClipboardFormatId::new(13))]; // CF_UNICODETEXT + + // Initiate copy - should send initialization PDUs + FormatList on first call + let result = cliprdr.initiate_copy(&formats); + assert!(result.is_ok(), "Should successfully initiate copy"); + + let output: Vec<_> = result.unwrap().into(); + // Should send Capabilities + TemporaryDirectory + FormatList during initialization + assert!( + !output.is_empty(), + "Should send at least FormatList (may include initialization PDUs)" + ); +} + +#[test] +fn initiate_copy_clears_file_list_state() { + // Verify that initiate_copy clears any previous file list state + // This ensures that file list state doesn't leak between operations + + let mut cliprdr = new_ready_cliprdr(); + + // First, initiate file copy (now in Ready state, so this succeeds) + let files = vec![FileDescriptor::new("test.txt").with_file_size(1024)]; + + cliprdr.initiate_file_copy(files).unwrap(); + + // Now initiate regular copy (should clear file list state) + let text_formats = vec![ClipboardFormat::new(ClipboardFormatId::new(13))]; // CF_UNICODETEXT + let result = cliprdr.initiate_copy(&text_formats); + assert!(result.is_ok(), "Should allow regular copy after file copy"); +} + +#[test] +fn file_descriptor_round_trip() { + // Test that FileDescriptor can be encoded and decoded correctly + // This is essential for file list metadata exchange + + let original = FileDescriptor::new("test.txt") + .with_last_write_time(129010042240261384) + .with_file_size(1024); + + let encoded = ironrdp_core::encode_vec(&original).unwrap(); + let decoded: FileDescriptor = ironrdp_core::decode(&encoded).unwrap(); + + assert_eq!(decoded.name, original.name); + assert_eq!(decoded.attributes, original.attributes); + assert_eq!(decoded.last_write_time, original.last_write_time); + assert_eq!(decoded.file_size, original.file_size); +} + +#[test] +fn file_list_format_name_constant() { + // [MS-RDPECLIP] 1.3.1.2 - Verify that the FileGroupDescriptorW format name constant is correct + // The format name is constant across all implementations, only format ID varies + + assert_eq!( + ClipboardFormatName::FILE_LIST.value(), + "FileGroupDescriptorW", + "FILE_LIST constant should be FileGroupDescriptorW per MS-RDPECLIP 1.3.1.2" + ); +} + +#[test] +fn empty_file_list() { + // Verify that empty file lists are handled gracefully in Ready state + + let mut cliprdr = new_ready_cliprdr(); + + let files = Vec::new(); // Empty file list + + let result = cliprdr.initiate_file_copy(files); + assert!(result.is_ok(), "Should handle empty file list gracefully"); +} + +#[test] +fn file_descriptor_minimal_metadata() { + // Test file descriptor with only filename (no attributes, size, or timestamp) + // Per spec, these fields are optional + + let file_desc = FileDescriptor::new("minimal.txt"); + + let encoded = ironrdp_core::encode_vec(&file_desc).unwrap(); + let decoded: FileDescriptor = ironrdp_core::decode(&encoded).unwrap(); + + assert_eq!(decoded.name, "minimal.txt"); + assert!(decoded.attributes.is_none()); + assert!(decoded.last_write_time.is_none()); + assert!(decoded.file_size.is_none()); +} + +#[test] +fn file_descriptor_259_char_name_accepted() { + // [MS-RDPECLIP] 2.2.5.2.3.1 - Verify that exactly 259 character filename is valid + // The fileName field is 520 bytes = 260 Unicode characters (including null terminator) + // Maximum content length is 259 characters + + let mut cliprdr = new_ready_cliprdr(); + + // Create a filename with exactly 259 characters (valid boundary) + let name = "a".repeat(259); + let files = vec![FileDescriptor::new(name).with_file_size(1024)]; + + let result = cliprdr.initiate_file_copy(files); + assert!(result.is_ok(), "Should accept 259 character filename"); + + // In Ready state, a valid file should produce a FormatList message + let output: Vec<_> = result.unwrap().into(); + assert!(!output.is_empty(), "Should send FormatList for valid file"); +} + +#[test] +fn file_descriptor_260_char_name_rejected() { + // [MS-RDPECLIP] 2.2.5.2.3.1 - Verify that 260 character filename is rejected + // Maximum length is 259 characters (leaving room for null terminator in 260-character field) + + let mut cliprdr = new_ready_cliprdr(); + + // Create a filename with 260 characters (invalid - exceeds max) + let name = "a".repeat(260); + let files = vec![FileDescriptor::new(name).with_file_size(1024)]; + + // Should succeed but skip the invalid descriptor + let result = cliprdr.initiate_file_copy(files); + assert!(result.is_ok(), "Should handle 260 character filename gracefully"); + + // The invalid descriptor is skipped with a warning; an empty file list is still sent +} + +#[test] +fn file_descriptor_empty_name_rejected() { + // [MS-RDPECLIP] 2.2.5.2.3.1 - Verify that empty filename is rejected + // File names must not be empty per spec + + let mut cliprdr = new_ready_cliprdr(); + + let files = vec![FileDescriptor::new("").with_file_size(1024)]; + + // Should succeed but skip the invalid descriptor + let result = cliprdr.initiate_file_copy(files); + assert!(result.is_ok(), "Should handle empty filename gracefully"); +} + +#[test] +fn file_descriptor_mixed_valid_invalid_names() { + // Test that valid descriptors are accepted while invalid ones are skipped + // when processing a file list with mixed validity + + let mut cliprdr = new_ready_cliprdr(); + + let files = vec![ + FileDescriptor::new("valid.txt").with_file_size(100), // Valid + FileDescriptor::new("").with_file_size(200), // Invalid - empty + FileDescriptor::new("also_valid.doc").with_file_size(300), // Valid + FileDescriptor::new("x".repeat(260)).with_file_size(400), // Invalid - too long + ]; + + // Should succeed - valid files accepted, invalid ones skipped + let result = cliprdr.initiate_file_copy(files); + assert!( + result.is_ok(), + "Should process file list with mixed valid/invalid descriptors" + ); + + // Two valid descriptors should be stored and a FormatList sent + let output: Vec<_> = result.unwrap().into(); + assert!(!output.is_empty(), "Should send FormatList with valid descriptors"); +} + +#[test] +fn initiate_file_copy_requires_stream_fileclip_enabled() { + // Verify that initiate_file_copy returns Err when STREAM_FILECLIP_ENABLED is not negotiated. + // This can happen when the server does not advertise file transfer support. + + let backend = Box::new(MockBackend { + temp_dir: "/tmp/test".to_owned(), + tracker: Arc::new(Mutex::new(CallbackTracker::default())), + }); + + let mut cliprdr = CliprdrClient::new(backend); + + // Drive to Ready but with server capabilities that lack STREAM_FILECLIP_ENABLED + let caps_pdu = ClipboardPdu::Capabilities(Capabilities::new( + ClipboardProtocolVersion::V2, + // Only USE_LONG_FORMAT_NAMES - no STREAM_FILECLIP_ENABLED + ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES, + )); + let caps_bytes = ironrdp_core::encode_vec(&caps_pdu).unwrap(); + cliprdr.process(&caps_bytes).unwrap(); + + let monitor_pdu = ClipboardPdu::MonitorReady; + let monitor_bytes = ironrdp_core::encode_vec(&monitor_pdu).unwrap(); + cliprdr.process(&monitor_bytes).unwrap(); + + let formats = vec![ClipboardFormat::new(ClipboardFormatId::new(13))]; + cliprdr.initiate_copy(&formats).unwrap(); + + let resp_pdu = ClipboardPdu::FormatListResponse(FormatListResponse::Ok); + let resp_bytes = ironrdp_core::encode_vec(&resp_pdu).unwrap(); + cliprdr.process(&resp_bytes).unwrap(); + + // Now in Ready state, but without STREAM_FILECLIP_ENABLED + let files = vec![FileDescriptor::new("test.txt").with_file_size(1024)]; + + let result = cliprdr.initiate_file_copy(files); + assert!( + result.is_err(), + "Should return Err when STREAM_FILECLIP_ENABLED is not negotiated" + ); +} diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/delayed_rendering_integration.rs b/crates/ironrdp-testsuite-core/tests/clipboard/delayed_rendering_integration.rs new file mode 100644 index 0000000000..c5528a8ecb --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/clipboard/delayed_rendering_integration.rs @@ -0,0 +1,497 @@ +use std::sync::{Arc, Mutex}; + +use ironrdp_cliprdr::CliprdrClient; +use ironrdp_cliprdr::backend::CliprdrBackend; +use ironrdp_cliprdr::pdu::{ + Capabilities, CapabilitySet, ClipboardFileAttributes, ClipboardFormat, ClipboardFormatId, ClipboardFormatName, + ClipboardGeneralCapabilityFlags, ClipboardPdu, ClipboardProtocolVersion, FileDescriptor, FormatDataResponse, + FormatList, FormatListResponse, GeneralCapabilitySet, PackedFileList, +}; +use ironrdp_core::AsAny; +use ironrdp_svc::SvcProcessor as _; + +/// Tracks callbacks invoked on the backend for verification in integration tests +#[derive(Debug, Default, Clone)] +struct IntegrationCallbackTracker { + remote_copy_calls: Vec>, + remote_file_list_calls: Vec>, +} + +/// Mock backend for integration testing that tracks all callbacks +#[derive(Debug)] +struct IntegrationMockBackend { + temp_dir: String, + tracker: Arc>, +} + +impl CliprdrBackend for IntegrationMockBackend { + fn temporary_directory(&self) -> &str { + &self.temp_dir + } + + fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags { + ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES + | ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + | ClipboardGeneralCapabilityFlags::FILECLIP_NO_FILE_PATHS + } + + fn on_ready(&mut self) {} + fn on_request_format_list(&mut self) {} + fn on_process_negotiated_capabilities(&mut self, _capabilities: ClipboardGeneralCapabilityFlags) {} + + fn on_remote_copy(&mut self, available_formats: &[ClipboardFormat]) { + self.tracker + .lock() + .unwrap() + .remote_copy_calls + .push(available_formats.to_vec()); + } + + fn on_format_data_request(&mut self, _request: ironrdp_cliprdr::pdu::FormatDataRequest) {} + fn on_format_data_response(&mut self, _response: FormatDataResponse<'_>) {} + fn on_file_contents_request(&mut self, _request: ironrdp_cliprdr::pdu::FileContentsRequest) {} + fn on_file_contents_response(&mut self, _response: ironrdp_cliprdr::pdu::FileContentsResponse<'_>) {} + fn on_lock(&mut self, _data_id: ironrdp_cliprdr::pdu::LockDataId) {} + fn on_unlock(&mut self, _data_id: ironrdp_cliprdr::pdu::LockDataId) {} + + fn on_remote_file_list(&mut self, files: &[FileDescriptor], _clip_data_id: Option) { + self.tracker.lock().unwrap().remote_file_list_calls.push(files.to_vec()); + } + + fn now_ms(&self) -> u64 { + // Tests use real time; lock timeouts are not exercised here. + use std::time::Instant; + static EPOCH: std::sync::OnceLock = std::sync::OnceLock::new(); + u64::try_from(EPOCH.get_or_init(Instant::now).elapsed().as_millis()).unwrap_or(u64::MAX) + } + + fn elapsed_ms(&self, since: u64) -> u64 { + self.now_ms().saturating_sub(since) + } +} + +impl AsAny for IntegrationMockBackend { + fn as_any(&self) -> &dyn core::any::Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { + self + } +} + +#[test] +fn integration_remote_file_list_delayed_rendering() { + // [MS-RDPECLIP] 1.3.2.2.3 and 1.3.1.4 - Integration test for delayed rendering file list request + // + // This test verifies the spec-compliant flow when remote sends a FormatList with FileGroupDescriptorW: + // 1. Remote sends FormatList containing FileGroupDescriptorW format + // 2. Client stores the format but does NOT request file list immediately (delayed rendering) + // 3. User initiates paste by calling initiate_paste() with the FileGroupDescriptorW format ID + // 4. Client sends FormatDataRequest for the file list + // 5. Remote sends FormatDataResponse with PackedFileList + // 6. Client parses file list and calls backend.on_remote_file_list() + // + // Per MS-RDPECLIP section 1.3.2.2.3, "The Local Clipboard Owner first requests the list of + // files available from the clipboard." The word "first" refers to ordering within the paste + // sequence (file list before file contents), NOT immediately after FormatList receipt. + // File lists are requested only when the user initiates a paste operation. + + let tracker = Arc::new(Mutex::new(IntegrationCallbackTracker::default())); + let backend = Box::new(IntegrationMockBackend { + temp_dir: "/tmp/test".to_owned(), + tracker: Arc::clone(&tracker), + }); + + let mut cliprdr = CliprdrClient::new(backend); + + // Initialize the client to Ready state (simulating connection setup) + let empty_formats: Vec = vec![]; + let _: Vec<_> = cliprdr.initiate_copy(&empty_formats).unwrap().into(); + let format_list_response_pdu = ClipboardPdu::FormatListResponse(FormatListResponse::Ok); + let _: Vec<_> = cliprdr + .process(&ironrdp_core::encode_vec(&format_list_response_pdu).unwrap()) + .unwrap(); + + // Step 1: Simulate remote sending FormatList with FileGroupDescriptorW + let formats = vec![ + ClipboardFormat::new(ClipboardFormatId::new(13)), // CF_UNICODETEXT + ClipboardFormat::new(ClipboardFormatId::new(0xC0BC)).with_name(ClipboardFormatName::FILE_LIST), // FileGroupDescriptorW + ]; + + let format_list = FormatList::new_unicode(&formats, true).unwrap(); + let format_list_pdu = ClipboardPdu::FormatList(format_list); + let encoded_format_list = ironrdp_core::encode_vec(&format_list_pdu).unwrap(); + + // Step 2: Process the FormatList - should store format but NOT request file list yet + let messages: Vec<_> = cliprdr.process(&encoded_format_list).unwrap(); + + // Verify that backend.on_remote_copy() was called with the formats + let copy_calls = tracker.lock().unwrap().remote_copy_calls.clone(); + assert_eq!(copy_calls.len(), 1, "on_remote_copy should be called once"); + assert_eq!(copy_calls[0].len(), 2, "Should receive both formats"); + + // Verify that ONLY FormatListResponse was sent (no automatic FormatDataRequest) + assert_eq!( + messages.len(), + 1, + "Should send only FormatListResponse (delayed rendering)" + ); + + // Step 3: User initiates paste for FileGroupDescriptorW format + let file_list_format_id = ClipboardFormatId::new(0xC0BC); + let paste_messages: Vec<_> = cliprdr.initiate_paste(file_list_format_id).unwrap().into(); + + // Verify that FormatDataRequest is sent NOW (on user paste) + assert_eq!( + paste_messages.len(), + 1, + "Should send FormatDataRequest on user-initiated paste" + ); + + // Step 4: Simulate remote sending FormatDataResponse with file list + let file_list = PackedFileList { + files: vec![ + FileDescriptor::new("document.pdf") + .with_attributes(ClipboardFileAttributes::ARCHIVE) + .with_last_write_time(129010042240261384) + .with_file_size(1024), + FileDescriptor::new("spreadsheet.xlsx") + .with_attributes(ClipboardFileAttributes::ARCHIVE) + .with_last_write_time(129010042240261384) + .with_file_size(2048), + ], + }; + + let response = FormatDataResponse::new_file_list(&file_list).unwrap(); + let response_pdu = ClipboardPdu::FormatDataResponse(response); + let encoded_response = ironrdp_core::encode_vec(&response_pdu).unwrap(); + + // Step 5: Process the FormatDataResponse + let messages: Vec<_> = cliprdr.process(&encoded_response).unwrap(); + + // Should not send any messages in response + assert_eq!(messages.len(), 0, "No response expected for FormatDataResponse"); + + // Step 6: Verify that backend.on_remote_file_list() was called with correct files + let file_list_calls = tracker.lock().unwrap().remote_file_list_calls.clone(); + assert_eq!(file_list_calls.len(), 1, "on_remote_file_list should be called once"); + assert_eq!(file_list_calls[0].len(), 2, "Should receive 2 files"); + assert_eq!(file_list_calls[0][0].name, "document.pdf"); + assert_eq!(file_list_calls[0][0].file_size, Some(1024)); + assert_eq!(file_list_calls[0][1].name, "spreadsheet.xlsx"); + assert_eq!(file_list_calls[0][1].file_size, Some(2048)); +} + +#[test] +fn integration_local_file_copy_sends_format_list() { + // [MS-RDPECLIP] 2.2.5.2 - Integration test for local file copy operation + // + // This test verifies the complete flow when client initiates a file copy: + // 1. Client calls initiate_file_copy() with file metadata + // 2. Client sends FormatList with FileGroupDescriptorW format + // 3. Remote sends FormatDataRequest for the file list + // 4. Client automatically responds with FormatDataResponse containing file list + + let tracker = Arc::new(Mutex::new(IntegrationCallbackTracker::default())); + let backend = Box::new(IntegrationMockBackend { + temp_dir: "/tmp/test".to_owned(), + tracker: Arc::clone(&tracker), + }); + + let mut cliprdr = CliprdrClient::new(backend); + + // Transition to Ready state by simulating server initialization messages + // Server sends Capabilities + let capabilities = Capabilities { + capabilities: vec![CapabilitySet::General(GeneralCapabilitySet { + version: ClipboardProtocolVersion::V2, + general_flags: ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES + | ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + | ClipboardGeneralCapabilityFlags::FILECLIP_NO_FILE_PATHS, + })], + }; + let capabilities_pdu = ClipboardPdu::Capabilities(capabilities); + let _: Vec<_> = cliprdr + .process(&ironrdp_core::encode_vec(&capabilities_pdu).unwrap()) + .unwrap(); + + // Server sends MonitorReady + let monitor_ready_pdu = ClipboardPdu::MonitorReady; + let _: Vec<_> = cliprdr + .process(&ironrdp_core::encode_vec(&monitor_ready_pdu).unwrap()) + .unwrap(); + + // Client sends initial empty FormatList to complete initialization + // initiate_copy works in Initialization state and sends Capabilities + TemporaryDirectory + FormatList + let empty_formats: Vec = vec![]; + let _: Vec<_> = cliprdr.initiate_copy(&empty_formats).unwrap().into(); + + // Server responds with FormatListResponse::Ok (transitions client to Ready state) + let format_list_response_pdu = ClipboardPdu::FormatListResponse(FormatListResponse::Ok); + let _: Vec<_> = cliprdr + .process(&ironrdp_core::encode_vec(&format_list_response_pdu).unwrap()) + .unwrap(); + + // Step 1: Client initiates file copy + let files = vec![ + FileDescriptor::new("report.docx") + .with_attributes(ClipboardFileAttributes::ARCHIVE) + .with_last_write_time(132489216000000000) + .with_file_size(5000), + FileDescriptor::new("presentation.pptx") + .with_attributes(ClipboardFileAttributes::ARCHIVE) + .with_last_write_time(132489216000000000) + .with_file_size(10000), + ]; + + let messages: Vec<_> = cliprdr.initiate_file_copy(files).unwrap().into(); + + // Step 2: Verify FormatList was sent + assert_eq!(messages.len(), 1, "Should send FormatList"); + + // Step 3: Simulate remote sending FormatDataRequest for our file list + let request_pdu = ClipboardPdu::FormatDataRequest(ironrdp_cliprdr::pdu::FormatDataRequest { + format: ClipboardFormatId::new(0xC0FE), // Our format ID + }); + let encoded_request = ironrdp_core::encode_vec(&request_pdu).unwrap(); + + let messages: Vec<_> = cliprdr.process(&encoded_request).unwrap(); + + // Step 4: Verify FormatDataResponse with file list was sent + assert_eq!(messages.len(), 1, "Should send FormatDataResponse"); +} + +#[test] +fn integration_empty_file_list_handling() { + // Edge case: Verify that empty file lists are handled gracefully throughout the flow + + let tracker = Arc::new(Mutex::new(IntegrationCallbackTracker::default())); + let backend = Box::new(IntegrationMockBackend { + temp_dir: "/tmp/test".to_owned(), + tracker: Arc::clone(&tracker), + }); + + let mut cliprdr = CliprdrClient::new(backend); + + // Initialize the client to Ready state + let empty_formats: Vec = vec![]; + let _: Vec<_> = cliprdr.initiate_copy(&empty_formats).unwrap().into(); + let format_list_response_pdu = ClipboardPdu::FormatListResponse(FormatListResponse::Ok); + let _: Vec<_> = cliprdr + .process(&ironrdp_core::encode_vec(&format_list_response_pdu).unwrap()) + .unwrap(); + + // Simulate remote sending FormatList with FileGroupDescriptorW + let formats = vec![ClipboardFormat::new(ClipboardFormatId::new(0xC0BC)).with_name(ClipboardFormatName::FILE_LIST)]; + + let format_list = FormatList::new_unicode(&formats, true).unwrap(); + let format_list_pdu = ClipboardPdu::FormatList(format_list); + let encoded_format_list = ironrdp_core::encode_vec(&format_list_pdu).unwrap(); + + let messages: Vec<_> = cliprdr.process(&encoded_format_list).unwrap(); + assert_eq!( + messages.len(), + 1, + "Should send only FormatListResponse (delayed rendering)" + ); + + // User initiates paste + let file_list_format_id = ClipboardFormatId::new(0xC0BC); + let _: Vec<_> = cliprdr.initiate_paste(file_list_format_id).unwrap().into(); + + // Simulate remote sending empty file list + let empty_file_list = PackedFileList { files: vec![] }; + + let response = FormatDataResponse::new_file_list(&empty_file_list).unwrap(); + let response_pdu = ClipboardPdu::FormatDataResponse(response); + let encoded_response = ironrdp_core::encode_vec(&response_pdu).unwrap(); + + let _: Vec<_> = cliprdr.process(&encoded_response).unwrap(); + + // Verify backend was called with empty file list + let file_list_calls = tracker.lock().unwrap().remote_file_list_calls.clone(); + assert_eq!(file_list_calls.len(), 1); + assert_eq!(file_list_calls[0].len(), 0, "Should receive empty file list"); +} + +#[test] +fn integration_multiple_format_lists_in_sequence() { + // Edge case: Multiple FormatLists with FileGroupDescriptorW in rapid succession + // Verifies that state is properly cleared between clipboard updates + + let tracker = Arc::new(Mutex::new(IntegrationCallbackTracker::default())); + let backend = Box::new(IntegrationMockBackend { + temp_dir: "/tmp/test".to_owned(), + tracker: Arc::clone(&tracker), + }); + + let mut cliprdr = CliprdrClient::new(backend); + + // Initialize the client to Ready state + let empty_formats: Vec = vec![]; + let _: Vec<_> = cliprdr.initiate_copy(&empty_formats).unwrap().into(); + let format_list_response_pdu = ClipboardPdu::FormatListResponse(FormatListResponse::Ok); + let _: Vec<_> = cliprdr + .process(&ironrdp_core::encode_vec(&format_list_response_pdu).unwrap()) + .unwrap(); + + // First FormatList with one file + let formats1 = vec![ClipboardFormat::new(ClipboardFormatId::new(0xC0BC)).with_name(ClipboardFormatName::FILE_LIST)]; + let format_list1 = FormatList::new_unicode(&formats1, true).unwrap(); + let pdu1 = ClipboardPdu::FormatList(format_list1); + let _: Vec<_> = cliprdr.process(&ironrdp_core::encode_vec(&pdu1).unwrap()).unwrap(); + + // User initiates paste for first file list + let file_list_format_id = ClipboardFormatId::new(0xC0BC); + let _: Vec<_> = cliprdr.initiate_paste(file_list_format_id).unwrap().into(); + + // Respond with file list + let file_list1 = PackedFileList { + files: vec![FileDescriptor::new("file1.txt").with_file_size(100)], + }; + let response1 = FormatDataResponse::new_file_list(&file_list1).unwrap(); + let response_pdu1 = ClipboardPdu::FormatDataResponse(response1); + let _: Vec<_> = cliprdr + .process(&ironrdp_core::encode_vec(&response_pdu1).unwrap()) + .unwrap(); + + // Second FormatList with different file (new clipboard content) + let formats2 = vec![ClipboardFormat::new(ClipboardFormatId::new(0xC0BC)).with_name(ClipboardFormatName::FILE_LIST)]; + let format_list2 = FormatList::new_unicode(&formats2, true).unwrap(); + let pdu2 = ClipboardPdu::FormatList(format_list2); + let _: Vec<_> = cliprdr.process(&ironrdp_core::encode_vec(&pdu2).unwrap()).unwrap(); + + // User initiates paste for second file list + let _: Vec<_> = cliprdr.initiate_paste(file_list_format_id).unwrap().into(); + + // Respond with different file list + let file_list2 = PackedFileList { + files: vec![FileDescriptor::new("file2.txt").with_file_size(200)], + }; + let response2 = FormatDataResponse::new_file_list(&file_list2).unwrap(); + let response_pdu2 = ClipboardPdu::FormatDataResponse(response2); + let _: Vec<_> = cliprdr + .process(&ironrdp_core::encode_vec(&response_pdu2).unwrap()) + .unwrap(); + + // Verify both file lists were processed correctly + let file_list_calls = tracker.lock().unwrap().remote_file_list_calls.clone(); + assert_eq!(file_list_calls.len(), 2, "Should process both file lists"); + assert_eq!(file_list_calls[0][0].name, "file1.txt"); + assert_eq!(file_list_calls[1][0].name, "file2.txt"); +} + +#[test] +fn integration_unexpected_file_list_response_ignored() { + // Edge case: Receiving FormatDataResponse with file list when not expecting it + // Should be safely ignored (no crash or state corruption) + + let tracker = Arc::new(Mutex::new(IntegrationCallbackTracker::default())); + let backend = Box::new(IntegrationMockBackend { + temp_dir: "/tmp/test".to_owned(), + tracker: Arc::clone(&tracker), + }); + + let mut cliprdr = CliprdrClient::new(backend); + + // Send FormatDataResponse with file list WITHOUT first receiving a FormatList + let file_list = PackedFileList { + files: vec![FileDescriptor::new("unexpected.txt").with_file_size(100)], + }; + + let response = FormatDataResponse::new_file_list(&file_list).unwrap(); + let response_pdu = ClipboardPdu::FormatDataResponse(response); + let encoded_response = ironrdp_core::encode_vec(&response_pdu).unwrap(); + + // Should not crash or call backend + let _: Vec<_> = cliprdr.process(&encoded_response).unwrap(); + + // Verify backend was NOT called since we weren't expecting a file list + let file_list_calls = tracker.lock().unwrap().remote_file_list_calls.clone(); + assert_eq!( + file_list_calls.len(), + 0, + "Unexpected file list should not trigger callback" + ); +} + +#[test] +fn integration_format_list_response_fail_clears_local_state() { + // Verify that FormatListResponse::Fail clears local file list state + // Per MS-RDPECLIP 3.1.5.2.4, if the remote rejects our FormatList, we should + // clear our local clipboard state since the remote cannot process it + + let tracker = Arc::new(Mutex::new(IntegrationCallbackTracker::default())); + let backend = Box::new(IntegrationMockBackend { + temp_dir: "/tmp/test".to_owned(), + tracker: Arc::clone(&tracker), + }); + + let mut cliprdr = CliprdrClient::new(backend); + + // Transition to Ready state + let capabilities = Capabilities { + capabilities: vec![CapabilitySet::General(GeneralCapabilitySet { + version: ClipboardProtocolVersion::V2, + general_flags: ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES + | ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + | ClipboardGeneralCapabilityFlags::FILECLIP_NO_FILE_PATHS, + })], + }; + let capabilities_pdu = ClipboardPdu::Capabilities(capabilities); + let _: Vec<_> = cliprdr + .process(&ironrdp_core::encode_vec(&capabilities_pdu).unwrap()) + .unwrap(); + + let monitor_ready_pdu = ClipboardPdu::MonitorReady; + let _: Vec<_> = cliprdr + .process(&ironrdp_core::encode_vec(&monitor_ready_pdu).unwrap()) + .unwrap(); + + let empty_formats: Vec = vec![]; + let _: Vec<_> = cliprdr.initiate_copy(&empty_formats).unwrap().into(); + + let format_list_response_pdu = ClipboardPdu::FormatListResponse(FormatListResponse::Ok); + let _: Vec<_> = cliprdr + .process(&ironrdp_core::encode_vec(&format_list_response_pdu).unwrap()) + .unwrap(); + + // Now in Ready state - initiate file copy + let files = vec![ + FileDescriptor::new("report.docx") + .with_attributes(ClipboardFileAttributes::ARCHIVE) + .with_last_write_time(132489216000000000) + .with_file_size(5000), + ]; + + let messages: Vec<_> = cliprdr.initiate_file_copy(files).unwrap().into(); + assert_eq!(messages.len(), 1, "Should send FormatList"); + + // Simulate remote rejecting our FormatList + let fail_response_pdu = ClipboardPdu::FormatListResponse(FormatListResponse::Fail); + let messages: Vec<_> = cliprdr + .process(&ironrdp_core::encode_vec(&fail_response_pdu).unwrap()) + .unwrap(); + + // Should not send any response + assert_eq!(messages.len(), 0, "No messages expected after FormatListResponse::Fail"); + + // Verify state was cleared by attempting to send a FormatDataRequest + // If state wasn't cleared, the cliprdr would try to respond with the stored file list + // Since state WAS cleared, it should forward the request to backend instead + let request_pdu = ClipboardPdu::FormatDataRequest(ironrdp_cliprdr::pdu::FormatDataRequest { + format: ClipboardFormatId::new(0xC0FE), // Our format ID from initiate_file_copy + }); + let encoded_request = ironrdp_core::encode_vec(&request_pdu).unwrap(); + + let messages: Vec<_> = cliprdr.process(&encoded_request).unwrap(); + + // Should NOT send FormatDataResponse with file list (since state was cleared) + // Instead, request should be forwarded to backend (no immediate response) + assert_eq!( + messages.len(), + 0, + "Should not auto-respond with file list after state was cleared by Fail" + ); +} diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/file_contents_state_machine.rs b/crates/ironrdp-testsuite-core/tests/clipboard/file_contents_state_machine.rs new file mode 100644 index 0000000000..2c06f01593 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/clipboard/file_contents_state_machine.rs @@ -0,0 +1,437 @@ +//! Tests for FileContentsRequest/Response tracking, validation, and +//! concurrent transfer support. +//! +//! Migrated from `ironrdp-cliprdr/src/lib.rs` inline `#[cfg(test)]` module. +//! Behavior assertions verify returned PDUs and backend callbacks; +//! bookkeeping assertions (tracking map state) use the `__test` feature gate. + +use std::sync::{Arc, Mutex}; + +use ironrdp_cliprdr::pdu::{ + ClipboardPdu, FileContentsFlags, FileContentsRequest, FileContentsResponse, FileDescriptor, PackedFileList, +}; +use ironrdp_cliprdr::{CliprdrClient, CliprdrState, FileTransferState}; +use ironrdp_core::Encode as _; +use ironrdp_svc::{SvcMessage, SvcProcessor as _}; + +use super::test_helpers::{RecordingBackend, TestBackend}; + +/// Introduce `let` bindings for the encoded bytes and the decoded +/// [`ClipboardPdu`] in the caller's scope. Two names are required so +/// that the byte buffer outlives the borrowing PDU. +macro_rules! decode_pdu { + ($msg:expr => $bytes:ident, $pdu:ident) => { + let $bytes = ($msg).encode_unframed_pdu().unwrap(); + let $pdu = ironrdp_core::decode::>(&$bytes).unwrap(); + }; +} + +/// Helper: create a CliprdrClient in Ready state using __test accessors. +fn ready_client() -> CliprdrClient { + let mut cliprdr = CliprdrClient::new(Box::new(TestBackend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + cliprdr +} + +/// Helper: build a simple file list with the given file names. +fn file_list(names: &[&str]) -> PackedFileList { + PackedFileList { + files: names.iter().map(|name| FileDescriptor::new(*name)).collect(), + } +} + +/// Helper: build a file list where each file has a specified size. +fn sized_file_list(entries: &[(&str, u64)]) -> PackedFileList { + PackedFileList { + files: entries + .iter() + .map(|(name, size)| FileDescriptor::new(*name).with_file_size(*size)) + .collect(), + } +} + +// ── Request tracking ──────────────────────────────────────────────── + +#[test] +fn request_tracking() { + let mut cliprdr = ready_client(); + *cliprdr.__test_remote_file_list_mut() = Some(file_list(&["file1.txt", "file2.txt", "file3.txt"])); + + let request = FileContentsRequest { + stream_id: 42, + index: 1, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: None, + }; + + let messages: Vec = cliprdr.request_file_contents(request).unwrap().into(); + + // Behavior: a FileContentsRequest PDU is returned with the correct fields + assert_eq!(messages.len(), 1); + decode_pdu!(messages[0] => _bytes, pdu); + match pdu { + ClipboardPdu::FileContentsRequest(req) => { + assert_eq!(req.stream_id, 42); + assert_eq!(req.index, 1); + assert_eq!(req.flags, FileContentsFlags::SIZE); + } + other => panic!("expected FileContentsRequest PDU, got {other:?}"), + } + + // Bookkeeping: tracking entry created + assert!(cliprdr.__test_sent_file_contents_requests().contains_key(&42)); +} + +#[test] +fn request_index_validation() { + let mut cliprdr = ready_client(); + *cliprdr.__test_remote_file_list_mut() = Some(file_list(&["file1.txt", "file2.txt"])); + + // Index 5 is out of bounds for a 2-file list + let request = FileContentsRequest { + stream_id: 99, + index: 5, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: None, + }; + + let result = cliprdr.request_file_contents(request); + assert!(result.is_err(), "out-of-bounds index should be rejected"); +} + +#[test] +fn request_bounds_validation() { + let mut cliprdr = ready_client(); + *cliprdr.__test_remote_file_list_mut() = Some(sized_file_list(&[("test.txt", 1000)])); + + // Test 1: Overflow detection - position near u64::MAX + let overflow_request = FileContentsRequest { + stream_id: 100, + index: 0, + flags: FileContentsFlags::RANGE, + position: u64::MAX - 1, + requested_size: 100, + data_id: None, + }; + assert!( + cliprdr.request_file_contents(overflow_request).is_err(), + "overflow position should be rejected" + ); + + // Test 2: Out-of-bounds detection - position + size > file_size + let out_of_bounds_request = FileContentsRequest { + stream_id: 101, + index: 0, + flags: FileContentsFlags::RANGE, + position: 900, + requested_size: 200, // 900 + 200 = 1100 > 1000 + data_id: None, + }; + assert!( + cliprdr.request_file_contents(out_of_bounds_request).is_err(), + "out-of-bounds range should be rejected" + ); + + // Test 3: Valid request within bounds + let valid_request = FileContentsRequest { + stream_id: 102, + index: 0, + flags: FileContentsFlags::RANGE, + position: 500, + requested_size: 400, // 500 + 400 = 900 <= 1000 + data_id: None, + }; + assert!(cliprdr.request_file_contents(valid_request).is_ok()); + assert!(cliprdr.__test_sent_file_contents_requests().contains_key(&102)); +} + +// ── Response validation ───────────────────────────────────────────── + +#[test] +fn response_validation() { + let responses = Arc::new(Mutex::new(Vec::new())); + let backend = RecordingBackend { + responses: Arc::clone(&responses), + }; + let mut cliprdr = CliprdrClient::new(Box::new(backend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + + // Pre-populate tracking for stream_id 42 (bookkeeping setup) + cliprdr.__test_sent_file_contents_requests_mut().insert( + 42, + FileTransferState { + file_index: 0, + flags: FileContentsFlags::SIZE, + sent_at_ms: 0, + }, + ); + + // Create and encode a valid SIZE response + let response = FileContentsResponse::new_size_response(42, 1024); + let pdu = ClipboardPdu::FileContentsResponse(response); + let mut buf = vec![0u8; pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + pdu.encode(&mut cursor).unwrap(); + + cliprdr.process(&buf).unwrap(); + + // Behavior: backend received the response via callback + let received = responses.lock().unwrap(); + assert_eq!(received.len(), 1); + assert_eq!(received[0].stream_id, 42); + assert!(!received[0].is_error); + + // Bookkeeping: tracking entry consumed + drop(received); + assert!(!cliprdr.__test_sent_file_contents_requests().contains_key(&42)); +} + +#[test] +fn concurrent_file_transfers() { + let mut cliprdr = ready_client(); + *cliprdr.__test_remote_file_list_mut() = Some(file_list(&["file1.txt", "file2.txt"])); + + let request1 = FileContentsRequest { + stream_id: 10, + index: 0, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: None, + }; + let request2 = FileContentsRequest { + stream_id: 20, + index: 1, + flags: FileContentsFlags::RANGE, + position: 0, + requested_size: 1024, + data_id: None, + }; + + cliprdr.request_file_contents(request1).unwrap(); + cliprdr.request_file_contents(request2).unwrap(); + + assert!(cliprdr.__test_sent_file_contents_requests().contains_key(&10)); + assert!(cliprdr.__test_sent_file_contents_requests().contains_key(&20)); + assert_eq!(cliprdr.__test_sent_file_contents_requests().len(), 2); +} + +#[test] +fn error_response_clears_tracking() { + let mut cliprdr = ready_client(); + + cliprdr.__test_sent_file_contents_requests_mut().insert( + 123, + FileTransferState { + file_index: 0, + flags: FileContentsFlags::SIZE, + sent_at_ms: 0, + }, + ); + + let response = FileContentsResponse::new_error(123); + let pdu = ClipboardPdu::FileContentsResponse(response); + let mut buf = vec![0u8; pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + pdu.encode(&mut cursor).unwrap(); + + let result = cliprdr.process(&buf); + assert!(result.is_ok()); + assert!(!cliprdr.__test_sent_file_contents_requests().contains_key(&123)); +} + +/// [MS-RDPECLIP] 2.2.5.4 - SIZE responses MUST be exactly 8 bytes. +/// A malformed SIZE response should be converted to an error response +/// before forwarding to the backend. +#[test] +fn malformed_size_response_converted_to_error() { + let responses = Arc::new(Mutex::new(Vec::new())); + let backend = RecordingBackend { + responses: Arc::clone(&responses), + }; + + let mut cliprdr = CliprdrClient::new(Box::new(backend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + + cliprdr.__test_sent_file_contents_requests_mut().insert( + 42, + FileTransferState { + file_index: 0, + flags: FileContentsFlags::SIZE, + sent_at_ms: 0, + }, + ); + + // Create a malformed SIZE response (4 bytes instead of required 8) + let response = FileContentsResponse::new_data_response(42, vec![0u8; 4]); + let pdu = ClipboardPdu::FileContentsResponse(response); + let mut buf = vec![0u8; pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + pdu.encode(&mut cursor).unwrap(); + + let result = cliprdr.process(&buf); + assert!(result.is_ok()); + + let received = responses.lock().unwrap(); + assert_eq!(received.len(), 1); + assert_eq!(received[0].stream_id, 42); + assert!( + received[0].is_error, + "malformed SIZE response should be converted to error" + ); + assert_eq!(received[0].data_len, 0, "error response should have zero-length data"); +} + +/// [MS-RDPECLIP] 2.2.5.4 - FAIL responses MUST have zero-length data. +/// Non-empty error responses should be sanitized before forwarding. +#[test] +fn error_response_data_sanitized() { + let responses = Arc::new(Mutex::new(Vec::new())); + let backend = RecordingBackend { + responses: Arc::clone(&responses), + }; + + let mut cliprdr = CliprdrClient::new(Box::new(backend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + + cliprdr.__test_sent_file_contents_requests_mut().insert( + 99, + FileTransferState { + file_index: 0, + flags: FileContentsFlags::RANGE, + sent_at_ms: 0, + }, + ); + + // Manually construct a non-conforming error response with non-empty data. + // Wire format: msgType(2) + msgFlags(2) + dataLen(4) + streamId(4) + data(N) + // For an error response: msgFlags = CB_RESPONSE_FAIL (0x0002) + let mut buf = Vec::new(); + buf.extend_from_slice(&0x0009u16.to_le_bytes()); // msgType = CB_FILECONTENTS_RESPONSE + buf.extend_from_slice(&0x0002u16.to_le_bytes()); // msgFlags = CB_RESPONSE_FAIL + buf.extend_from_slice(&8u32.to_le_bytes()); // dataLen = 4 (streamId) + 4 (stale data) + buf.extend_from_slice(&99u32.to_le_bytes()); // streamId = 99 + buf.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); // stale data (should be empty) + + let result = cliprdr.process(&buf); + assert!(result.is_ok()); + + let received = responses.lock().unwrap(); + assert_eq!(received.len(), 1); + assert_eq!(received[0].stream_id, 99); + assert!(received[0].is_error, "should still be an error"); + assert_eq!( + received[0].data_len, 0, + "stale data should be stripped from error response" + ); +} + +// ── Incoming request validation ───────────────────────────────────── + +#[test] +fn incoming_file_contents_request_validation() { + let mut cliprdr = ready_client(); + *cliprdr.__test_local_file_list_mut() = Some(file_list(&["local1.txt", "local2.txt"])); + + // Create an incoming request with invalid index + let request = FileContentsRequest { + stream_id: 555, + index: 10, // Out of bounds + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: None, + }; + + let pdu = ClipboardPdu::FileContentsRequest(request); + let mut buf = vec![0u8; pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + pdu.encode(&mut cursor).unwrap(); + + // Behavior: returns an error FileContentsResponse + let messages: Vec = cliprdr.process(&buf).unwrap(); + assert_eq!(messages.len(), 1); + decode_pdu!(messages[0] => _bytes, pdu); + match pdu { + ClipboardPdu::FileContentsResponse(resp) => { + assert!(resp.is_error(), "out-of-bounds request should produce error response"); + assert_eq!(resp.stream_id(), 555); + } + other => panic!("expected FileContentsResponse PDU, got {other:?}"), + } +} + +// ── Unknown and duplicate streamId handling ───────────────────────── + +#[test] +fn unknown_stream_id_response_dropped_silently() { + let responses = Arc::new(Mutex::new(Vec::new())); + let backend = RecordingBackend { + responses: Arc::clone(&responses), + }; + let mut cliprdr = super::test_helpers::init_ready_client_with_backend(Box::new(backend)); + + // Send a FileContentsResponse for a stream_id that was never requested + let response = FileContentsResponse::new_size_response(9999, 42); + let pdu = ClipboardPdu::FileContentsResponse(response); + let bytes = ironrdp_core::encode_vec(&pdu).unwrap(); + + // Should succeed without error + let messages: Vec = cliprdr.process(&bytes).unwrap(); + + // No outbound PDUs generated + assert!(messages.is_empty(), "no PDUs should be sent for unknown streamId"); + + // Backend should NOT have received the response (it was dropped) + let received = responses.lock().unwrap(); + assert!( + received.is_empty(), + "backend should not receive response for unknown streamId" + ); +} + +#[test] +fn duplicate_stream_id_overwrites_tracking() { + let mut cliprdr = ready_client(); + *cliprdr.__test_remote_file_list_mut() = Some(sized_file_list(&[("a.txt", 100), ("b.txt", 200)])); + + // First request with stream_id=1, file index 0 + let request1 = FileContentsRequest { + stream_id: 1, + index: 0, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: None, + }; + let _: Vec = cliprdr.request_file_contents(request1).unwrap().into(); + + // Second request reusing stream_id=1, but for file index 1 + let request2 = FileContentsRequest { + stream_id: 1, + index: 1, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: None, + }; + let _: Vec = cliprdr.request_file_contents(request2).unwrap().into(); + + // Only one tracking entry should exist (second overwrites first) + let tracking = cliprdr.__test_sent_file_contents_requests(); + assert_eq!( + tracking.len(), + 1, + "duplicate stream_id should overwrite, not accumulate" + ); + + let state = tracking.get(&1).unwrap(); + assert_eq!( + state.file_index, 1, + "tracking should reflect the second request's file index" + ); +} diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/file_contents_validation.rs b/crates/ironrdp-testsuite-core/tests/clipboard/file_contents_validation.rs new file mode 100644 index 0000000000..c3bc620fe8 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/clipboard/file_contents_validation.rs @@ -0,0 +1,240 @@ +/// [MS-RDPECLIP] File Contents Request/Response Validation Tests +/// +/// These tests verify spec compliance per MS-RDPECLIP 2.2.5.3, 2.2.5.4, and 3.1.5.4.5-3.1.5.4.8. +use ironrdp_cliprdr::pdu::{ + ClipboardFileAttributes, ClipboardPdu, FileContentsFlags, FileContentsRequest, FileContentsResponse, + FileDescriptor, FormatDataResponse, PackedFileList, +}; + +// ============================================================================ +// Flags Mutual Exclusion Tests (MS-RDPECLIP 2.2.5.3) +// ============================================================================ + +#[test] +fn test_flags_validation_both_set() { + // [MS-RDPECLIP] 2.2.5.3 - SIZE and RANGE flags MUST NOT be set simultaneously + let flags = FileContentsFlags::SIZE | FileContentsFlags::RANGE; + let result = flags.validate(); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "SIZE and RANGE flags are mutually exclusive per MS-RDPECLIP 2.2.5.3" + ); +} + +#[test] +fn test_flags_validation_neither_set() { + // [MS-RDPECLIP] 2.2.5.3 - Exactly one of SIZE or RANGE must be set + let flags = FileContentsFlags::empty(); + let result = flags.validate(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "exactly one of SIZE or RANGE must be set"); +} + +#[test] +fn test_flags_validation_size_only() { + // Valid: only SIZE flag set + let flags = FileContentsFlags::SIZE; + let result = flags.validate(); + assert!(result.is_ok()); +} + +#[test] +fn test_flags_validation_range_only() { + // Valid: only RANGE flag set + let flags = FileContentsFlags::RANGE; + let result = flags.validate(); + assert!(result.is_ok()); +} + +#[test] +fn test_invalid_flags_rejected_during_decode() { + // [MS-RDPECLIP] 2.2.5.3 - Decoder should reject invalid flag combinations + let request = FileContentsRequest { + stream_id: 1, + index: 0, + flags: FileContentsFlags::SIZE | FileContentsFlags::RANGE, // Invalid combination + position: 0, + requested_size: 8, + data_id: None, + }; + + let pdu = ClipboardPdu::FileContentsRequest(request); + + // Encode with invalid flags + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + + // Decode should fail due to validation + let result = ironrdp_core::decode::>(&encoded); + assert!(result.is_err(), "Decode should reject mutually exclusive flags"); +} + +// ============================================================================ +// SIZE Request Constraints Tests (MS-RDPECLIP 2.2.5.3) +// ============================================================================ + +#[test] +fn test_size_request_valid_constraints() { + // [MS-RDPECLIP] 2.2.5.3 - Valid SIZE request: requested_size=8, position=0 + let request = FileContentsRequest { + stream_id: 1, + index: 0, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: None, + }; + + let pdu = ClipboardPdu::FileContentsRequest(request); + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + let decoded = ironrdp_core::decode::>(&encoded).unwrap(); + + // Should decode successfully + assert!(matches!(decoded, ClipboardPdu::FileContentsRequest(_))); +} + +#[test] +fn test_size_request_invalid_requested_size() { + // [MS-RDPECLIP] 2.2.5.3 - SIZE request MUST have requested_size=8 + let request = FileContentsRequest { + stream_id: 1, + index: 0, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 1024, // Invalid - should be 8 + data_id: None, + }; + + let pdu = ClipboardPdu::FileContentsRequest(request); + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + + // Decode should fail + let result = ironrdp_core::decode::>(&encoded); + assert!( + result.is_err(), + "SIZE request with requested_size != 8 should be rejected" + ); +} + +#[test] +fn test_size_request_invalid_position() { + // [MS-RDPECLIP] 2.2.5.3 - SIZE request MUST have position=0 + let request = FileContentsRequest { + stream_id: 1, + index: 0, + flags: FileContentsFlags::SIZE, + position: 1024, // Invalid - should be 0 + requested_size: 8, + data_id: None, + }; + + let pdu = ClipboardPdu::FileContentsRequest(request); + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + + // Decode should fail + let result = ironrdp_core::decode::>(&encoded); + assert!(result.is_err(), "SIZE request with position != 0 should be rejected"); +} + +// ============================================================================ +// SIZE Response Data Length Validation Tests (MS-RDPECLIP 2.2.5.4) +// ============================================================================ + +#[test] +fn test_size_response_valid_length() { + // [MS-RDPECLIP] 2.2.5.4 - SIZE response with exactly 8 bytes + let response = FileContentsResponse::new_size_response(1, 1024); + + assert_eq!(response.data().len(), 8); + assert!(!response.is_error()); + + let size = response.data_as_size().unwrap(); + assert_eq!(size, 1024); +} + +#[test] +fn test_size_response_invalid_length_too_short() { + // [MS-RDPECLIP] 2.2.5.4 - SIZE response with wrong length should fail parsing + let response = FileContentsResponse::new_data_response(1, vec![1, 2, 3, 4]); // Only 4 bytes + + let result = response.data_as_size(); + assert!(result.is_err(), "SIZE response with 4 bytes should fail data_as_size()"); +} + +#[test] +fn test_size_response_invalid_length_too_long() { + // [MS-RDPECLIP] 2.2.5.4 - SIZE response with too many bytes should fail + let response = FileContentsResponse::new_data_response(1, vec![0u8; 16]); // 16 bytes + + let result = response.data_as_size(); + assert!( + result.is_err(), + "SIZE response with 16 bytes should fail data_as_size()" + ); +} + +// ============================================================================ +// FAIL Response Data Validation Tests (MS-RDPECLIP 2.2.5.4) +// ============================================================================ + +#[test] +fn test_fail_response_zero_length() { + // [MS-RDPECLIP] 2.2.5.4 - FAIL response MUST have zero-length data + let response = FileContentsResponse::new_error(42); + + assert!(response.is_error()); + assert_eq!(response.data().len(), 0, "Error response must have zero-length data"); +} + +#[test] +fn test_fail_response_encoding() { + // Verify error response encodes with CB_RESPONSE_FAIL flag + let response = FileContentsResponse::new_error(123); + let pdu = ClipboardPdu::FileContentsResponse(response); + + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + let decoded = ironrdp_core::decode::>(&encoded).unwrap(); + + if let ClipboardPdu::FileContentsResponse(resp) = decoded { + assert!(resp.is_error()); + assert_eq!(resp.data().len(), 0); + assert_eq!(resp.stream_id(), 123); + } else { + panic!("Expected FileContentsResponse"); + } +} + +// ============================================================================ +// File List Round-Trip Tests +// ============================================================================ + +#[test] +fn test_file_list_with_file_sizes_for_bounds_validation() { + // Verify file list round-trip preserves file_size needed for bounds validation + let file_list = PackedFileList { + files: vec![ + FileDescriptor::new("small.txt") + .with_attributes(ClipboardFileAttributes::ARCHIVE) + .with_last_write_time(129010042240261384) + .with_file_size(1024), + FileDescriptor::new("huge.dat") + .with_attributes(ClipboardFileAttributes::ARCHIVE) + .with_last_write_time(129010042240261384) + .with_file_size(10_000_000_000), // 10GB + ], + }; + + let response = FormatDataResponse::new_file_list(&file_list).unwrap(); + let pdu = ClipboardPdu::FormatDataResponse(response); + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + + let decoded = ironrdp_core::decode::>(&encoded).unwrap(); + if let ClipboardPdu::FormatDataResponse(resp) = decoded { + let decoded_list = resp.to_file_list().unwrap(); + assert_eq!(decoded_list.files.len(), 2); + assert_eq!(decoded_list.files[0].file_size, Some(1024)); + assert_eq!(decoded_list.files[1].file_size, Some(10_000_000_000)); + } else { + panic!("Expected FormatDataResponse"); + } +} diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/file_list_format.rs b/crates/ironrdp-testsuite-core/tests/clipboard/file_list_format.rs new file mode 100644 index 0000000000..fde52ada33 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/clipboard/file_list_format.rs @@ -0,0 +1,145 @@ +use ironrdp_cliprdr::pdu::{ + ClipboardFileAttributes, ClipboardFormat, ClipboardFormatId, ClipboardFormatName, ClipboardPdu, FileDescriptor, + FormatDataRequest, FormatDataResponse, FormatList, MAX_FILE_COUNT, PackedFileList, +}; + +// [MS-RDPECLIP] 2.2.5.2 - File List Format Tests +// Note: FileDescriptor encode/decode is already tested in file_list_pdu_ms test with real MS test data. +// These tests focus on higher-level integration and round-trip testing. + +#[test] +fn format_list_with_file_group_descriptor() { + // Test that FileGroupDescriptorW format can be properly encoded in a FormatList + let formats = vec![ClipboardFormat::new(ClipboardFormatId::new(0xC0BC)).with_name(ClipboardFormatName::FILE_LIST)]; + + let format_list = FormatList::new_unicode(&formats, true).unwrap(); + let encoded = ironrdp_core::encode_vec(&ClipboardPdu::FormatList(format_list)).unwrap(); + + // Decode and verify + let decoded: ClipboardPdu<'_> = ironrdp_core::decode(&encoded).unwrap(); + if let ClipboardPdu::FormatList(list) = decoded { + let decoded_formats = list.get_formats(true).unwrap(); + assert_eq!(decoded_formats.len(), 1); + assert_eq!(decoded_formats[0].id, ClipboardFormatId::new(0xC0BC)); + assert_eq!( + decoded_formats[0].name.as_ref().unwrap().value(), + "FileGroupDescriptorW" + ); + } else { + panic!("Expected FormatList PDU"); + } +} + +#[test] +fn format_data_request_for_file_list() { + // Test requesting file list format data + let request = FormatDataRequest { + format: ClipboardFormatId::new(0xC0BC), + }; + + let pdu = ClipboardPdu::FormatDataRequest(request); + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + + let decoded: ClipboardPdu<'_> = ironrdp_core::decode(&encoded).unwrap(); + if let ClipboardPdu::FormatDataRequest(req) = decoded { + assert_eq!(req.format, ClipboardFormatId::new(0xC0BC)); + } else { + panic!("Expected FormatDataRequest PDU"); + } +} + +#[test] +fn format_data_response_with_file_list() { + // Test sending file list in format data response + let file_list = PackedFileList { + files: vec![ + FileDescriptor::new("file1.txt") + .with_attributes(ClipboardFileAttributes::ARCHIVE) + .with_last_write_time(129010042240261384) + .with_file_size(1024), + FileDescriptor::new("file2.dat") + .with_attributes(ClipboardFileAttributes::ARCHIVE) + .with_last_write_time(129010042240261384) + .with_file_size(2048), + ], + }; + + let response = FormatDataResponse::new_file_list(&file_list).unwrap(); + let pdu = ClipboardPdu::FormatDataResponse(response); + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + + let decoded: ClipboardPdu<'_> = ironrdp_core::decode(&encoded).unwrap(); + if let ClipboardPdu::FormatDataResponse(resp) = decoded { + assert!(!resp.is_error()); + + let decoded_list = resp.to_file_list().unwrap(); + assert_eq!(decoded_list.files.len(), 2); + assert_eq!(decoded_list.files[0].name, "file1.txt"); + assert_eq!(decoded_list.files[0].file_size, Some(1024)); + assert_eq!(decoded_list.files[1].name, "file2.dat"); + assert_eq!(decoded_list.files[1].file_size, Some(2048)); + } else { + panic!("Expected FormatDataResponse PDU"); + } +} + +#[test] +fn empty_file_list() { + // Test handling empty file lists gracefully + let file_list = PackedFileList { files: vec![] }; + + let response = FormatDataResponse::new_file_list(&file_list).unwrap(); + let pdu = ClipboardPdu::FormatDataResponse(response); + let encoded = ironrdp_core::encode_vec(&pdu).unwrap(); + + let decoded: ClipboardPdu<'_> = ironrdp_core::decode(&encoded).unwrap(); + if let ClipboardPdu::FormatDataResponse(resp) = decoded { + let decoded_list = resp.to_file_list().unwrap(); + assert_eq!(decoded_list.files.len(), 0); + } else { + panic!("Expected FormatDataResponse PDU"); + } +} + +#[test] +fn file_descriptor_with_minimal_metadata() { + // Test file descriptor with only filename (no attributes, size, or timestamp) + let file_desc = FileDescriptor::new("minimal.txt"); + + let encoded = ironrdp_core::encode_vec(&file_desc).unwrap(); + let decoded: FileDescriptor = ironrdp_core::decode(&encoded).unwrap(); + + assert_eq!(decoded.name, "minimal.txt"); + assert!(decoded.attributes.is_none()); + assert!(decoded.last_write_time.is_none()); + assert!(decoded.file_size.is_none()); +} + +#[test] +fn file_descriptor_preserves_metadata() { + // Test that all metadata fields are preserved through encode/decode + let original = FileDescriptor::new("test_file.doc") + .with_attributes(ClipboardFileAttributes::READONLY | ClipboardFileAttributes::HIDDEN) + .with_last_write_time(132489216000000000) // Some arbitrary timestamp + .with_file_size(9876543210); // Large file size + + let encoded = ironrdp_core::encode_vec(&original).unwrap(); + let decoded: FileDescriptor = ironrdp_core::decode(&encoded).unwrap(); + + assert_eq!(decoded.name, original.name); + assert_eq!(decoded.attributes, original.attributes); + assert_eq!(decoded.last_write_time, original.last_write_time); + assert_eq!(decoded.file_size, original.file_size); +} + +#[test] +fn packed_file_list_rejects_count_exceeding_max() { + // Craft a minimal buffer where cItems exceeds MAX_FILE_COUNT. + // PackedFileList::decode reads a u32 cItems first, then checks + // against MAX_FILE_COUNT before allocating. + let count = u32::try_from(MAX_FILE_COUNT + 1).unwrap(); + let buf = count.to_le_bytes(); + + let result = ironrdp_core::decode::(&buf); + assert!(result.is_err(), "decode must reject cItems > MAX_FILE_COUNT"); +} diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/file_transfer_capabilities.rs b/crates/ironrdp-testsuite-core/tests/clipboard/file_transfer_capabilities.rs new file mode 100644 index 0000000000..8a7d09399c --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/clipboard/file_transfer_capabilities.rs @@ -0,0 +1,251 @@ +use ironrdp_cliprdr::pdu::{ClipboardGeneralCapabilityFlags, *}; +use ironrdp_testsuite_core::encode_decode_test; + +// [MS-RDPECLIP] 2.2.2.1 General Capability Set (CLIPRDR_GENERAL_CAPABILITY) +// Tests for file transfer capability flags negotiation + +encode_decode_test! { + // Test all file transfer capability flags together + capabilities_all_file_transfer_flags: + ClipboardPdu::Capabilities( + Capabilities { + capabilities: vec![ + CapabilitySet::General( + GeneralCapabilitySet { + version: ClipboardProtocolVersion::V2, + general_flags: ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES + | ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + | ClipboardGeneralCapabilityFlags::FILECLIP_NO_FILE_PATHS + | ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA + | ClipboardGeneralCapabilityFlags::HUGE_FILE_SUPPORT_ENABLED, + } + ) + ] + } + ), + [ + // PartialHeader (8 bytes) + 0x07, 0x00, 0x00, 0x00, // msgType: CB_CLIP_CAPS (7) + 0x10, 0x00, 0x00, 0x00, // dataLen: 16 + // cCapabilitiesSets (2 bytes) + pad (2 bytes) + 0x01, 0x00, // cCapabilitiesSets: 1 + 0x00, 0x00, // pad + // CapabilitySet header + 0x01, 0x00, // capabilitySetType: CB_CAPSTYPE_GENERAL (1) + 0x0c, 0x00, // lengthCapability: 12 + // GeneralCapabilitySet + 0x02, 0x00, 0x00, 0x00, // version: CB_CAPS_VERSION_2 (2) + 0x3e, 0x00, 0x00, 0x00, // generalFlags: 0x3e + // = USE_LONG_FORMAT_NAMES (0x02) + // | STREAM_FILECLIP_ENABLED (0x04) + // | FILECLIP_NO_FILE_PATHS (0x08) + // | CAN_LOCK_CLIPDATA (0x10) + // | HUGE_FILE_SUPPORT_ENABLED (0x20) + ]; + + // Test minimal file transfer capabilities (streaming only) + capabilities_minimal_file_transfer: + ClipboardPdu::Capabilities( + Capabilities { + capabilities: vec![ + CapabilitySet::General( + GeneralCapabilitySet { + version: ClipboardProtocolVersion::V2, + general_flags: ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED, + } + ) + ] + } + ), + [ + 0x07, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x0c, 0x00, + 0x02, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, + ]; + + // Test file transfer with locking support + capabilities_with_locking: + ClipboardPdu::Capabilities( + Capabilities { + capabilities: vec![ + CapabilitySet::General( + GeneralCapabilitySet { + version: ClipboardProtocolVersion::V2, + general_flags: ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + | ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA, + } + ) + ] + } + ), + [ + 0x07, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x0c, 0x00, + 0x02, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, // 0x04 | 0x10 = 0x14 + ]; + + // Test huge file support + capabilities_huge_files: + ClipboardPdu::Capabilities( + Capabilities { + capabilities: vec![ + CapabilitySet::General( + GeneralCapabilitySet { + version: ClipboardProtocolVersion::V2, + general_flags: ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + | ClipboardGeneralCapabilityFlags::HUGE_FILE_SUPPORT_ENABLED, + } + ) + ] + } + ), + [ + 0x07, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x0c, 0x00, + 0x02, 0x00, 0x00, 0x00, 0x24, 0x00, 0x00, 0x00, // 0x04 | 0x20 = 0x24 + ]; +} + +#[test] +fn capability_flags_bitwise_operations() { + use ClipboardGeneralCapabilityFlags as Flags; + + // Test individual flag values match spec + assert_eq!(Flags::USE_LONG_FORMAT_NAMES.bits(), 0x0000_0002); + assert_eq!(Flags::STREAM_FILECLIP_ENABLED.bits(), 0x0000_0004); + assert_eq!(Flags::FILECLIP_NO_FILE_PATHS.bits(), 0x0000_0008); + assert_eq!(Flags::CAN_LOCK_CLIPDATA.bits(), 0x0000_0010); + assert_eq!(Flags::HUGE_FILE_SUPPORT_ENABLED.bits(), 0x0000_0020); + + // Test flag combinations + let all_file_flags = Flags::STREAM_FILECLIP_ENABLED + | Flags::FILECLIP_NO_FILE_PATHS + | Flags::CAN_LOCK_CLIPDATA + | Flags::HUGE_FILE_SUPPORT_ENABLED; + + assert_eq!(all_file_flags.bits(), 0x0000_003c); // 0x04 | 0x08 | 0x10 | 0x20 + + // Test flag checking + assert!(all_file_flags.contains(Flags::STREAM_FILECLIP_ENABLED)); + assert!(all_file_flags.contains(Flags::FILECLIP_NO_FILE_PATHS)); + assert!(all_file_flags.contains(Flags::CAN_LOCK_CLIPDATA)); + assert!(all_file_flags.contains(Flags::HUGE_FILE_SUPPORT_ENABLED)); + assert!(!all_file_flags.contains(Flags::USE_LONG_FORMAT_NAMES)); +} + +#[test] +fn capability_negotiation_downgrade() { + use ClipboardGeneralCapabilityFlags as Flags; + + // Client supports all file transfer features + let client_caps = Capabilities::new( + ClipboardProtocolVersion::V2, + Flags::USE_LONG_FORMAT_NAMES + | Flags::STREAM_FILECLIP_ENABLED + | Flags::FILECLIP_NO_FILE_PATHS + | Flags::CAN_LOCK_CLIPDATA + | Flags::HUGE_FILE_SUPPORT_ENABLED, + ); + + // Server only supports basic file streaming + let server_caps = Capabilities::new( + ClipboardProtocolVersion::V2, + Flags::USE_LONG_FORMAT_NAMES | Flags::STREAM_FILECLIP_ENABLED, + ); + + let mut negotiated = client_caps; + negotiated.downgrade(&server_caps); + + // After negotiation, only common flags should remain + let negotiated_flags = negotiated.flags(); + assert!(negotiated_flags.contains(Flags::USE_LONG_FORMAT_NAMES)); + assert!(negotiated_flags.contains(Flags::STREAM_FILECLIP_ENABLED)); + assert!(!negotiated_flags.contains(Flags::FILECLIP_NO_FILE_PATHS)); + assert!(!negotiated_flags.contains(Flags::CAN_LOCK_CLIPDATA)); + assert!(!negotiated_flags.contains(Flags::HUGE_FILE_SUPPORT_ENABLED)); +} + +#[test] +fn capability_negotiation_no_file_transfer() { + use ClipboardGeneralCapabilityFlags as Flags; + + // Client supports file transfer + let client_caps = Capabilities::new( + ClipboardProtocolVersion::V2, + Flags::USE_LONG_FORMAT_NAMES | Flags::STREAM_FILECLIP_ENABLED, + ); + + // Server does not support file transfer (text only) + let server_caps = Capabilities::new(ClipboardProtocolVersion::V2, Flags::USE_LONG_FORMAT_NAMES); + + let mut negotiated = client_caps; + negotiated.downgrade(&server_caps); + + // After negotiation, file transfer should be disabled + let negotiated_flags = negotiated.flags(); + assert!(negotiated_flags.contains(Flags::USE_LONG_FORMAT_NAMES)); + assert!(!negotiated_flags.contains(Flags::STREAM_FILECLIP_ENABLED)); +} + +#[test] +fn capability_version_downgrade() { + use ClipboardGeneralCapabilityFlags as Flags; + + // Client uses V2 + let client_caps = Capabilities::new(ClipboardProtocolVersion::V2, Flags::USE_LONG_FORMAT_NAMES); + + // Server uses V1 + let server_caps = Capabilities::new(ClipboardProtocolVersion::V1, Flags::USE_LONG_FORMAT_NAMES); + + let mut negotiated = client_caps; + negotiated.downgrade(&server_caps); + + // Version should downgrade to V1 + assert_eq!(negotiated.version(), ClipboardProtocolVersion::V1); +} + +/// [MS-RDPECLIP] 2.2.2.1.1.1 - The version field is for informational purposes +/// and MUST NOT be used to make protocol capability decisions. Unknown version +/// values must not cause decode failures. +#[test] +fn capability_unknown_version_accepted() { + use ironrdp_core::{Decode as _, Encode as _}; + + // Build a Capabilities PDU with a hypothetical version 3 + let caps = Capabilities::new( + ClipboardProtocolVersion::Unknown(3), + ClipboardGeneralCapabilityFlags::empty(), + ); + + let mut buf = vec![0u8; caps.size() + 2 /* msgType */]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + let pdu = ClipboardPdu::Capabilities(caps); + pdu.encode(&mut cursor).unwrap(); + + // Decode should succeed (not reject the PDU) + let mut read = ironrdp_core::ReadCursor::new(&buf); + let decoded = ClipboardPdu::decode(&mut read).unwrap(); + + match decoded { + ClipboardPdu::Capabilities(decoded_caps) => { + assert_eq!(decoded_caps.version(), ClipboardProtocolVersion::Unknown(3)); + } + other => panic!("expected Capabilities PDU, got {other:?}"), + } +} + +/// Unknown versions should downgrade to V1 during negotiation, +/// since they don't match V2. +#[test] +fn capability_unknown_version_downgrades() { + use ClipboardGeneralCapabilityFlags as Flags; + + let client_caps = Capabilities::new(ClipboardProtocolVersion::V2, Flags::USE_LONG_FORMAT_NAMES); + let server_caps = Capabilities::new(ClipboardProtocolVersion::Unknown(3), Flags::USE_LONG_FORMAT_NAMES); + + let mut negotiated = client_caps; + negotiated.downgrade(&server_caps); + + // Unknown version != V2, so downgrade picks V1 + assert_eq!(negotiated.version(), ClipboardProtocolVersion::V1); +} diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/lock_lifecycle.rs b/crates/ironrdp-testsuite-core/tests/clipboard/lock_lifecycle.rs new file mode 100644 index 0000000000..aecab1cbb6 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/clipboard/lock_lifecycle.rs @@ -0,0 +1,638 @@ +//! Tests for clipboard lock lifecycle: automatic lock creation via FormatList, +//! incoming lock snapshots, concurrent locks, incoming lock limits, and +//! lock expiry on clipboard change. +//! +//! Migrated from `ironrdp-cliprdr/src/lib.rs` inline `#[cfg(test)]` module. +//! Behavior assertions use the public API (returned PDUs, backend callbacks); +//! bookkeeping assertions (internal lock counts) use the `__test` feature gate. + +use std::sync::{Arc, Mutex}; + +use ironrdp_cliprdr::pdu::{ + Capabilities, ClipboardFormat, ClipboardFormatId, ClipboardFormatName, ClipboardGeneralCapabilityFlags, + ClipboardPdu, ClipboardProtocolVersion, FileContentsFlags, FileContentsRequest, FileDescriptor, FormatList, + LockDataId, PackedFileList, +}; +use ironrdp_cliprdr::{Cliprdr, CliprdrClient, CliprdrState}; +use ironrdp_core::Encode as _; +use ironrdp_svc::{SvcMessage, SvcProcessor as _}; + +use super::test_helpers::{CallbackTrackingBackend, LockingBackend, TestBackend}; + +/// Introduce `let` bindings for the encoded bytes and the decoded +/// [`ClipboardPdu`] in the caller's scope. Two names are required so +/// that the byte buffer outlives the borrowing PDU. +macro_rules! decode_pdu { + ($msg:expr => $bytes:ident, $pdu:ident) => { + let $bytes = ($msg).encode_unframed_pdu().unwrap(); + let $pdu = ironrdp_core::decode::>(&$bytes).unwrap(); + }; +} + +/// Helper: build a simple file list. +fn file_list(names: &[&str]) -> PackedFileList { + PackedFileList { + files: names.iter().map(|name| FileDescriptor::new(*name)).collect(), + } +} + +/// Helper: build a file list with sizes. +fn sized_file_list(entries: &[(&str, u64)]) -> PackedFileList { + PackedFileList { + files: entries + .iter() + .map(|(name, size)| FileDescriptor::new(*name).with_file_size(*size)) + .collect(), + } +} + +/// Helper: create a Format List PDU containing FileGroupDescriptorW. +fn file_format_list_buf() -> Vec { + let formats = vec![ClipboardFormat { + id: ClipboardFormatId(49171), + name: Some(ClipboardFormatName::new("FileGroupDescriptorW")), + }]; + + let format_list = FormatList::new_unicode(&formats, true).unwrap(); + let pdu = ClipboardPdu::FormatList(format_list); + let mut buf = vec![0u8; pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + pdu.encode(&mut cursor).unwrap(); + buf +} + +/// Helper: create a Format List PDU containing only text (no files). +fn text_format_list_buf() -> Vec { + let formats = vec![ClipboardFormat { + id: ClipboardFormatId(13), // CF_UNICODETEXT + name: None, + }]; + + let format_list = FormatList::new_unicode(&formats, true).unwrap(); + let pdu = ClipboardPdu::FormatList(format_list); + let mut buf = vec![0u8; pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + pdu.encode(&mut cursor).unwrap(); + buf +} + +/// Helper: set up a CliprdrClient in Ready state with CAN_LOCK_CLIPDATA. +fn ready_locking_client() -> CliprdrClient { + let mut cliprdr = CliprdrClient::new(Box::new(LockingBackend::new())); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + *cliprdr.__test_capabilities_mut() = Capabilities::new( + ClipboardProtocolVersion::V2, + ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA + | ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES + | ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED, + ); + cliprdr +} + +/// Helper: process a file FormatList and extract the lock ID from the +/// returned Lock PDU. +fn process_file_format_list(cliprdr: &mut CliprdrClient) -> u32 { + let messages: Vec = cliprdr.process(&file_format_list_buf()).unwrap(); + assert!(messages.len() >= 2, "expected FormatListResponse + LockData"); + decode_pdu!(messages[1] => _bytes, lock_pdu); + match lock_pdu { + ClipboardPdu::LockData(id) => id.0, + other => panic!("expected LockData PDU, got {other:?}"), + } +} + +// -- Automatic lock basics ------------------------------------------- + +#[test] +fn lock_without_capability() { + let mut cliprdr = CliprdrClient::new(Box::new(TestBackend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + + // No CAN_LOCK_CLIPDATA in default capabilities + assert!( + !cliprdr + .__test_capabilities() + .flags() + .contains(ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA) + ); + + let messages: Vec = cliprdr.process(&file_format_list_buf()).unwrap(); + + // Only FormatListResponse, no Lock PDU + assert_eq!(messages.len(), 1); + assert!(cliprdr.__test_outgoing_locks().is_empty()); +} + +#[test] +fn lock_with_capability() { + let mut cliprdr = ready_locking_client(); + + let messages: Vec = cliprdr.process(&file_format_list_buf()).unwrap(); + assert_eq!(messages.len(), 2); + + // Behavior: returned message is a Lock PDU + decode_pdu!(messages[1] => _bytes, pdu); + let clip_data_id = match pdu { + ClipboardPdu::LockData(id) => id.0, + other => panic!("expected LockData PDU, got {other:?}"), + }; + + // Bookkeeping + assert!(cliprdr.__test_outgoing_locks().contains_key(&clip_data_id)); + assert_eq!(cliprdr.__test_current_lock_id(), Some(clip_data_id)); +} + +#[test] +fn lock_expired_on_new_format_list() { + let cleared_ids = Arc::new(Mutex::new(Vec::new())); + let expired_ids = Arc::new(Mutex::new(Vec::new())); + let backend = CallbackTrackingBackend::with_expired_tracking(Arc::clone(&cleared_ids), Arc::clone(&expired_ids)); + + let mut cliprdr: CliprdrClient = Cliprdr::with_lock_timeouts( + Box::new(backend), + core::time::Duration::from_secs(60), + core::time::Duration::from_secs(600), + ); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + *cliprdr.__test_capabilities_mut() = Capabilities::new( + ClipboardProtocolVersion::V2, + ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA | ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES, + ); + + // Create lock via file FormatList + let clip_data_id = process_file_format_list(&mut cliprdr); + assert!(cliprdr.__test_outgoing_locks().contains_key(&clip_data_id)); + + // Simulate receiving a new text FormatList (clipboard changed, no files) + let messages: Vec = cliprdr.process(&text_format_list_buf()).unwrap(); + + // Behavior: only FormatListResponse sent (no immediate Unlock PDU, no new lock) + assert_eq!(messages.len(), 1); + + // Behavior: on_outgoing_locks_expired callback fired with our lock ID + { + let expired = expired_ids.lock().unwrap(); + assert_eq!(expired.len(), 1); + assert!(expired[0].iter().any(|id| id.0 == clip_data_id)); + } + + // Behavior: on_outgoing_locks_cleared has NOT fired (lock not yet removed) + assert!(cleared_ids.lock().unwrap().is_empty()); + + // Bookkeeping: lock still tracked but current_lock_id cleared + assert!(cliprdr.__test_outgoing_locks().contains_key(&clip_data_id)); + assert_eq!(cliprdr.__test_current_lock_id(), None); +} + +#[test] +fn file_contents_request_includes_clip_data_id() { + let mut cliprdr = ready_locking_client(); + + let fl = sized_file_list(&[("test.txt", 1024)]); + *cliprdr.__test_remote_file_list_mut() = Some(fl.clone()); + *cliprdr.__test_local_file_list_mut() = Some(fl); + + let clip_data_id = process_file_format_list(&mut cliprdr); + assert_eq!(cliprdr.__test_current_lock_id(), Some(clip_data_id)); + + let request = FileContentsRequest { + stream_id: 100, + index: 0, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: None, + }; + + let result = cliprdr.request_file_contents(request); + assert!(result.is_ok()); + + let messages = Vec::from(result.unwrap()); + assert_eq!(messages.len(), 1); +} + +// -- Incoming lock snapshots ----------------------------------------- + +#[test] +fn lock_pdu_creates_file_list_snapshot() { + let mut cliprdr = CliprdrClient::new(Box::new(TestBackend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + *cliprdr.__test_local_file_list_mut() = Some(file_list(&["file1.txt", "file2.txt"])); + + assert_eq!(cliprdr.__test_locked_file_lists().len(), 0); + + let lock_pdu = ClipboardPdu::LockData(LockDataId(42)); + let mut buf = vec![0u8; lock_pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + lock_pdu.encode(&mut cursor).unwrap(); + + let result = cliprdr.process(&buf); + assert!(result.is_ok()); + + assert_eq!(cliprdr.__test_locked_file_lists().len(), 1); + let snapshot = &cliprdr.__test_locked_file_lists()[&42]; + assert_eq!(snapshot.files.len(), 2); + assert_eq!(snapshot.files[0].name, "file1.txt"); + assert_eq!(snapshot.files[1].name, "file2.txt"); +} + +#[test] +fn unlock_pdu_removes_file_list_snapshot() { + let mut cliprdr = CliprdrClient::new(Box::new(TestBackend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + + let fl = file_list(&["test.txt"]); + *cliprdr.__test_local_file_list_mut() = Some(fl.clone()); + cliprdr.__test_locked_file_lists_mut().insert(99, fl); + + assert_eq!(cliprdr.__test_locked_file_lists().len(), 1); + + let unlock_pdu = ClipboardPdu::UnlockData(LockDataId(99)); + let mut buf = vec![0u8; unlock_pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + unlock_pdu.encode(&mut cursor).unwrap(); + + let result = cliprdr.process(&buf); + assert!(result.is_ok()); + assert_eq!(cliprdr.__test_locked_file_lists().len(), 0); +} + +#[test] +fn file_contents_request_with_valid_clip_data_id() { + let mut cliprdr = CliprdrClient::new(Box::new(TestBackend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + + cliprdr + .__test_locked_file_lists_mut() + .insert(123, sized_file_list(&[("locked.txt", 500)])); + + *cliprdr.__test_local_file_list_mut() = Some(sized_file_list(&[("current.txt", 1000)])); + + let request = FileContentsRequest { + stream_id: 200, + index: 0, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: Some(123), + }; + + let pdu = ClipboardPdu::FileContentsRequest(request); + let mut buf = vec![0u8; pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + pdu.encode(&mut cursor).unwrap(); + + let result = cliprdr.process(&buf); + assert!(result.is_ok()); +} + +#[test] +fn file_contents_request_with_invalid_clip_data_id() { + let mut cliprdr = CliprdrClient::new(Box::new(TestBackend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + + cliprdr + .__test_locked_file_lists_mut() + .insert(123, sized_file_list(&[("locked.txt", 500)])); + + let request = FileContentsRequest { + stream_id: 300, + index: 0, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: Some(999), // Invalid ID + }; + + let pdu = ClipboardPdu::FileContentsRequest(request); + let mut buf = vec![0u8; pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + pdu.encode(&mut cursor).unwrap(); + + let result = cliprdr.process(&buf); + assert!(result.is_ok()); + + let messages = result.unwrap(); + assert_eq!(messages.len(), 1); +} + +#[test] +fn locked_file_list_persists_after_clipboard_change() { + let mut cliprdr = CliprdrClient::new(Box::new(TestBackend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + + *cliprdr.__test_local_file_list_mut() = Some(sized_file_list(&[("original.txt", 100)])); + + let lock_pdu = ClipboardPdu::LockData(LockDataId(555)); + let mut buf = vec![0u8; lock_pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + lock_pdu.encode(&mut cursor).unwrap(); + cliprdr.process(&buf).unwrap(); + + // Change the local file list (simulating clipboard update) + *cliprdr.__test_local_file_list_mut() = Some(sized_file_list(&[("new.txt", 200)])); + + // Verify locked snapshot still has original file + let locked_snapshot = &cliprdr.__test_locked_file_lists()[&555]; + assert_eq!(locked_snapshot.files.len(), 1); + assert_eq!(locked_snapshot.files[0].name, "original.txt"); + assert_eq!(locked_snapshot.files[0].file_size, Some(100)); + + // FileContentsRequest with clipDataId should use original file + let request = FileContentsRequest { + stream_id: 400, + index: 0, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: Some(555), + }; + + let pdu = ClipboardPdu::FileContentsRequest(request); + let mut req_buf = vec![0u8; pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut req_buf); + pdu.encode(&mut cursor).unwrap(); + + let result = cliprdr.process(&req_buf); + assert!(result.is_ok()); +} + +// -- Lock replacement on successive FormatLists ---------------------- + +#[test] +fn successive_file_format_lists_create_new_locks() { + let mut cliprdr = ready_locking_client(); + + // First file FormatList -> automatic lock + let id1 = process_file_format_list(&mut cliprdr); + assert_eq!(cliprdr.__test_outgoing_locks().len(), 1); + + // Second file FormatList -> new lock, first expired + let id2 = process_file_format_list(&mut cliprdr); + assert_ne!(id1, id2); + assert_eq!(cliprdr.__test_outgoing_locks().len(), 2); + assert_eq!(cliprdr.__test_current_lock_id(), Some(id2)); + + // Third file FormatList + let id3 = process_file_format_list(&mut cliprdr); + assert_ne!(id2, id3); + assert_eq!(cliprdr.__test_outgoing_locks().len(), 3); + assert_eq!(cliprdr.__test_current_lock_id(), Some(id3)); +} + +#[test] +fn incoming_lock_limit_exceeded() { + let mut cliprdr = CliprdrClient::new(Box::new(TestBackend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + *cliprdr.__test_local_file_list_mut() = Some(sized_file_list(&[("test.txt", 100)])); + + assert_eq!(cliprdr.__test_locked_file_lists().len(), 0); + + // Process 100 incoming Lock PDUs (should all succeed) + for i in 1..=100 { + let lock_pdu = ClipboardPdu::LockData(LockDataId(i)); + let mut buf = vec![0u8; lock_pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + lock_pdu.encode(&mut cursor).unwrap(); + let result = cliprdr.process(&buf); + assert!(result.is_ok()); + } + + assert_eq!(cliprdr.__test_locked_file_lists().len(), 100); + + // 101st Lock PDU should be rejected silently + let lock_pdu = ClipboardPdu::LockData(LockDataId(101)); + let mut buf = vec![0u8; lock_pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + lock_pdu.encode(&mut cursor).unwrap(); + + let result = cliprdr.process(&buf); + assert!(result.is_ok()); + assert_eq!(cliprdr.__test_locked_file_lists().len(), 100); + assert!(!cliprdr.__test_locked_file_lists().contains_key(&101)); + + assert!(cliprdr.__test_locked_file_lists().contains_key(&1)); + assert!(cliprdr.__test_locked_file_lists().contains_key(&50)); + assert!(cliprdr.__test_locked_file_lists().contains_key(&100)); +} + +#[test] +fn all_locks_expired_on_text_format_list() { + let cleared_ids = Arc::new(Mutex::new(Vec::new())); + let expired_ids = Arc::new(Mutex::new(Vec::new())); + let backend = CallbackTrackingBackend::with_expired_tracking(Arc::clone(&cleared_ids), Arc::clone(&expired_ids)); + + let mut cliprdr: CliprdrClient = Cliprdr::with_lock_timeouts( + Box::new(backend), + core::time::Duration::from_secs(60), + core::time::Duration::from_secs(600), + ); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + *cliprdr.__test_capabilities_mut() = Capabilities::new( + ClipboardProtocolVersion::V2, + ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA | ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES, + ); + + // Create 3 locks via successive file FormatLists + let _id1 = process_file_format_list(&mut cliprdr); + let _id2 = process_file_format_list(&mut cliprdr); + let id3 = process_file_format_list(&mut cliprdr); + + // The first two FormatLists expired the previous lock(s), so clear the callback log + expired_ids.lock().unwrap().clear(); + + assert_eq!(cliprdr.__test_outgoing_locks().len(), 3); + + // Text FormatList -> all locks expired + let messages: Vec = cliprdr.process(&text_format_list_buf()).unwrap(); + + // Behavior: only FormatListResponse sent (no immediate Unlock PDUs) + assert_eq!(messages.len(), 1); + + // Behavior: expired callback fired with the remaining active lock (id3) + // (id1 and id2 were already expired by successive file FormatLists) + { + let expired = expired_ids.lock().unwrap(); + assert_eq!(expired.len(), 1, "expired callback should fire once"); + // Only id3 was still Active when the text FormatList arrived + assert!(expired[0].iter().any(|id| id.0 == id3)); + } + + // Behavior: cleared callback has NOT fired (cleanup hasn't run) + assert!(cleared_ids.lock().unwrap().is_empty()); + + // Bookkeeping: locks still tracked, current_lock_id cleared + assert_eq!(cliprdr.__test_outgoing_locks().len(), 3); + assert!(cliprdr.__test_current_lock_id().is_none()); +} + +// -- Callback tracking ----------------------------------------------- + +#[test] +fn on_outgoing_locks_cleared_callback_invoked() { + let cleared_ids = Arc::new(Mutex::new(Vec::new())); + let backend = CallbackTrackingBackend::new(Arc::clone(&cleared_ids)); + + let mut cliprdr: CliprdrClient = Cliprdr::with_lock_timeouts( + Box::new(backend), + core::time::Duration::from_millis(20), // inactivity: 20ms + core::time::Duration::from_secs(10), // max: 10s + ); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + *cliprdr.__test_capabilities_mut() = Capabilities::new( + ClipboardProtocolVersion::V2, + ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA | ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES, + ); + + // Create lock via file FormatList + let lock_id = process_file_format_list(&mut cliprdr); + assert_eq!(cliprdr.__test_outgoing_locks().len(), 1); + assert!( + cleared_ids.lock().unwrap().is_empty(), + "Callback should not be called yet" + ); + + // Text FormatList expires the lock + cliprdr.process(&text_format_list_buf()).unwrap(); + + // Callback should NOT be called yet (locks are expired, not cleaned up) + assert!( + cleared_ids.lock().unwrap().is_empty(), + "Callback should not be called until drive_timeouts() is called" + ); + assert_eq!(cliprdr.__test_outgoing_locks().len(), 1, "Locks should still exist"); + + // Advance mock clock past inactivity timeout (20ms) + let backend = cliprdr.downcast_backend::().unwrap(); + backend.advance_ms(50); + + let _cleanup_messages = cliprdr.drive_timeouts().unwrap(); + + let callbacks = cleared_ids.lock().unwrap(); + assert_eq!(callbacks.len(), 1, "Callback should be called once after cleanup"); + + let cleared = &callbacks[0]; + assert_eq!(cleared.len(), 1, "Should have cleared 1 lock"); + assert!( + cleared.iter().any(|id| id.0 == lock_id), + "Cleared IDs should contain lock ID {lock_id}" + ); + + drop(callbacks); + assert!(cliprdr.__test_outgoing_locks().is_empty()); + assert_eq!(cliprdr.__test_current_lock_id(), None); +} + +#[test] +fn on_outgoing_locks_expired_callback_invoked() { + let cleared_ids = Arc::new(Mutex::new(Vec::new())); + let expired_ids = Arc::new(Mutex::new(Vec::new())); + let backend = CallbackTrackingBackend::with_expired_tracking(Arc::clone(&cleared_ids), Arc::clone(&expired_ids)); + + let mut cliprdr: CliprdrClient = Cliprdr::with_lock_timeouts( + Box::new(backend), + core::time::Duration::from_millis(20), + core::time::Duration::from_secs(10), + ); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + *cliprdr.__test_capabilities_mut() = Capabilities::new( + ClipboardProtocolVersion::V2, + ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA | ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES, + ); + + // Create lock via file FormatList + let lock_id = process_file_format_list(&mut cliprdr); + + // No callbacks yet + assert!(expired_ids.lock().unwrap().is_empty()); + + // Text FormatList triggers expire_all_locks -> on_outgoing_locks_expired + cliprdr.process(&text_format_list_buf()).unwrap(); + + // Expired callback should have fired with the lock ID + { + let callbacks = expired_ids.lock().unwrap(); + assert_eq!(callbacks.len(), 1, "expired callback should fire once"); + + let expired = &callbacks[0]; + assert_eq!(expired.len(), 1, "one lock should be expired"); + assert!(expired.iter().any(|id| id.0 == lock_id)); + } + + // Cleared callback should NOT have fired yet (cleanup hasn't run) + assert!(cleared_ids.lock().unwrap().is_empty()); +} + +// -- Taking clipboard ownership releases stale download locks -------- + +/// When the local side initiates a file copy (an upload), it takes clipboard +/// ownership — so the outgoing locks placed for downloads from the previous owner are +/// now stale. They must be released with `Unlock` PDUs that PRECEDE our `FormatList` on +/// the wire; otherwise the server keeps tracking a lock for a download that will never +/// complete, which desyncs its clipboard state. +#[test] +fn initiate_file_copy_releases_outgoing_download_locks() { + let mut cliprdr = ready_locking_client(); + + // Two remote file lists => two outgoing download locks (e.g. two in-flight downloads). + let lock1 = process_file_format_list(&mut cliprdr); + let lock2 = process_file_format_list(&mut cliprdr); + assert_eq!(cliprdr.__test_outgoing_locks().len(), 2); + + let messages: Vec = cliprdr + .initiate_file_copy(vec![FileDescriptor::new("upload.txt")]) + .unwrap() + .into(); + + // Every outgoing lock is released and the current lock id is cleared. + assert!( + cliprdr.__test_outgoing_locks().is_empty(), + "outgoing locks must be released when taking clipboard ownership" + ); + assert_eq!(cliprdr.__test_current_lock_id(), None); + + // Wire order: an Unlock for each held lock, THEN our FormatList last. + assert_eq!(messages.len(), 3, "expected 2 Unlock PDUs + 1 FormatList"); + + decode_pdu!(messages[0] => _b0, pdu0); + let id0 = match pdu0 { + ClipboardPdu::UnlockData(id) => id.0, + other => panic!("expected UnlockData first, got {other:?}"), + }; + decode_pdu!(messages[1] => _b1, pdu1); + let id1 = match pdu1 { + ClipboardPdu::UnlockData(id) => id.0, + other => panic!("expected UnlockData second, got {other:?}"), + }; + let mut unlocked = [id0, id1]; + unlocked.sort_unstable(); + let mut expected = [lock1, lock2]; + expected.sort_unstable(); + assert_eq!(unlocked, expected, "both download locks must be unlocked"); + + decode_pdu!(messages[2] => _b2, last_pdu); + assert!( + matches!(last_pdu, ClipboardPdu::FormatList(_)), + "FormatList must come after the Unlock PDUs, got {last_pdu:?}" + ); +} + +/// With no outgoing locks held, `initiate_file_copy` sends only the `FormatList` +/// (no spurious `Unlock`). +#[test] +fn initiate_file_copy_without_locks_sends_only_format_list() { + let mut cliprdr = ready_locking_client(); + assert!(cliprdr.__test_outgoing_locks().is_empty()); + + let messages: Vec = cliprdr + .initiate_file_copy(vec![FileDescriptor::new("upload.txt")]) + .unwrap() + .into(); + + assert_eq!(messages.len(), 1, "expected only a FormatList when no locks are held"); + decode_pdu!(messages[0] => _b0, pdu); + assert!( + matches!(pdu, ClipboardPdu::FormatList(_)), + "expected FormatList, got {pdu:?}" + ); +} diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/lock_strategy.rs b/crates/ironrdp-testsuite-core/tests/clipboard/lock_strategy.rs new file mode 100644 index 0000000000..afc15701ba --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/clipboard/lock_strategy.rs @@ -0,0 +1,157 @@ +//! Tests for automatic lock behavior on incoming FormatList processing. +//! +//! Migrated from `ironrdp-cliprdr/src/lib.rs` inline `#[cfg(test)]` module. +//! Behavior assertions use the public API (returned PDUs, message counts); +//! bookkeeping assertions (internal lock counts) use the `__test` feature gate. + +use ironrdp_cliprdr::pdu::{ + Capabilities, ClipboardFormat, ClipboardFormatId, ClipboardFormatName, ClipboardGeneralCapabilityFlags, + ClipboardPdu, ClipboardProtocolVersion, FormatList, +}; +use ironrdp_cliprdr::{CliprdrClient, CliprdrState}; +use ironrdp_core::Encode as _; +use ironrdp_svc::{SvcMessage, SvcProcessor as _}; + +use super::test_helpers::TestBackend; + +/// Introduce `let` bindings for the encoded bytes and the decoded +/// [`ClipboardPdu`] in the caller's scope. Two names are required so +/// that the byte buffer outlives the borrowing PDU. +macro_rules! decode_pdu { + ($msg:expr => $bytes:ident, $pdu:ident) => { + let $bytes = ($msg).encode_unframed_pdu().unwrap(); + let $pdu = ironrdp_core::decode::>(&$bytes).unwrap(); + }; +} + +/// Helper: create a Format List PDU containing FileGroupDescriptorW. +fn file_format_list_buf() -> Vec { + let formats = vec![ClipboardFormat { + id: ClipboardFormatId(49171), + name: Some(ClipboardFormatName::new("FileGroupDescriptorW")), + }]; + + let format_list = FormatList::new_unicode(&formats, true).unwrap(); + let pdu = ClipboardPdu::FormatList(format_list); + let mut buf = vec![0u8; pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + pdu.encode(&mut cursor).unwrap(); + buf +} + +/// Helper: create a Format List PDU containing only text (no files). +fn text_format_list_buf() -> Vec { + let formats = vec![ClipboardFormat { + id: ClipboardFormatId(13), // CF_UNICODETEXT + name: None, + }]; + + let format_list = FormatList::new_unicode(&formats, true).unwrap(); + let pdu = ClipboardPdu::FormatList(format_list); + let mut buf = vec![0u8; pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + pdu.encode(&mut cursor).unwrap(); + buf +} + +/// Helper: set up a CliprdrClient with locking capability in Ready state. +fn ready_client(can_lock: bool) -> CliprdrClient { + let mut cliprdr = CliprdrClient::new(Box::new(TestBackend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + + let mut flags = ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES; + if can_lock { + flags |= ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA; + } + *cliprdr.__test_capabilities_mut() = Capabilities::new(ClipboardProtocolVersion::V2, flags); + + cliprdr +} + +#[test] +fn automatic_lock() { + let mut cliprdr = ready_client(true); + let buf = file_format_list_buf(); + + let messages: Vec = cliprdr.process(&buf).unwrap(); + + // Behavior: FormatListResponse + Lock PDU + assert_eq!(messages.len(), 2, "should have FormatListResponse and Lock"); + decode_pdu!(messages[0] => _bytes0, pdu0); + assert!(matches!(pdu0, ClipboardPdu::FormatListResponse(_))); + + decode_pdu!(messages[1] => _bytes1, lock_pdu); + let lock_id = match lock_pdu { + ClipboardPdu::LockData(id) => id, + other => panic!("expected LockData PDU, got {other:?}"), + }; + + // Bookkeeping: one lock tracked, IDs consistent + assert_eq!(cliprdr.__test_outgoing_locks().len(), 1); + assert!(cliprdr.__test_outgoing_locks().contains_key(&lock_id.0)); +} + +#[test] +fn lock_without_capability() { + let mut cliprdr = ready_client(false); + let buf = file_format_list_buf(); + + let messages: Vec = cliprdr.process(&buf).unwrap(); + + // Behavior: no Lock PDU when CAN_LOCK_CLIPDATA not negotiated + assert_eq!(messages.len(), 1, "should only have FormatListResponse"); + decode_pdu!(messages[0] => _bytes, pdu); + assert!(matches!(pdu, ClipboardPdu::FormatListResponse(_))); + + // Bookkeeping + assert_eq!(cliprdr.__test_outgoing_locks().len(), 0); +} + +#[test] +fn new_format_list_replaces_old_lock() { + let mut cliprdr = ready_client(true); + + // First FormatList with files -> gets a Lock PDU + let msgs1: Vec = cliprdr.process(&file_format_list_buf()).unwrap(); + assert_eq!(msgs1.len(), 2); + decode_pdu!(msgs1[1] => _bytes1, pdu1); + let first_lock_id = match pdu1 { + ClipboardPdu::LockData(id) => id, + other => panic!("expected LockData, got {other:?}"), + }; + + // Second FormatList with files -> new Lock PDU, old lock expired + let msgs2: Vec = cliprdr.process(&file_format_list_buf()).unwrap(); + assert_eq!(msgs2.len(), 2, "should have FormatListResponse + new Lock"); + decode_pdu!(msgs2[1] => _bytes2, pdu2); + let second_lock_id = match pdu2 { + ClipboardPdu::LockData(id) => id, + other => panic!("expected LockData, got {other:?}"), + }; + + // Behavior: different lock IDs issued + assert_ne!( + first_lock_id, second_lock_id, + "lock ID should change on clipboard change" + ); + + // Bookkeeping: both locks tracked (first expired, second active) + assert_eq!(cliprdr.__test_outgoing_locks().len(), 2); + assert!(cliprdr.__test_outgoing_locks().contains_key(&second_lock_id.0)); +} + +#[test] +fn no_lock_for_non_file_formats() { + let mut cliprdr = ready_client(true); + let buf = text_format_list_buf(); + + let messages: Vec = cliprdr.process(&buf).unwrap(); + + // Behavior: no Lock PDU for text-only clipboard + assert_eq!(messages.len(), 1, "should only have FormatListResponse"); + decode_pdu!(messages[0] => _bytes, pdu); + assert!(matches!(pdu, ClipboardPdu::FormatListResponse(_))); + + // Bookkeeping + assert_eq!(cliprdr.__test_outgoing_locks().len(), 0); +} diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/lock_timeout.rs b/crates/ironrdp-testsuite-core/tests/clipboard/lock_timeout.rs new file mode 100644 index 0000000000..f09995b78c --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/clipboard/lock_timeout.rs @@ -0,0 +1,248 @@ +//! Tests for lock timeout and manual cleanup behavior. +//! +//! Migrated from `ironrdp-cliprdr/src/lib.rs` inline `#[cfg(test)]` module. +//! Behavior assertions verify returned PDUs and backend callbacks; +//! bookkeeping assertions (internal lock counts, throttle state) use the +//! `__test` feature gate. + +use std::sync::{Arc, Mutex}; + +use ironrdp_cliprdr::pdu::{ + Capabilities, ClipboardFormat, ClipboardFormatId, ClipboardFormatName, ClipboardGeneralCapabilityFlags, + ClipboardPdu, ClipboardProtocolVersion, FileContentsFlags, FileContentsRequest, FileDescriptor, FormatList, + PackedFileList, +}; +use ironrdp_cliprdr::{Cliprdr, CliprdrClient, CliprdrState}; +use ironrdp_core::Encode as _; +use ironrdp_svc::{SvcMessage, SvcProcessor as _}; + +use super::test_helpers::{CallbackTrackingBackend, LockingBackend}; + +/// Introduce `let` bindings for the encoded bytes and the decoded +/// [`ClipboardPdu`] in the caller's scope. Two names are required so +/// that the byte buffer outlives the borrowing PDU. +macro_rules! decode_pdu { + ($msg:expr => $bytes:ident, $pdu:ident) => { + let $bytes = ($msg).encode_unframed_pdu().unwrap(); + let $pdu = ironrdp_core::decode::>(&$bytes).unwrap(); + }; +} + +/// Helper: build a file list with sizes. +fn sized_file_list(entries: &[(&str, u64)]) -> PackedFileList { + PackedFileList { + files: entries + .iter() + .map(|(name, size)| FileDescriptor::new(*name).with_file_size(*size)) + .collect(), + } +} + +/// Helper: create a Format List PDU containing FileGroupDescriptorW. +fn file_format_list_buf() -> Vec { + let formats = vec![ClipboardFormat { + id: ClipboardFormatId(49171), + name: Some(ClipboardFormatName::new("FileGroupDescriptorW")), + }]; + + let format_list = FormatList::new_unicode(&formats, true).unwrap(); + let pdu = ClipboardPdu::FormatList(format_list); + let mut buf = vec![0u8; pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + pdu.encode(&mut cursor).unwrap(); + buf +} + +/// Helper: create a Format List PDU containing only text (no files). +fn text_format_list_buf() -> Vec { + let formats = vec![ClipboardFormat { + id: ClipboardFormatId(13), // CF_UNICODETEXT + name: None, + }]; + + let format_list = FormatList::new_unicode(&formats, true).unwrap(); + let pdu = ClipboardPdu::FormatList(format_list); + let mut buf = vec![0u8; pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + pdu.encode(&mut cursor).unwrap(); + buf +} + +/// Helper: set up a CliprdrClient with lock timeouts and a mock clock backend. +fn timed_locking_client(inactivity_ms: u64, max_ms: u64) -> CliprdrClient { + let mut cliprdr: CliprdrClient = Cliprdr::with_lock_timeouts( + Box::new(LockingBackend::new()), + core::time::Duration::from_millis(inactivity_ms), + core::time::Duration::from_millis(max_ms), + ); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + *cliprdr.__test_capabilities_mut() = Capabilities::new( + ClipboardProtocolVersion::V2, + ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA + | ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + | ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES, + ); + cliprdr +} + +/// Helper: process a file FormatList and extract the lock ID from the +/// returned Lock PDU. +fn process_file_format_list(cliprdr: &mut CliprdrClient) -> u32 { + let messages: Vec = cliprdr.process(&file_format_list_buf()).unwrap(); + assert!(messages.len() >= 2, "expected FormatListResponse + LockData"); + decode_pdu!(messages[1] => _bytes, lock_pdu); + match lock_pdu { + ClipboardPdu::LockData(id) => id.0, + other => panic!("expected LockData PDU, got {other:?}"), + } +} + +/// Helper: expire a lock by processing a text FormatList. +fn expire_via_text_format_list(cliprdr: &mut CliprdrClient) { + cliprdr.process(&text_format_list_buf()).unwrap(); +} + +// -- Activity & timeout ---------------------------------------------- + +#[test] +fn lock_activity_prevents_timeout() { + let mut cliprdr = timed_locking_client(200, 10_000); + *cliprdr.__test_remote_file_list_mut() = Some(sized_file_list(&[("test.txt", 1000)])); + + let clip_data_id = process_file_format_list(&mut cliprdr); + + // Expire the lock via text FormatList (simulates clipboard change) + expire_via_text_format_list(&mut cliprdr); + + // Advance 50ms before making request + cliprdr.downcast_backend::().unwrap().advance_ms(50); + + let request = FileContentsRequest { + stream_id: 1, + index: 0, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: Some(clip_data_id), + }; + let _result = cliprdr.request_file_contents(request).unwrap(); + + // Cleanup should not remove lock (activity happened < 200ms ago) + let messages: Vec = Vec::from(cliprdr.drive_timeouts().unwrap()); + assert_eq!(messages.len(), 0, "no locks should be cleaned up yet"); + + // Bookkeeping: lock still tracked + assert!(cliprdr.__test_outgoing_locks().contains_key(&clip_data_id)); + + // Advance past inactivity timeout (250ms more, total 300ms since start) + cliprdr.downcast_backend::().unwrap().advance_ms(250); + + // Behavior: cleanup returns an Unlock PDU for the expired lock + let messages: Vec = Vec::from(cliprdr.drive_timeouts().unwrap()); + assert_eq!(messages.len(), 1, "one lock should be cleaned up"); + decode_pdu!(messages[0] => _bytes, pdu); + match pdu { + ClipboardPdu::UnlockData(id) => assert_eq!(id.0, clip_data_id), + other => panic!("expected UnlockData PDU, got {other:?}"), + } + + // Bookkeeping: lock removed + assert!(!cliprdr.__test_outgoing_locks().contains_key(&clip_data_id)); +} + +#[test] +fn max_lifetime_forces_cleanup() { + let mut cliprdr = timed_locking_client(60_000, 150); + + let fl = sized_file_list(&[("test.txt", 1000)]); + *cliprdr.__test_local_file_list_mut() = Some(fl.clone()); + *cliprdr.__test_remote_file_list_mut() = Some(fl); + + let clip_data_id = process_file_format_list(&mut cliprdr); + + // Expire the lock via text FormatList + expire_via_text_format_list(&mut cliprdr); + + // Simulate active transfer with 40ms intervals via mock clock + for i in 0..5 { + cliprdr.downcast_backend::().unwrap().advance_ms(40); + + let request = FileContentsRequest { + stream_id: i + 1, + index: 0, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: Some(clip_data_id), + }; + let _result = cliprdr.request_file_contents(request).unwrap(); + + let messages: Vec = Vec::from(cliprdr.drive_timeouts().unwrap()); + if !messages.is_empty() { + // Behavior: cleanup returns an Unlock PDU for the expired lock + assert_eq!(messages.len(), 1); + decode_pdu!(messages[0] => _bytes, pdu); + match pdu { + ClipboardPdu::UnlockData(id) => assert_eq!(id.0, clip_data_id), + other => panic!("expected UnlockData PDU, got {other:?}"), + } + assert!(!cliprdr.__test_outgoing_locks().contains_key(&clip_data_id)); + return; // Test passed + } + } + + // Final check after all iterations (total 200ms > 150ms max lifetime) + let messages: Vec = Vec::from(cliprdr.drive_timeouts().unwrap()); + assert_eq!( + messages.len(), + 1, + "lock should be cleaned up after max lifetime even with activity" + ); + decode_pdu!(messages[0] => _bytes, pdu); + match pdu { + ClipboardPdu::UnlockData(id) => assert_eq!(id.0, clip_data_id), + other => panic!("expected UnlockData PDU, got {other:?}"), + } + assert!(!cliprdr.__test_outgoing_locks().contains_key(&clip_data_id)); +} + +#[test] +fn expire_via_format_list_transitions_state() { + let cleared_ids = Arc::new(Mutex::new(Vec::new())); + let expired_ids = Arc::new(Mutex::new(Vec::new())); + let backend = CallbackTrackingBackend::with_expired_tracking(Arc::clone(&cleared_ids), Arc::clone(&expired_ids)); + + let mut cliprdr: CliprdrClient = Cliprdr::with_lock_timeouts( + Box::new(backend), + core::time::Duration::from_secs(60), + core::time::Duration::from_secs(600), + ); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + *cliprdr.__test_capabilities_mut() = Capabilities::new( + ClipboardProtocolVersion::V2, + ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA | ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES, + ); + + // Create lock via file FormatList + let id1 = process_file_format_list(&mut cliprdr); + + // No expired callback yet (only lock creation) + assert!(expired_ids.lock().unwrap().is_empty()); + + // Text FormatList triggers expire_all_locks -> on_outgoing_locks_expired + expire_via_text_format_list(&mut cliprdr); + + // Behavior: expired callback fired with the lock ID + { + let callbacks = expired_ids.lock().unwrap(); + assert_eq!(callbacks.len(), 1, "expired callback should fire once"); + assert_eq!(callbacks[0].len(), 1, "one lock should be expired"); + assert!(callbacks[0].iter().any(|id| id.0 == id1)); + } + + // Behavior: cleared callback has NOT fired + assert!(cleared_ids.lock().unwrap().is_empty()); + + // Bookkeeping: lock still tracked (expired, not removed) + assert_eq!(cliprdr.__test_outgoing_locks().len(), 1); +} diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/mod.rs b/crates/ironrdp-testsuite-core/tests/clipboard/mod.rs index bbcee7af7f..40c860a444 100644 --- a/crates/ironrdp-testsuite-core/tests/clipboard/mod.rs +++ b/crates/ironrdp-testsuite-core/tests/clipboard/mod.rs @@ -1,4 +1,18 @@ +mod delayed_rendering; +mod delayed_rendering_integration; +mod file_contents_state_machine; +mod file_contents_validation; +mod file_list_format; +mod file_transfer_capabilities; mod format; +mod lock_lifecycle; +mod lock_strategy; +mod lock_timeout; +mod path_sanitization; +mod preferred_drop_effect; +mod server_role; +mod test_helpers; +mod upload_and_cleanup; use expect_test::expect; use ironrdp_cliprdr::pdu::{ @@ -94,11 +108,11 @@ encode_decode_test! { 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, ]; - file_contents_request_data: + file_contents_request_range: ClipboardPdu::FileContentsRequest(FileContentsRequest { stream_id: 2, index: 1, - flags: FileContentsFlags::DATA, + flags: FileContentsFlags::RANGE, position: 0, requested_size: 65536, data_id: None, @@ -320,13 +334,11 @@ fn fake_format_list(use_ascii: bool, use_long_format: bool) -> FormatList<'stati ClipboardFormat::new(ClipboardFormatId::new(11)).with_name(ClipboardFormatName::new("World")), ]; - let list = if use_ascii { + if use_ascii { FormatList::new_ascii(&formats, use_long_format).unwrap() } else { FormatList::new_unicode(&formats, use_long_format).unwrap() - }; - - list + } } #[test] @@ -356,7 +368,7 @@ fn metafile_pdu_ms() { assert_eq!(metafile.y_ext, 423); // Just check some known arbitrary byte in raw metafile data - assert_eq!(metafile.data()[metafile.data().len() - 6], 0x03); + assert_eq!(metafile.data[metafile.data.len() - 6], 0x03); } else { panic!("Expected FormatDataResponse"); }; @@ -417,6 +429,7 @@ fn file_list_pdu_ms() { 44, ), name: "File1.txt", + relative_path: None, }, FileDescriptor { attributes: Some( @@ -431,6 +444,7 @@ fn file_list_pdu_ms() { 10, ), name: "File2.txt", + relative_path: None, }, ] "#]] diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/path_sanitization.rs b/crates/ironrdp-testsuite-core/tests/clipboard/path_sanitization.rs new file mode 100644 index 0000000000..39a52a612e --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/clipboard/path_sanitization.rs @@ -0,0 +1,309 @@ +//! Tests for the `is_absolute_path`, `sanitize_file_path`, and +//! `is_windows_device_name` security helpers. +//! +//! These functions are private implementation details of `ironrdp-cliprdr`, exposed +//! via the `__test` feature gate for external testing. They are security-critical +//! pure functions that sanitize remote-peer file paths before use. + +use ironrdp_cliprdr::{is_absolute_path, is_windows_device_name, sanitize_file_path}; + +// ── is_absolute_path ──────────────────────────────────────────────── + +#[test] +fn is_absolute_path_unix() { + // Unix absolute paths + assert!(is_absolute_path("/")); + assert!(is_absolute_path("/path/to/file")); + assert!(is_absolute_path("/usr/bin/bash")); + assert!(is_absolute_path("/home/user/document.txt")); + + // Unix relative paths (should be false) + assert!(!is_absolute_path("file.txt")); + assert!(!is_absolute_path("subfolder/file.txt")); + assert!(!is_absolute_path("./file.txt")); + assert!(!is_absolute_path("../file.txt")); +} + +#[test] +fn is_absolute_path_windows() { + // Windows absolute paths with backslash + assert!(is_absolute_path("C:\\")); + assert!(is_absolute_path("C:\\path\\to\\file")); + assert!(is_absolute_path("D:\\Windows\\System32")); + assert!(is_absolute_path("Z:\\data\\file.txt")); + + // Windows absolute paths with forward slash + assert!(is_absolute_path("C:/")); + assert!(is_absolute_path("C:/path/to/file")); + assert!(is_absolute_path("D:/Windows/System32")); + + // Windows drive-relative paths (C:relative - should be detected as absolute) + assert!(is_absolute_path("C:file.txt")); + assert!(is_absolute_path("D:relative")); + assert!(is_absolute_path("Z:path")); +} + +#[test] +fn is_absolute_path_unc() { + // UNC paths with backslash + assert!(is_absolute_path("\\\\server\\share")); + assert!(is_absolute_path("\\\\server\\share\\file.txt")); + assert!(is_absolute_path("\\\\192.168.1.1\\data")); + + // UNC paths with forward slash + assert!(is_absolute_path("//server/share")); + assert!(is_absolute_path("//server/share/file.txt")); +} + +#[test] +fn is_absolute_path_long_paths() { + // Windows long path prefix + assert!(is_absolute_path("\\\\?\\C:\\very\\long\\path")); + assert!(is_absolute_path("\\\\?\\D:\\path")); + + // Windows long UNC paths + assert!(is_absolute_path("\\\\?\\UNC\\server\\share")); + assert!(is_absolute_path("\\\\?\\UNC\\server\\share\\file.txt")); + + // Device paths + assert!(is_absolute_path("\\\\.\\device")); + assert!(is_absolute_path("\\\\.\\PhysicalDrive0")); +} + +#[test] +fn is_absolute_path_relative() { + // Simple relative paths + assert!(!is_absolute_path("file.txt")); + assert!(!is_absolute_path("document.pdf")); + + // Relative paths with subdirectories + assert!(!is_absolute_path("folder\\file.txt")); + assert!(!is_absolute_path("folder/file.txt")); + assert!(!is_absolute_path("a\\b\\c\\file.txt")); + + // Current and parent directory references + assert!(!is_absolute_path(".")); + assert!(!is_absolute_path("..")); + assert!(!is_absolute_path(".\\file.txt")); + assert!(!is_absolute_path("../file.txt")); + + // Empty string + assert!(!is_absolute_path("")); +} + +// ── sanitize_file_path ────────────────────────────────────────────── + +#[test] +fn sanitize_file_path_basic() { + let result = sanitize_file_path("file.txt").unwrap(); + assert_eq!(result.name, "file.txt"); + assert_eq!(result.relative_path, None); +} + +#[test] +fn sanitize_file_path_strips_trailing_nulls() { + let result = sanitize_file_path("file.txt\0\0\0").unwrap(); + assert_eq!(result.name, "file.txt"); + assert_eq!(result.relative_path, None); +} + +#[test] +fn sanitize_file_path_strips_traversal_preserves_relative() { + // Traversal components are stripped, but remaining safe path is preserved + let result = sanitize_file_path("../../../etc/passwd").unwrap(); + assert_eq!(result.name, "passwd"); + assert_eq!(result.relative_path, Some("etc".to_owned())); + + let result = sanitize_file_path("..\\..\\system32\\config\\SAM").unwrap(); + assert_eq!(result.name, "SAM"); + assert_eq!(result.relative_path, Some("system32\\config".to_owned())); +} + +#[test] +fn sanitize_file_path_windows_absolute_path() { + // Absolute paths are stripped to basename only (drive letter removed) + let result = sanitize_file_path("C:\\Users\\victim\\Desktop\\file.txt").unwrap(); + assert_eq!(result.name, "file.txt"); + assert_eq!(result.relative_path, Some("Users\\victim\\Desktop".to_owned())); +} + +#[test] +fn sanitize_file_path_relative_path_preserved() { + // Per MS-RDPECLIP 3.1.1.2, file lists use relative paths + let result = sanitize_file_path("temp\\file1.txt").unwrap(); + assert_eq!(result.name, "file1.txt"); + assert_eq!(result.relative_path, Some("temp".to_owned())); + + let result = sanitize_file_path("folder\\sub\\file.txt").unwrap(); + assert_eq!(result.name, "file.txt"); + assert_eq!(result.relative_path, Some("folder\\sub".to_owned())); +} + +#[test] +fn sanitize_file_path_unix_relative_path() { + // Unix-style separators are also handled + let result = sanitize_file_path("temp/subdir/file.txt").unwrap(); + assert_eq!(result.name, "file.txt"); + assert_eq!(result.relative_path, Some("temp\\subdir".to_owned())); +} + +#[test] +fn sanitize_file_path_mixed_separators() { + let result = sanitize_file_path("folder/sub\\file.txt").unwrap(); + assert_eq!(result.name, "file.txt"); + assert_eq!(result.relative_path, Some("folder\\sub".to_owned())); +} + +#[test] +fn sanitize_file_path_rejects_empty() { + assert!(sanitize_file_path("").is_none()); + assert!(sanitize_file_path("\0\0\0").is_none()); +} + +#[test] +fn sanitize_file_path_rejects_traversal_only() { + assert!(sanitize_file_path("..").is_none()); + assert!(sanitize_file_path(".").is_none()); + assert!(sanitize_file_path("../..").is_none()); +} + +#[test] +fn sanitize_file_path_rejects_embedded_nulls() { + // Embedded nulls could cause C-based filesystem APIs to truncate the name + assert!(sanitize_file_path("safe\0evil").is_none()); + assert!(sanitize_file_path("file\0.txt").is_none()); + assert!(sanitize_file_path("dir/file\0name.txt").is_none()); +} + +#[test] +fn sanitize_file_path_directory_entry() { + // Directory entries end with a separator; the name is the dir itself + let result = sanitize_file_path("temp\\").unwrap(); + assert_eq!(result.name, "temp"); + assert_eq!(result.relative_path, None); + + let result = sanitize_file_path("folder\\subfolder\\").unwrap(); + assert_eq!(result.name, "subfolder"); + assert_eq!(result.relative_path, Some("folder".to_owned())); +} + +#[test] +fn sanitize_file_path_unc_path() { + // UNC paths - after split and filtering, first component is the server name. + // Since we can't detect UNC purely from components (prefix is stripped by split), + // the server/share components become relative path parts. + let result = sanitize_file_path("\\\\server\\share\\file.txt").unwrap(); + assert_eq!(result.name, "file.txt"); + // Server and share become part of the relative path since we can't + // distinguish them from regular path components after splitting. + assert_eq!(result.relative_path, Some("server\\share".to_owned())); +} + +#[test] +fn sanitize_file_path_long_path_prefix() { + // Windows long path prefix \\?\C:\path + let result = sanitize_file_path("\\\\?\\C:\\Users\\file.txt").unwrap(); + assert_eq!(result.name, "file.txt"); + assert_eq!(result.relative_path, Some("Users".to_owned())); +} + +#[test] +fn sanitize_file_path_triple_dot_not_traversal() { + // "..." is not a traversal component, it's a valid (if unusual) filename + let result = sanitize_file_path("...").unwrap(); + assert_eq!(result.name, "..."); + assert_eq!(result.relative_path, None); +} + +#[test] +fn sanitize_file_path_allows_windows_reserved_device_names() { + // Windows reserved device names pass through sanitize_file_path. + // Backends that write to disk on Windows must reject these separately. + for name in ["CON", "PRN", "AUX", "NUL", "COM1", "COM9", "LPT1", "LPT9"] { + let result = sanitize_file_path(name).unwrap(); + assert_eq!(result.name, name, "reserved name {name} should pass through"); + assert_eq!(result.relative_path, None); + } + + // Also verify they pass through with extensions and in subdirectories + let result = sanitize_file_path("CON.txt").unwrap(); + assert_eq!(result.name, "CON.txt"); + + let result = sanitize_file_path("folder\\NUL").unwrap(); + assert_eq!(result.name, "NUL"); + assert_eq!(result.relative_path, Some("folder".to_owned())); +} + +#[test] +fn sanitize_file_path_unicode_lookalike_separators_pass_through() { + // Unicode look-alike separators are NOT treated as path separators. + // This is a documented limitation - the sanitizer only splits on + // ASCII '/' (U+002F) and '\' (U+005C). OS-level normalization + // handles these if needed. + let fullwidth_solidus = "folder\u{FF0F}file.txt"; // U+FF0F fullwidth solidus + let result = sanitize_file_path(fullwidth_solidus).unwrap(); + assert_eq!( + result.name, fullwidth_solidus, + "fullwidth solidus should not split path" + ); + assert_eq!(result.relative_path, None); + + let fullwidth_reverse = "folder\u{FF3C}file.txt"; // U+FF3C fullwidth reverse solidus + let result = sanitize_file_path(fullwidth_reverse).unwrap(); + assert_eq!( + result.name, fullwidth_reverse, + "fullwidth reverse solidus should not split path" + ); + assert_eq!(result.relative_path, None); + + let division_slash = "folder\u{2215}file.txt"; // U+2215 division slash + let result = sanitize_file_path(division_slash).unwrap(); + assert_eq!(result.name, division_slash, "division slash should not split path"); + assert_eq!(result.relative_path, None); +} + +// ── is_windows_device_name ────────────────────────────────────────── + +#[test] +fn device_name_detects_standard_names() { + for name in ["CON", "PRN", "AUX", "NUL"] { + assert!(is_windows_device_name(name), "{name} should be detected as device name"); + } +} + +#[test] +fn device_name_detects_numbered_ports() { + for i in 1..=9 { + let com = format!("COM{i}"); + let lpt = format!("LPT{i}"); + assert!(is_windows_device_name(&com), "{com} should be detected"); + assert!(is_windows_device_name(&lpt), "{lpt} should be detected"); + } +} + +#[test] +fn device_name_is_case_insensitive() { + assert!(is_windows_device_name("con")); + assert!(is_windows_device_name("Con")); + assert!(is_windows_device_name("nul")); + assert!(is_windows_device_name("Lpt1")); +} + +#[test] +fn device_name_detects_names_with_extension() { + assert!(is_windows_device_name("CON.txt")); + assert!(is_windows_device_name("nul.tar.gz")); + assert!(is_windows_device_name("COM1.log")); +} + +#[test] +fn device_name_rejects_safe_names() { + assert!(!is_windows_device_name("file.txt")); + assert!(!is_windows_device_name("CONSOLE")); + assert!(!is_windows_device_name("COM10")); + assert!(!is_windows_device_name("LPT10")); + assert!(!is_windows_device_name("")); + assert!(!is_windows_device_name("CONX")); + assert!(!is_windows_device_name("NULLIFY")); + assert!(!is_windows_device_name(".hidden")); +} diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/preferred_drop_effect.rs b/crates/ironrdp-testsuite-core/tests/clipboard/preferred_drop_effect.rs new file mode 100644 index 0000000000..5c80976b74 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/clipboard/preferred_drop_effect.rs @@ -0,0 +1,138 @@ +//! Tests for the `Preferred DropEffect` companion format that +//! [`Cliprdr::initiate_file_copy`] advertises alongside +//! `FileGroupDescriptorW`: +//! +//! 1. `initiate_file_copy` advertises BOTH `FileGroupDescriptorW` and +//! `Preferred DropEffect` in the outgoing `FormatList`. +//! 2. A subsequent `FormatDataRequest` for the drop-effect format id is +//! answered inline with the 4-byte little-endian `DROPEFFECT_COPY` +//! payload (`0x01 0x00 0x00 0x00`), not forwarded to the backend. + +use ironrdp_cliprdr::pdu::{ClipboardFormatName, ClipboardPdu, FileDescriptor, FormatDataRequest}; +use ironrdp_svc::{SvcMessage, SvcProcessor as _}; + +use super::test_helpers::init_ready_client; + +/// Decode an SvcMessage back into a ClipboardPdu for assertion. +/// Two `let` bindings are required so the byte buffer outlives the +/// borrowing PDU. +macro_rules! decode_pdu { + ($msg:expr => $bytes:ident, $pdu:ident) => { + let $bytes = ($msg).encode_unframed_pdu().unwrap(); + let $pdu = ironrdp_core::decode::>(&$bytes).unwrap(); + }; +} + +/// `initiate_file_copy` must advertise BOTH `FileGroupDescriptorW` +/// (the file list itself) AND `Preferred DropEffect` (the companion +/// format Windows Explorer pairs with file lists to engage its shell +/// file-copy machinery + native progress dialog). +#[test] +fn initiate_file_copy_advertises_drop_effect_alongside_file_group_descriptor() { + let mut cliprdr = init_ready_client(); + + let files = vec![ + FileDescriptor::new("alpha.txt").with_file_size(100), + FileDescriptor::new("beta.bin").with_file_size(200), + ]; + let messages: Vec = cliprdr.initiate_file_copy(files).unwrap().into(); + + assert_eq!( + messages.len(), + 1, + "initiate_file_copy should send a single FormatList PDU" + ); + + decode_pdu!(&messages[0] => bytes, pdu); + let ClipboardPdu::FormatList(format_list) = pdu else { + panic!("expected FormatList PDU, got {pdu:?}"); + }; + + let formats = format_list + .get_formats(true) + .expect("FormatList should decode under long-format-names"); + + let has_file_group_descriptor = formats + .iter() + .any(|f| f.name.as_ref().is_some_and(|n| n == &ClipboardFormatName::FILE_LIST)); + let has_drop_effect = formats.iter().any(|f| { + f.name + .as_ref() + .is_some_and(|n| n == &ClipboardFormatName::PREFERRED_DROP_EFFECT) + }); + + assert!( + has_file_group_descriptor, + "FormatList must advertise FileGroupDescriptorW; got {formats:#?}" + ); + assert!( + has_drop_effect, + "FormatList must advertise Preferred DropEffect; got {formats:#?}" + ); +} + +/// A `FormatDataRequest` for the drop-effect format id is answered +/// inline by `Cliprdr` itself (not forwarded to the backend) with the +/// 4-byte little-endian `DROPEFFECT_COPY = 0x00000001` payload. +/// +/// Keys off the format *name* (`PREFERRED_DROP_EFFECT`) when looking up +/// the id — wire-faithful (the remote keys off the name too), and +/// resilient to any internal-id constant changes upstream. +/// +/// If `local_drop_effect_format_id` ever stops being set by +/// `initiate_file_copy` (or the inline short-circuit in +/// `handle_format_data_request` is removed), this test fails because +/// `TestBackend::on_format_data_request` is a no-op — the request +/// would fall through to the backend, no response would be emitted, +/// and `responses.len()` would be `0`. +#[test] +fn format_data_request_for_drop_effect_returns_dropeffect_copy_inline() { + let mut cliprdr = init_ready_client(); + + // Drive `initiate_file_copy`; the returned FormatList carries the + // drop-effect format we need to query. + let files = vec![FileDescriptor::new("doc.txt").with_file_size(42)]; + let initiate_msgs: Vec = cliprdr.initiate_file_copy(files).unwrap().into(); + + decode_pdu!(&initiate_msgs[0] => initiate_bytes, initiate_pdu); + let ClipboardPdu::FormatList(format_list) = initiate_pdu else { + panic!("expected FormatList, got {initiate_pdu:?}"); + }; + let drop_effect_id = format_list + .get_formats(true) + .unwrap() + .into_iter() + .find(|f| { + f.name + .as_ref() + .is_some_and(|n| n == &ClipboardFormatName::PREFERRED_DROP_EFFECT) + }) + .expect("initiate_file_copy must advertise Preferred DropEffect") + .id; + + // Simulate the remote asking for the drop-effect format. + let request_pdu = ClipboardPdu::FormatDataRequest(FormatDataRequest { format: drop_effect_id }); + let request_bytes = ironrdp_core::encode_vec(&request_pdu).unwrap(); + let responses: Vec = cliprdr.process(&request_bytes).unwrap(); + + assert_eq!( + responses.len(), + 1, + "drop-effect FormatDataRequest must be answered inline with one FormatDataResponse" + ); + + decode_pdu!(&responses[0] => resp_bytes, resp_pdu); + let ClipboardPdu::FormatDataResponse(response) = resp_pdu else { + panic!("expected FormatDataResponse, got {resp_pdu:?}"); + }; + assert!(!response.is_error(), "response must not be an error"); + + // [MS-RDPECLIP] Preferred DropEffect payload is a 4-byte u32 LE. + // `DROPEFFECT_COPY = 0x00000001` is what `initiate_file_copy` + // semantically always means. + assert_eq!( + response.data(), + &[0x01, 0x00, 0x00, 0x00], + "Preferred DropEffect payload must be exactly 4 bytes DROPEFFECT_COPY (LE)" + ); +} diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/server_role.rs b/crates/ironrdp-testsuite-core/tests/clipboard/server_role.rs new file mode 100644 index 0000000000..cff51b4144 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/clipboard/server_role.rs @@ -0,0 +1,76 @@ +//! Server-role tests for the CLIPRDR channel. +//! +//! The server has different initialization behavior from the client: +//! it sends Capabilities + MonitorReady in `start()`, and transitions +//! to Ready on receiving a FormatList (not FormatListResponse). + +use ironrdp_cliprdr::pdu::{ClipboardFormat, ClipboardFormatId, ClipboardPdu, FormatList, FormatListResponse}; +use ironrdp_cliprdr::{CliprdrServer, CliprdrState}; +use ironrdp_svc::SvcProcessor as _; + +use super::test_helpers::TestBackend; + +/// Helper: decode a SvcMessage back into a ClipboardPdu for assertion. +macro_rules! decode_pdu { + ($msg:expr => $bytes:ident, $pdu:ident) => { + let $bytes = ($msg).encode_unframed_pdu().unwrap(); + let $pdu = ironrdp_core::decode::>(&$bytes).unwrap(); + }; +} + +#[test] +fn server_start_sends_capabilities_and_monitor_ready() { + let mut server = CliprdrServer::new(Box::new(TestBackend)); + + let messages = server.start().unwrap(); + assert_eq!(messages.len(), 2, "start() should send Capabilities + MonitorReady"); + + // First PDU: Capabilities + decode_pdu!(&messages[0] => bytes0, pdu0); + assert!( + matches!(pdu0, ClipboardPdu::Capabilities(_)), + "first PDU should be Capabilities, got {pdu0:?}" + ); + + // Second PDU: MonitorReady + decode_pdu!(&messages[1] => bytes1, pdu1); + assert!( + matches!(pdu1, ClipboardPdu::MonitorReady), + "second PDU should be MonitorReady, got {pdu1:?}" + ); +} + +#[test] +fn server_transitions_to_ready_on_format_list() { + let mut server = CliprdrServer::new(Box::new(TestBackend)); + let _ = server.start().unwrap(); + + // Server should be in Initialization + assert_eq!(*server.__test_state(), CliprdrState::Initialization); + + // Client sends a FormatList + let format_list = FormatList::new_unicode(&[ClipboardFormat::new(ClipboardFormatId::new(13))], true).unwrap(); + let bytes = ironrdp_core::encode_vec(&ClipboardPdu::FormatList(format_list)).unwrap(); + let response = server.process(&bytes).unwrap(); + + // Server should now be Ready + assert_eq!(*server.__test_state(), CliprdrState::Ready); + + // Response should contain FormatListResponse::Ok + assert!(!response.is_empty(), "server should respond to FormatList"); + decode_pdu!(&response[0] => resp_bytes, resp_pdu); + assert!( + matches!(resp_pdu, ClipboardPdu::FormatListResponse(FormatListResponse::Ok)), + "server should respond with FormatListResponse::Ok, got {resp_pdu:?}" + ); +} + +#[test] +fn server_rejects_operations_before_format_list() { + let mut server = CliprdrServer::new(Box::new(TestBackend)); + let _ = server.start().unwrap(); + + // Attempting initiate_paste should fail because server is not in Ready state + let result = server.initiate_paste(ClipboardFormatId::new(13)); + assert!(result.is_err(), "initiate_paste should fail in Initialization state"); +} diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/test_helpers.rs b/crates/ironrdp-testsuite-core/tests/clipboard/test_helpers.rs new file mode 100644 index 0000000000..105f2b8e38 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/clipboard/test_helpers.rs @@ -0,0 +1,448 @@ +//! Shared test backends and initialization helpers for clipboard tests. +//! +//! This module consolidates the various mock backend implementations used +//! across clipboard test modules into a single location, and provides +//! convenience helpers for driving a [`CliprdrClient`] through the protocol +//! handshake to the `Ready` state via the public API. + +// Items in this module are consumed by sibling test modules via +// `super::test_helpers::*`; the compiler cannot see that usage chain and +// warns about "unreachable pub items" on methods/fields inside +// `pub(super)` structs. `dead_code` is suppressed because not all items +// are used yet during the incremental migration from lib.rs inline tests. +#![allow(unreachable_pub, dead_code)] + +use core::cell::Cell; +use std::sync::{Arc, Mutex}; + +use ironrdp_cliprdr::CliprdrClient; +use ironrdp_cliprdr::backend::CliprdrBackend; +use ironrdp_cliprdr::pdu::{ + Capabilities, ClipboardFormat, ClipboardFormatId, ClipboardFormatName, ClipboardGeneralCapabilityFlags, + ClipboardPdu, ClipboardProtocolVersion, FileContentsRequest, FileContentsResponse, FileDescriptor, + FormatDataRequest, FormatDataResponse, FormatListResponse, LockDataId, +}; +use ironrdp_core::AsAny; +use ironrdp_svc::SvcProcessor as _; + +// ── Clock helper ──────────────────────────────────────────────────── + +/// Returns monotonic milliseconds using a process-wide epoch, for test +/// backends that don't need a controllable clock. +pub(super) fn real_now_ms() -> u64 { + use std::sync::OnceLock; + use std::time::Instant; + static EPOCH: OnceLock = OnceLock::new(); + u64::try_from(EPOCH.get_or_init(Instant::now).elapsed().as_millis()).unwrap_or(u64::MAX) +} + +// ── TestBackend ───────────────────────────────────────────────────── + +/// Simplest possible backend: all callbacks are no-ops, no locking +/// capability, uses real wall-clock time. +#[derive(Debug)] +pub(super) struct TestBackend; + +impl CliprdrBackend for TestBackend { + fn temporary_directory(&self) -> &str { + "/tmp" + } + + fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags { + ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + } + + fn on_ready(&mut self) {} + fn on_request_format_list(&mut self) {} + fn on_process_negotiated_capabilities(&mut self, _capabilities: ClipboardGeneralCapabilityFlags) {} + fn on_remote_copy(&mut self, _available_formats: &[ClipboardFormat]) {} + fn on_format_data_request(&mut self, _request: FormatDataRequest) {} + fn on_format_data_response(&mut self, _response: FormatDataResponse<'_>) {} + fn on_file_contents_request(&mut self, _request: FileContentsRequest) {} + fn on_file_contents_response(&mut self, _response: FileContentsResponse<'_>) {} + fn on_lock(&mut self, _data_id: LockDataId) {} + fn on_unlock(&mut self, _data_id: LockDataId) {} + + fn now_ms(&self) -> u64 { + real_now_ms() + } + fn elapsed_ms(&self, since: u64) -> u64 { + self.now_ms().saturating_sub(since) + } +} + +impl AsAny for TestBackend { + fn as_any(&self) -> &dyn core::any::Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { + self + } +} + +// ── LockingBackend ────────────────────────────────────────────────── + +/// Backend that advertises `CAN_LOCK_CLIPDATA` and provides a mock +/// clock via `Cell` for deterministic lock timeout tests. +#[derive(Debug)] +pub(super) struct LockingBackend { + clock_ms: Cell, +} + +impl LockingBackend { + pub fn new() -> Self { + Self { clock_ms: Cell::new(0) } + } + + /// Advance the mock clock by `ms` milliseconds. + pub fn advance_ms(&self, ms: u64) { + self.clock_ms.set(self.clock_ms.get() + ms); + } +} + +impl CliprdrBackend for LockingBackend { + fn temporary_directory(&self) -> &str { + "/tmp" + } + + fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags { + ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA | ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + } + + fn on_ready(&mut self) {} + fn on_request_format_list(&mut self) {} + fn on_process_negotiated_capabilities(&mut self, _: ClipboardGeneralCapabilityFlags) {} + fn on_remote_copy(&mut self, _: &[ClipboardFormat]) {} + fn on_format_data_request(&mut self, _: FormatDataRequest) {} + fn on_format_data_response(&mut self, _: FormatDataResponse<'_>) {} + fn on_file_contents_request(&mut self, _: FileContentsRequest) {} + fn on_file_contents_response(&mut self, _: FileContentsResponse<'_>) {} + fn on_lock(&mut self, _: LockDataId) {} + fn on_unlock(&mut self, _: LockDataId) {} + + fn now_ms(&self) -> u64 { + self.clock_ms.get() + } + fn elapsed_ms(&self, since: u64) -> u64 { + self.now_ms().saturating_sub(since) + } +} + +impl AsAny for LockingBackend { + fn as_any(&self) -> &dyn core::any::Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { + self + } +} + +// ── ReceivedResponse + RecordingBackend ───────────────────────────── + +/// Response recorded by [`RecordingBackend`] for assertion in tests. +#[derive(Debug, Clone)] +pub(super) struct ReceivedResponse { + pub stream_id: u32, + pub is_error: bool, + pub data_len: usize, +} + +/// Backend that records [`FileContentsResponse`] callbacks for later +/// assertion. Used by tests that verify response forwarding behavior +/// (e.g. malformed size responses, error sanitization). +#[derive(Debug)] +pub(super) struct RecordingBackend { + pub responses: Arc>>, +} + +impl CliprdrBackend for RecordingBackend { + fn temporary_directory(&self) -> &str { + "/tmp" + } + fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags { + ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + } + fn on_ready(&mut self) {} + fn on_request_format_list(&mut self) {} + fn on_process_negotiated_capabilities(&mut self, _: ClipboardGeneralCapabilityFlags) {} + fn on_remote_copy(&mut self, _: &[ClipboardFormat]) {} + fn on_format_data_request(&mut self, _: FormatDataRequest) {} + fn on_format_data_response(&mut self, _: FormatDataResponse<'_>) {} + fn on_file_contents_request(&mut self, _: FileContentsRequest) {} + fn on_file_contents_response(&mut self, response: FileContentsResponse<'_>) { + self.responses.lock().unwrap().push(ReceivedResponse { + stream_id: response.stream_id(), + is_error: response.is_error(), + data_len: response.data().len(), + }); + } + fn on_lock(&mut self, _: LockDataId) {} + fn on_unlock(&mut self, _: LockDataId) {} + + fn now_ms(&self) -> u64 { + real_now_ms() + } + fn elapsed_ms(&self, since: u64) -> u64 { + self.now_ms().saturating_sub(since) + } +} + +impl AsAny for RecordingBackend { + fn as_any(&self) -> &dyn core::any::Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { + self + } +} + +// ── TimedRecordingBackend ─────────────────────────────────────────── + +/// Backend combining mock clock, response recording, and unlock +/// tracking. Used for tests that need deterministic time together with +/// callback verification. +#[derive(Debug)] +pub(super) struct TimedRecordingBackend { + pub clock_ms: Cell, + pub responses: Arc>>, + pub unlocks: Arc>>, +} + +impl TimedRecordingBackend { + pub fn new(responses: Arc>>, unlocks: Arc>>) -> Self { + Self { + clock_ms: Cell::new(0), + responses, + unlocks, + } + } + + /// Advance the mock clock by `ms` milliseconds. + pub fn advance_ms(&self, ms: u64) { + self.clock_ms.set(self.clock_ms.get() + ms); + } +} + +impl CliprdrBackend for TimedRecordingBackend { + fn temporary_directory(&self) -> &str { + "/tmp" + } + fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags { + ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA | ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + } + fn on_ready(&mut self) {} + fn on_request_format_list(&mut self) {} + fn on_process_negotiated_capabilities(&mut self, _: ClipboardGeneralCapabilityFlags) {} + fn on_remote_copy(&mut self, _: &[ClipboardFormat]) {} + fn on_format_data_request(&mut self, _: FormatDataRequest) {} + fn on_format_data_response(&mut self, _: FormatDataResponse<'_>) {} + fn on_file_contents_request(&mut self, _: FileContentsRequest) {} + fn on_file_contents_response(&mut self, response: FileContentsResponse<'_>) { + self.responses.lock().unwrap().push(ReceivedResponse { + stream_id: response.stream_id(), + is_error: response.is_error(), + data_len: response.data().len(), + }); + } + fn on_lock(&mut self, _: LockDataId) {} + fn on_unlock(&mut self, data_id: LockDataId) { + self.unlocks.lock().unwrap().push(data_id.0); + } + + fn now_ms(&self) -> u64 { + self.clock_ms.get() + } + fn elapsed_ms(&self, since: u64) -> u64 { + self.now_ms().saturating_sub(since) + } +} + +impl AsAny for TimedRecordingBackend { + fn as_any(&self) -> &dyn core::any::Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { + self + } +} + +// ── CallbackTrackingBackend ───────────────────────────────────────── + +/// Backend that tracks `on_outgoing_locks_cleared` and +/// `on_outgoing_locks_expired` callback invocations. +/// Has a mock clock for deterministic lock timeout tests. +#[derive(Debug)] +pub(super) struct CallbackTrackingBackend { + pub cleared_ids: Arc>>>, + pub expired_ids: Arc>>>, + pub clock_ms: Cell, +} + +impl CallbackTrackingBackend { + pub fn new(cleared_ids: Arc>>>) -> Self { + Self { + cleared_ids, + expired_ids: Arc::new(Mutex::new(Vec::new())), + clock_ms: Cell::new(0), + } + } + + pub fn with_expired_tracking( + cleared_ids: Arc>>>, + expired_ids: Arc>>>, + ) -> Self { + Self { + cleared_ids, + expired_ids, + clock_ms: Cell::new(0), + } + } + + /// Advance the mock clock by `ms` milliseconds. + pub fn advance_ms(&self, ms: u64) { + self.clock_ms.set(self.clock_ms.get() + ms); + } +} + +impl CliprdrBackend for CallbackTrackingBackend { + fn temporary_directory(&self) -> &str { + "/tmp" + } + + fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags { + ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA + } + + fn on_ready(&mut self) {} + fn on_request_format_list(&mut self) {} + fn on_process_negotiated_capabilities(&mut self, _: ClipboardGeneralCapabilityFlags) {} + fn on_remote_copy(&mut self, _: &[ClipboardFormat]) {} + fn on_format_data_request(&mut self, _: FormatDataRequest) {} + fn on_format_data_response(&mut self, _: FormatDataResponse<'_>) {} + fn on_file_contents_request(&mut self, _: FileContentsRequest) {} + fn on_file_contents_response(&mut self, _: FileContentsResponse<'_>) {} + fn on_lock(&mut self, _: LockDataId) {} + fn on_unlock(&mut self, _: LockDataId) {} + + fn on_outgoing_locks_cleared(&mut self, clip_data_ids: &[LockDataId]) { + self.cleared_ids.lock().unwrap().push(clip_data_ids.to_vec()); + } + + fn on_outgoing_locks_expired(&mut self, clip_data_ids: &[LockDataId]) { + self.expired_ids.lock().unwrap().push(clip_data_ids.to_vec()); + } + + fn now_ms(&self) -> u64 { + self.clock_ms.get() + } + fn elapsed_ms(&self, since: u64) -> u64 { + self.now_ms().saturating_sub(since) + } +} + +impl AsAny for CallbackTrackingBackend { + fn as_any(&self) -> &dyn core::any::Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn core::any::Any { + self + } +} + +// ── Initialization helpers ────────────────────────────────────────── + +/// The capability flags used by [server_capabilities_pdu] in the +/// simulated handshake. Tests that need to match against negotiated +/// capabilities can reference this constant. +pub(super) const HANDSHAKE_SERVER_FLAGS: ClipboardGeneralCapabilityFlags = + ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES + .union(ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED) + .union(ClipboardGeneralCapabilityFlags::FILECLIP_NO_FILE_PATHS) + .union(ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA); + +/// Builds a server Capabilities PDU with file transfer + locking flags. +fn server_capabilities_pdu() -> Vec { + ironrdp_core::encode_vec(&ClipboardPdu::Capabilities(Capabilities::new( + ClipboardProtocolVersion::V2, + HANDSHAKE_SERVER_FLAGS, + ))) + .unwrap() +} + +/// Builds a MonitorReady PDU. +fn monitor_ready_pdu() -> Vec { + ironrdp_core::encode_vec(&ClipboardPdu::MonitorReady).unwrap() +} + +/// Builds a FormatListResponse::Ok PDU. +fn format_list_response_ok_pdu() -> Vec { + ironrdp_core::encode_vec(&ClipboardPdu::FormatListResponse(FormatListResponse::Ok)).unwrap() +} + +/// Drive a [`CliprdrClient`] through the full initialization handshake +/// to `Ready` state using the public API. +/// +/// Simulates: +/// 1. Server sends Capabilities +/// 2. Server sends MonitorReady +/// 3. Client calls `initiate_copy` (sends Caps + TempDir + FormatList) +/// 4. Server replies FormatListResponse::Ok -> client transitions to Ready +pub(super) fn drive_to_ready(cliprdr: &mut CliprdrClient) { + let caps_bytes = server_capabilities_pdu(); + cliprdr.process(&caps_bytes).unwrap(); + + let monitor_bytes = monitor_ready_pdu(); + cliprdr.process(&monitor_bytes).unwrap(); + + let formats = vec![ClipboardFormat::new(ClipboardFormatId::new(13))]; + cliprdr.initiate_copy(&formats).unwrap(); + + let resp_bytes = format_list_response_ok_pdu(); + cliprdr.process(&resp_bytes).unwrap(); +} + +/// Create a [`CliprdrClient`] with the given backend, driven to Ready state. +pub(super) fn init_ready_client_with_backend(backend: Box) -> CliprdrClient { + let mut cliprdr = CliprdrClient::new(backend); + drive_to_ready(&mut cliprdr); + cliprdr +} + +/// Create a [`CliprdrClient`] with a [`TestBackend`], driven to Ready state. +pub(super) fn init_ready_client() -> CliprdrClient { + init_ready_client_with_backend(Box::new(TestBackend)) +} + +/// Create a [`CliprdrClient`] with a [`LockingBackend`], driven to Ready state. +pub(super) fn init_ready_locking_client() -> CliprdrClient { + init_ready_client_with_backend(Box::new(LockingBackend::new())) +} + +/// Simulate the remote sending a FormatList containing FileGroupDescriptorW, +/// then the client requesting and receiving the file list through the public +/// protocol flow. +/// +/// After this call, `cliprdr.request_file_contents(...)` will accept +/// indices into the provided `files` list. +pub(super) fn set_remote_file_list(cliprdr: &mut CliprdrClient, files: Vec) { + use ironrdp_cliprdr::pdu::{FormatList, PackedFileList}; + + // 1. Remote sends FormatList with FileGroupDescriptorW + let file_list_format_id = ClipboardFormatId::new(49534); + let file_list_format = ClipboardFormat::new(file_list_format_id).with_name(ClipboardFormatName::FILE_LIST); + let format_list = FormatList::new_unicode(core::slice::from_ref(&file_list_format), true).unwrap(); + let format_list_pdu = ClipboardPdu::FormatList(format_list); + let format_list_bytes = ironrdp_core::encode_vec(&format_list_pdu).unwrap(); + cliprdr.process(&format_list_bytes).unwrap(); + + // 2. Client initiates paste for the FileGroupDescriptorW format + cliprdr.initiate_paste(file_list_format_id).unwrap(); + + // 3. Build a FormatDataResponse containing the packed file list + let packed = PackedFileList { files }; + let packed_bytes = ironrdp_core::encode_vec(&packed).unwrap(); + + let response_pdu = ClipboardPdu::FormatDataResponse(FormatDataResponse::new_data(&packed_bytes)); + let response_bytes = ironrdp_core::encode_vec(&response_pdu).unwrap(); + cliprdr.process(&response_bytes).unwrap(); +} diff --git a/crates/ironrdp-testsuite-core/tests/clipboard/upload_and_cleanup.rs b/crates/ironrdp-testsuite-core/tests/clipboard/upload_and_cleanup.rs new file mode 100644 index 0000000000..1f7edc17a3 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/clipboard/upload_and_cleanup.rs @@ -0,0 +1,540 @@ +//! Tests for initiate_copy file list preservation, FormatList interaction +//! with in-flight requests, stale request cleanup, and locked file list +//! cleanup. +//! +//! Migrated from `ironrdp-cliprdr/src/lib.rs` inline `#[cfg(test)]` module. +//! Behavior assertions verify returned PDUs and backend callbacks; +//! bookkeeping assertions (tracking maps, file lists) use the `__test` +//! feature gate. + +use std::sync::{Arc, Mutex}; + +use ironrdp_cliprdr::pdu::{ + ClipboardFormat, ClipboardFormatId, ClipboardPdu, FileContentsFlags, FileContentsRequest, FileContentsResponse, + FileDescriptor, FormatList, FormatListResponse, LockDataId, PackedFileList, +}; +use ironrdp_cliprdr::{Cliprdr, CliprdrClient, CliprdrState, FileTransferState}; +use ironrdp_core::Encode as _; +use ironrdp_svc::{SvcMessage, SvcProcessor as _}; + +use super::test_helpers::{RecordingBackend, TestBackend, TimedRecordingBackend}; + +/// Introduce `let` bindings for the encoded bytes and the decoded +/// [`ClipboardPdu`] in the caller's scope. Two names are required so +/// that the byte buffer outlives the borrowing PDU. +macro_rules! decode_pdu { + ($msg:expr => $bytes:ident, $pdu:ident) => { + let $bytes = ($msg).encode_unframed_pdu().unwrap(); + let $pdu = ironrdp_core::decode::>(&$bytes).unwrap(); + }; +} + +/// Helper: build a file list with sizes. +fn sized_file_list(entries: &[(&str, u64)]) -> PackedFileList { + PackedFileList { + files: entries + .iter() + .map(|(name, size)| FileDescriptor::new(*name).with_file_size(*size)) + .collect(), + } +} + +// ── initiate_copy behavior ────────────────────────────────────────── + +#[test] +fn initiate_copy_clears_file_list_even_during_upload() { + // Per [MS-RDPECLIP] 3.1.1.1, each FormatList completely replaces the previous. + // A text/image copy ends file visibility to the remote - acceptable since + // the user explicitly chose new content. + let mut cliprdr = CliprdrClient::new(Box::new(TestBackend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + + let file_format_id = ClipboardFormatId::new(0xC0FE); + *cliprdr.__test_local_file_list_mut() = Some(sized_file_list(&[("upload.txt", 1024)])); + *cliprdr.__test_local_file_list_format_id_mut() = Some(file_format_id); + + // Text clipboard change triggers initiate_copy + let text_format = ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT); + let messages: Vec = cliprdr + .initiate_copy(core::slice::from_ref(&text_format)) + .unwrap() + .into(); + + // Behavior: a FormatList PDU is returned + assert!(!messages.is_empty()); + decode_pdu!(messages[0] => _bytes, pdu); + assert!( + matches!(pdu, ClipboardPdu::FormatList(_)), + "initiate_copy should produce a FormatList PDU" + ); + + // Bookkeeping: file list cleared unconditionally + assert!(cliprdr.__test_local_file_list().is_none()); + assert_eq!(cliprdr.__test_local_file_list_format_id(), None); +} + +#[test] +fn initiate_copy_clears_file_list_when_no_upload() { + let mut cliprdr = CliprdrClient::new(Box::new(TestBackend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + + let text_format = ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT); + let messages: Vec = cliprdr + .initiate_copy(core::slice::from_ref(&text_format)) + .unwrap() + .into(); + + // Behavior: a FormatList PDU is returned + assert!(!messages.is_empty()); + decode_pdu!(messages[0] => _bytes, pdu); + assert!(matches!(pdu, ClipboardPdu::FormatList(_))); + + // Bookkeeping: no file list when not uploading + assert!(cliprdr.__test_local_file_list().is_none()); + assert!(cliprdr.__test_local_file_list_format_id().is_none()); +} + +// ── FormatList interaction with in-flight requests ────────────────── + +/// [MS-RDPECLIP] 2.2.4.1 / 3.1.5.3.2 - Clipboard locks ensure file data +/// survives clipboard changes. The client must NOT discard its request +/// tracking, or valid responses would be dropped as "unknown streamId". +#[test] +fn format_list_preserves_in_flight_file_contents_requests() { + let responses = Arc::new(Mutex::new(Vec::new())); + let backend = RecordingBackend { + responses: Arc::clone(&responses), + }; + + let mut cliprdr = CliprdrClient::new(Box::new(backend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + + // Simulate an in-flight download + cliprdr.__test_sent_file_contents_requests_mut().insert( + 1, + FileTransferState { + file_index: 0, + flags: FileContentsFlags::SIZE, + sent_at_ms: 0, + }, + ); + + // New FormatList arrives (remote user copied a second file) + let format_list = FormatList::new_unicode(&[], false).unwrap(); + let format_list_pdu = ClipboardPdu::FormatList(format_list); + let mut buf = vec![0u8; format_list_pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + format_list_pdu.encode(&mut cursor).unwrap(); + + let result = cliprdr.process(&buf); + assert!(result.is_ok()); + + assert!( + cliprdr.__test_sent_file_contents_requests().contains_key(&1), + "In-flight request for stream_id=1 must not be cleared by FormatList" + ); + + // Now the server delivers the FileContentsResponse for the locked data + let response = FileContentsResponse::new_size_response(1, 4096); + let response_pdu = ClipboardPdu::FileContentsResponse(response); + let mut buf = vec![0u8; response_pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + response_pdu.encode(&mut cursor).unwrap(); + + let result = cliprdr.process(&buf); + assert!(result.is_ok()); + + let received = responses.lock().unwrap(); + assert_eq!(received.len(), 1, "Response should be forwarded to backend"); + assert_eq!(received[0].stream_id, 1); + assert!(!received[0].is_error, "Response should not be an error"); + assert_eq!(received[0].data_len, 8, "SIZE response should be 8 bytes"); + + drop(received); + assert!( + !cliprdr.__test_sent_file_contents_requests().contains_key(&1), + "Request tracking should be consumed after response arrives" + ); +} + +#[test] +fn format_list_response_fail_notifies_backend_for_pending_requests() { + let responses = Arc::new(Mutex::new(Vec::new())); + let backend = RecordingBackend { + responses: Arc::clone(&responses), + }; + + let mut cliprdr = CliprdrClient::new(Box::new(backend)); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + + for stream_id in [10, 20, 30] { + cliprdr.__test_sent_file_contents_requests_mut().insert( + stream_id, + FileTransferState { + file_index: 0, + flags: FileContentsFlags::RANGE, + sent_at_ms: 0, + }, + ); + } + + let fail_pdu = ClipboardPdu::FormatListResponse(FormatListResponse::Fail); + let encoded = ironrdp_core::encode_vec(&fail_pdu).unwrap(); + let result = cliprdr.process(&encoded); + assert!(result.is_ok()); + + assert!( + cliprdr.__test_sent_file_contents_requests().is_empty(), + "Pending requests must be cleared after FormatListResponse::Fail" + ); + + let received = responses.lock().unwrap(); + assert_eq!( + received.len(), + 3, + "Backend should receive an error response for each pending request" + ); + + let mut received_ids: Vec = received.iter().map(|r| r.stream_id).collect(); + received_ids.sort(); + assert_eq!(received_ids, vec![10, 20, 30]); + + for r in received.iter() { + assert!( + r.is_error, + "Each response should be an error for stream_id={}", + r.stream_id + ); + assert_eq!(r.data_len, 0, "Error responses should have no data"); + } +} + +// ── Stale request cleanup ─────────────────────────────────────────── + +#[test] +fn stale_request_cleanup_after_timeout() { + let responses = Arc::new(Mutex::new(Vec::new())); + let unlocks = Arc::new(Mutex::new(Vec::new())); + let backend = TimedRecordingBackend::new(Arc::clone(&responses), Arc::clone(&unlocks)); + + let mut cliprdr: CliprdrClient = Cliprdr::with_all_config( + Box::new(backend), + core::time::Duration::from_secs(60), + core::time::Duration::from_secs(3600), + core::time::Duration::from_millis(200), // 200ms transfer timeout + ); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + + cliprdr.__test_sent_file_contents_requests_mut().insert( + 1, + FileTransferState { + file_index: 0, + flags: FileContentsFlags::SIZE, + sent_at_ms: 0, + }, + ); + cliprdr.__test_sent_file_contents_requests_mut().insert( + 2, + FileTransferState { + file_index: 1, + flags: FileContentsFlags::RANGE, + sent_at_ms: 0, + }, + ); + assert_eq!(cliprdr.__test_sent_file_contents_requests().len(), 2); + + cliprdr + .downcast_backend::() + .unwrap() + .advance_ms(250); + + let _messages = cliprdr.drive_timeouts().unwrap(); + + assert_eq!(cliprdr.__test_sent_file_contents_requests().len(), 0); + + let received = responses.lock().unwrap(); + assert_eq!(received.len(), 2); + for r in received.iter() { + assert!(r.is_error, "Timed-out request should produce error response"); + } +} + +#[test] +fn stale_request_cleanup_spares_recent_requests() { + let responses = Arc::new(Mutex::new(Vec::new())); + let unlocks = Arc::new(Mutex::new(Vec::new())); + let backend = TimedRecordingBackend::new(Arc::clone(&responses), Arc::clone(&unlocks)); + + let mut cliprdr: CliprdrClient = Cliprdr::with_all_config( + Box::new(backend), + core::time::Duration::from_secs(60), + core::time::Duration::from_secs(3600), + core::time::Duration::from_millis(200), // 200ms transfer timeout + ); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + + // Insert an old request (clock=0) + cliprdr.__test_sent_file_contents_requests_mut().insert( + 1, + FileTransferState { + file_index: 0, + flags: FileContentsFlags::SIZE, + sent_at_ms: 0, + }, + ); + + // Advance and insert a recent one (clock=150) + cliprdr + .downcast_backend::() + .unwrap() + .advance_ms(150); + + cliprdr.__test_sent_file_contents_requests_mut().insert( + 2, + FileTransferState { + file_index: 1, + flags: FileContentsFlags::RANGE, + sent_at_ms: 150, + }, + ); + + // Advance to 250ms total: request 1 = 250ms old (> 200ms), request 2 = 100ms (< 200ms) + cliprdr + .downcast_backend::() + .unwrap() + .advance_ms(100); + + let _messages = cliprdr.drive_timeouts().unwrap(); + + assert_eq!(cliprdr.__test_sent_file_contents_requests().len(), 1); + assert!(cliprdr.__test_sent_file_contents_requests().contains_key(&2)); + + let received = responses.lock().unwrap(); + assert_eq!(received.len(), 1); + assert_eq!(received[0].stream_id, 1); + assert!(received[0].is_error); +} + +// ── Locked file list cleanup ──────────────────────────────────────── + +#[test] +fn locked_file_list_cleaned_up_after_inactivity() { + let responses = Arc::new(Mutex::new(Vec::new())); + let unlocks = Arc::new(Mutex::new(Vec::new())); + let backend = TimedRecordingBackend::new(Arc::clone(&responses), Arc::clone(&unlocks)); + + let mut cliprdr: CliprdrClient = Cliprdr::with_all_config( + Box::new(backend), + core::time::Duration::from_secs(60), + core::time::Duration::from_secs(3600), + core::time::Duration::from_millis(200), // 200ms transfer timeout + ); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + *cliprdr.__test_local_file_list_mut() = Some(sized_file_list(&[("upload.txt", 100)])); + + // Process incoming Lock PDU at clock=0 + let lock_pdu = ClipboardPdu::LockData(LockDataId(42)); + let mut buf = vec![0u8; lock_pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + lock_pdu.encode(&mut cursor).unwrap(); + cliprdr.process(&buf).unwrap(); + + assert_eq!(cliprdr.__test_locked_file_lists().len(), 1); + assert!(cliprdr.__test_locked_file_list_activity().contains_key(&42)); + + // Advance past transfer timeout with no FileContentsRequest activity + cliprdr + .downcast_backend::() + .unwrap() + .advance_ms(250); + + let _messages = cliprdr.drive_timeouts().unwrap(); + + assert_eq!(cliprdr.__test_locked_file_lists().len(), 0); + assert!(!cliprdr.__test_locked_file_list_activity().contains_key(&42)); + + let unlock_ids = unlocks.lock().unwrap(); + assert_eq!(unlock_ids.len(), 1); + assert_eq!(unlock_ids[0], 42); +} + +#[test] +fn locked_file_list_activity_prevents_cleanup() { + let responses = Arc::new(Mutex::new(Vec::new())); + let unlocks = Arc::new(Mutex::new(Vec::new())); + let backend = TimedRecordingBackend::new(Arc::clone(&responses), Arc::clone(&unlocks)); + + let mut cliprdr: CliprdrClient = Cliprdr::with_all_config( + Box::new(backend), + core::time::Duration::from_secs(60), + core::time::Duration::from_secs(3600), + core::time::Duration::from_millis(200), // 200ms transfer timeout + ); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + *cliprdr.__test_local_file_list_mut() = Some(sized_file_list(&[("upload.txt", 100)])); + + // Process Lock PDU at clock=0 + let lock_pdu = ClipboardPdu::LockData(LockDataId(42)); + let mut buf = vec![0u8; lock_pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + lock_pdu.encode(&mut cursor).unwrap(); + cliprdr.process(&buf).unwrap(); + + // Advance 150ms, then send an incoming FileContentsRequest (updates activity) + cliprdr + .downcast_backend::() + .unwrap() + .advance_ms(150); + + let fcr = FileContentsRequest { + stream_id: 1, + index: 0, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: Some(42), + }; + let fcr_pdu = ClipboardPdu::FileContentsRequest(fcr); + let mut fcr_buf = vec![0u8; fcr_pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut fcr_buf); + fcr_pdu.encode(&mut cursor).unwrap(); + cliprdr.process(&fcr_buf).unwrap(); + + // Advance another 100ms (250ms total, but only 100ms since last activity) + cliprdr + .downcast_backend::() + .unwrap() + .advance_ms(100); + + let _messages = cliprdr.drive_timeouts().unwrap(); + + // Locked file list should NOT be cleaned up (100ms since activity < 200ms timeout) + assert_eq!(cliprdr.__test_locked_file_lists().len(), 1); + + let unlock_ids = unlocks.lock().unwrap(); + assert_eq!(unlock_ids.len(), 0); +} + +#[test] +fn locked_file_list_cleanup_only_inactive_entries() { + let responses = Arc::new(Mutex::new(Vec::new())); + let unlocks = Arc::new(Mutex::new(Vec::new())); + let backend = TimedRecordingBackend::new(Arc::clone(&responses), Arc::clone(&unlocks)); + + let mut cliprdr: CliprdrClient = Cliprdr::with_all_config( + Box::new(backend), + core::time::Duration::from_secs(60), + core::time::Duration::from_secs(3600), + core::time::Duration::from_millis(200), // 200ms transfer timeout + ); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + *cliprdr.__test_local_file_list_mut() = Some(sized_file_list(&[("upload.txt", 100)])); + + // Process two Lock PDUs at clock=0 + for clip_data_id in [10, 20] { + let lock_pdu = ClipboardPdu::LockData(LockDataId(clip_data_id)); + let mut buf = vec![0u8; lock_pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + lock_pdu.encode(&mut cursor).unwrap(); + cliprdr.process(&buf).unwrap(); + } + assert_eq!(cliprdr.__test_locked_file_lists().len(), 2); + + // Advance 150ms and send activity only for lock 20 + cliprdr + .downcast_backend::() + .unwrap() + .advance_ms(150); + + let fcr = FileContentsRequest { + stream_id: 1, + index: 0, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: Some(20), + }; + let fcr_pdu = ClipboardPdu::FileContentsRequest(fcr); + let mut fcr_buf = vec![0u8; fcr_pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut fcr_buf); + fcr_pdu.encode(&mut cursor).unwrap(); + cliprdr.process(&fcr_buf).unwrap(); + + // Advance another 100ms: lock 10 is 250ms inactive, lock 20 is 100ms since activity + cliprdr + .downcast_backend::() + .unwrap() + .advance_ms(100); + + let _messages = cliprdr.drive_timeouts().unwrap(); + + // Only lock 10 should be cleaned up (inactive), lock 20 still active + assert_eq!(cliprdr.__test_locked_file_lists().len(), 1); + assert!(cliprdr.__test_locked_file_lists().contains_key(&20)); + assert!(!cliprdr.__test_locked_file_lists().contains_key(&10)); + + let unlock_ids = unlocks.lock().unwrap(); + assert_eq!(unlock_ids.len(), 1); + assert_eq!(unlock_ids[0], 10); +} + +// ── Repaste after lock expiry ────────────────────────────────────── + +#[test] +fn file_contents_request_falls_back_to_local_file_list_after_lock_expires() { + let responses = Arc::new(Mutex::new(Vec::new())); + let unlocks = Arc::new(Mutex::new(Vec::new())); + let backend = TimedRecordingBackend::new(Arc::clone(&responses), Arc::clone(&unlocks)); + + let mut cliprdr: CliprdrClient = Cliprdr::with_all_config( + Box::new(backend), + core::time::Duration::from_secs(60), + core::time::Duration::from_secs(3600), + core::time::Duration::from_millis(200), // 200ms transfer timeout + ); + *cliprdr.__test_state_mut() = CliprdrState::Ready; + *cliprdr.__test_local_file_list_mut() = Some(sized_file_list(&[("report.pdf", 4096)])); + + // Server sends Lock PDU at clock=0 + let lock_pdu = ClipboardPdu::LockData(LockDataId(99)); + let mut buf = vec![0u8; lock_pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + lock_pdu.encode(&mut cursor).unwrap(); + cliprdr.process(&buf).unwrap(); + assert_eq!(cliprdr.__test_locked_file_lists().len(), 1); + + // First paste attempt fails on the server (no valid target), so no + // FileContentsRequest is sent. Time passes past the transfer timeout. + cliprdr + .downcast_backend::() + .unwrap() + .advance_ms(250); + let _messages = cliprdr.drive_timeouts().unwrap(); + + // Lock snapshot is gone + assert_eq!(cliprdr.__test_locked_file_lists().len(), 0); + + // User opens a valid target and pastes again. The server sends a + // FileContentsRequest with the original clipDataId. This must succeed + // by falling back to local_file_list rather than returning an error. + let fcr = FileContentsRequest { + stream_id: 7, + index: 0, + flags: FileContentsFlags::SIZE, + position: 0, + requested_size: 8, + data_id: Some(99), + }; + let fcr_pdu = ClipboardPdu::FileContentsRequest(fcr); + let mut fcr_buf = vec![0u8; fcr_pdu.size()]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut fcr_buf); + fcr_pdu.encode(&mut cursor).unwrap(); + + // process() returns Ok(empty) when the request is forwarded to the + // backend. A non-empty return would mean an error response was sent. + let result = cliprdr.process(&fcr_buf).unwrap(); + assert!( + result.is_empty(), + "Expected request to be forwarded to backend, but got error response PDU" + ); +} diff --git a/crates/ironrdp-testsuite-core/tests/connector/autodetect.rs b/crates/ironrdp-testsuite-core/tests/connector/autodetect.rs new file mode 100644 index 0000000000..dfddfe74de --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/connector/autodetect.rs @@ -0,0 +1,172 @@ +//! Connect-time auto-detection demux in the client connector. +//! +//! The continuous (session) auto-detect path is covered in +//! `tests/session/autodetect.rs`. These tests cover the connector's +//! `ConnectTimeAutoDetection` state, which demultiplexes the first PDU received +//! once a message channel has been negotiated: an Auto-Detect Request on the +//! message channel is answered, any other message-channel PDU is ignored, and a +//! PDU on the I/O channel is the first licensing PDU. + +use std::borrow::Cow; + +use ironrdp_connector::{ClientConnector, ClientConnectorState, Credentials, DesktopSize, Sequence as _, Written}; +use ironrdp_core::{WriteBuf, encode_vec}; +use ironrdp_pdu::gcc; +use ironrdp_pdu::mcs::{McsMessage, SendDataIndication}; +use ironrdp_pdu::rdp::autodetect::{AutoDetectReqPdu, AutoDetectRequest}; +use ironrdp_pdu::rdp::capability_sets::MajorPlatformType; +use ironrdp_pdu::rdp::headers::{BasicSecurityHeader, BasicSecurityHeaderFlags}; +use ironrdp_pdu::rdp::server_license::{ + LicenseErrorCode, LicenseHeader, LicensePdu, LicensingErrorMessage, LicensingStateTransition, PreambleFlags, + PreambleType, PreambleVersion, +}; +use ironrdp_pdu::x224::X224; + +const USER_CHANNEL_ID: u16 = 1002; +const IO_CHANNEL_ID: u16 = 1003; +const MESSAGE_CHANNEL_ID: u16 = 1004; + +fn test_config() -> ironrdp_connector::Config { + ironrdp_connector::Config { + desktop_size: DesktopSize { + width: 1024, + height: 768, + }, + desktop_scale_factor: 0, + enable_tls: true, + enable_credssp: false, + credentials: Credentials::UsernamePassword { + username: "test".into(), + password: "test".into(), + }, + domain: None, + client_build: 0, + client_name: "test".into(), + keyboard_type: gcc::KeyboardType::IbmEnhanced, + keyboard_subtype: 0, + keyboard_layout: 0, + keyboard_functional_keys_count: 12, + ime_file_name: String::new(), + bitmap: None, + dig_product_id: String::new(), + client_dir: String::new(), + platform: MajorPlatformType::UNIX, + hardware_id: None, + request_data: None, + autologon: false, + enable_audio_playback: false, + license_cache: None, + compression_type: None, + enable_server_pointer: false, + pointer_software_rendering: false, + multitransport_flags: None, + performance_flags: Default::default(), + timezone_info: Default::default(), + alternate_shell: String::new(), + work_dir: String::new(), + } +} + +/// A client connector parked in `ConnectTimeAutoDetection` with a negotiated +/// message channel, ready to receive the first PDU of that phase. +fn connect_time_autodetect_connector() -> ClientConnector { + let mut connector = ClientConnector::new(test_config(), "127.0.0.1:12345".parse().unwrap()); + connector.state = ClientConnectorState::ConnectTimeAutoDetection { + io_channel_id: IO_CHANNEL_ID, + user_channel_id: USER_CHANNEL_ID, + }; + connector.message_channel_id = Some(MESSAGE_CHANNEL_ID); + connector +} + +/// Frame a server-to-client SendDataIndication on the given MCS channel. +fn server_send_data_indication(channel_id: u16, user_data: Vec) -> Vec { + let indication = McsMessage::SendDataIndication(SendDataIndication { + initiator_id: USER_CHANNEL_ID, + channel_id, + user_data: Cow::Owned(user_data), + }); + + encode_vec(&X224(indication)).unwrap() +} + +#[test] +fn connect_time_autodetect_request_is_answered_and_phase_continues() { + let mut connector = connect_time_autodetect_connector(); + + let user_data = encode_vec(&AutoDetectReqPdu::new(AutoDetectRequest::rtt_connect_time(0x1234))).unwrap(); + let frame = server_send_data_indication(MESSAGE_CHANNEL_ID, user_data); + + let mut output = WriteBuf::new(); + let written = connector.step(&frame, &mut output).unwrap(); + + assert!(written.size().is_some(), "an RTT request must produce a response frame"); + assert!( + matches!(connector.state, ClientConnectorState::ConnectTimeAutoDetection { .. }), + "the connector keeps listening after answering an auto-detect request" + ); +} + +#[test] +fn unrelated_message_channel_pdu_is_ignored_and_phase_continues() { + let mut connector = connect_time_autodetect_connector(); + + // A message-channel PDU that is not an auto-detect request: a bare security + // header without the SEC_AUTODETECT_REQ flag. It must be ignored, not handed + // to the licensing sequence (which would try to decode it as a license PDU). + let user_data = encode_vec(&BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::HEARTBEAT, + }) + .unwrap(); + let frame = server_send_data_indication(MESSAGE_CHANNEL_ID, user_data); + + let mut output = WriteBuf::new(); + let written = connector.step(&frame, &mut output).unwrap(); + + assert_eq!( + written, + Written::Nothing, + "an unrelated message-channel PDU produces no response" + ); + assert!( + matches!(connector.state, ClientConnectorState::ConnectTimeAutoDetection { .. }), + "the connector keeps listening on the message channel" + ); +} + +#[test] +fn first_licensing_pdu_leaves_autodetect_for_the_licensing_path() { + let mut connector = connect_time_autodetect_connector(); + + // The first PDU that is not on the message channel is the licensing PDU on + // the I/O channel. A STATUS_VALID_CLIENT license error completes licensing in + // a single step ([MS-RDPELE] 3.1.5.3.1), so the connector advances out of + // auto-detection into multitransport bootstrapping. + let license = LicensePdu::LicensingErrorMessage(LicensingErrorMessage { + license_header: LicenseHeader { + security_header: BasicSecurityHeader { + flags: BasicSecurityHeaderFlags::LICENSE_PKT, + }, + preamble_message_type: PreambleType::ErrorAlert, + preamble_flags: PreambleFlags::empty(), + preamble_version: PreambleVersion::V3, + preamble_message_size: 0x10, + }, + error_code: LicenseErrorCode::StatusValidClient, + state_transition: LicensingStateTransition::NoTransition, + error_info: Vec::new(), + }); + let user_data = encode_vec(&license).unwrap(); + let frame = server_send_data_indication(IO_CHANNEL_ID, user_data); + + let mut output = WriteBuf::new(); + connector.step(&frame, &mut output).unwrap(); + + assert!( + matches!( + connector.state, + ClientConnectorState::MultitransportBootstrapping { .. } + ), + "a completed licensing exchange advances the connector out of auto-detection" + ); +} diff --git a/crates/ironrdp-testsuite-core/tests/connector/mod.rs b/crates/ironrdp-testsuite-core/tests/connector/mod.rs new file mode 100644 index 0000000000..06f642f83d --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/connector/mod.rs @@ -0,0 +1 @@ +mod autodetect; diff --git a/crates/ironrdp-testsuite-core/tests/dvc/data_first.rs b/crates/ironrdp-testsuite-core/tests/dvc/data_first.rs index 81c7605cb6..aee11e2bf6 100644 --- a/crates/ironrdp-testsuite-core/tests/dvc/data_first.rs +++ b/crates/ironrdp-testsuite-core/tests/dvc/data_first.rs @@ -9,6 +9,7 @@ const DATA: [u8; 12] = [0x71, 0x71, 0x71, 0x71, 0x71, 0x71, 0x71, 0x71, 0x71, 0x const EDGE_CASE_LENGTH: u32 = 0x639; const EDGE_CASE_CHANNEL_ID: u32 = 0x07; const EDGE_CASE_PREFIX: [u8; 4] = [0x24, 0x7, 0x39, 0x6]; +#[expect(clippy::as_conversions)] const EDGE_CASE_DATA: [u8; EDGE_CASE_LENGTH as usize] = [ 0xe0, 0x24, 0xa9, 0xba, 0xe0, 0x68, 0xa9, 0xba, 0x8a, 0x73, 0x41, 0x25, 0x12, 0x12, 0x1c, 0x28, 0x3b, 0xa6, 0x34, 0x8, 0x8, 0x7a, 0x38, 0x34, 0x2c, 0xe8, 0xf8, 0xd0, 0xef, 0x18, 0xc2, 0xc, 0x27, 0x1f, 0xb1, 0x83, 0x3c, 0x58, diff --git a/crates/ironrdp-testsuite-core/tests/echo/mod.rs b/crates/ironrdp-testsuite-core/tests/echo/mod.rs new file mode 100644 index 0000000000..be521f6931 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/echo/mod.rs @@ -0,0 +1,59 @@ +use ironrdp_core::{decode, encode_vec}; +use ironrdp_dvc::DvcProcessor as _; +use ironrdp_echo::client::EchoClient; +use ironrdp_echo::pdu::{EchoRequestPdu, EchoResponsePdu}; +use ironrdp_echo::server::EchoServer; + +#[test] +fn request_pdu_roundtrip() { + let request = EchoRequestPdu::new(b"Hello world!".to_vec()); + let encoded = encode_vec(&request).expect("request should encode"); + let decoded: EchoRequestPdu = decode(&encoded).expect("request should decode"); + + assert_eq!(decoded.payload(), b"Hello world!"); +} + +#[test] +fn response_pdu_roundtrip() { + let response = EchoResponsePdu::new(b"Hello world!".to_vec()); + let encoded = encode_vec(&response).expect("response should encode"); + let decoded: EchoResponsePdu = decode(&encoded).expect("response should decode"); + + assert_eq!(decoded.payload(), b"Hello world!"); +} + +#[test] +fn client_echoes_request_payload() { + let mut client = EchoClient::new(); + let request = EchoRequestPdu::new(b"ping".to_vec()); + let encoded_request = encode_vec(&request).expect("request should encode"); + + let responses = client + .process(1, &encoded_request) + .expect("client should process request"); + + assert_eq!(responses.len(), 1); + + let encoded_response = encode_vec(responses[0].as_ref()).expect("response should encode"); + let response: EchoResponsePdu = decode(&encoded_response).expect("response should decode"); + assert_eq!(response.payload(), b"ping"); +} + +#[test] +fn server_with_initial_request_sends_one_message() { + let mut server = EchoServer::new().with_initial_request(b"probe".to_vec()); + + let messages = server.start(42).expect("server should start channel"); + + assert_eq!(messages.len(), 1); + + let encoded_request = encode_vec(messages[0].as_ref()).expect("request should encode"); + let request: EchoRequestPdu = decode(&encoded_request).expect("request should decode"); + assert_eq!(request.payload(), b"probe"); +} + +#[test] +fn server_rejects_empty_requests() { + let result = EchoServer::request_message(Vec::new()); + assert!(result.is_err()); +} diff --git a/crates/ironrdp-testsuite-core/tests/egfx/capabilities.rs b/crates/ironrdp-testsuite-core/tests/egfx/capabilities.rs new file mode 100644 index 0000000000..67cf57a672 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/egfx/capabilities.rs @@ -0,0 +1,73 @@ +//! Tests for resilient capability-set decoding, per the +//! "Enumeration-like types should allow resilient parsing" section of +//! `crates/ironrdp-pdu/README.md`. +//! +//! Two properties matter: +//! +//! - an unrecognized `CapabilityVersion` decodes into a +//! `RawCapabilitySet` whose `.parsed()` returns `None`, instead of +//! failing the whole PDU; +//! - the original wire bytes (including the version value) are preserved so +//! that `encode(decode(m)) == m`. + +use ironrdp_core::{decode, encode_vec}; +use ironrdp_egfx::pdu::{CapabilitiesAdvertisePdu, CapabilityVersion}; +use proptest::{prelude::*, sample::select}; + +/// Build a raw `RDPGFX_CAPS_ADVERTISE_PDU` carrying a single capset: +/// `capsSetCount=1` then `version`, `dataLength`, `data`. +fn raw_advertise_one(version: u32, data: &[u8]) -> Vec { + let mut buf = Vec::with_capacity(2 + 8 + data.len()); + buf.extend_from_slice(&1u16.to_le_bytes()); + buf.extend_from_slice(&version.to_le_bytes()); + let len = u32::try_from(data.len()).expect("test data length fits u32"); + buf.extend_from_slice(&len.to_le_bytes()); + buf.extend_from_slice(data); + buf +} + +/// Choose a random version with a bias towards well-known ones. +fn version() -> impl Strategy { + prop_oneof![ + select::(&[ + CapabilityVersion::V8.0, + CapabilityVersion::V8_1.0, + CapabilityVersion::V10.0, + CapabilityVersion::V10_1.0, + CapabilityVersion::V10_2.0, + CapabilityVersion::V10_3.0, + CapabilityVersion::V10_4.0, + CapabilityVersion::V10_5.0, + CapabilityVersion::V10_6.0, + CapabilityVersion::V10_6_ERR.0, + CapabilityVersion::V10_7.0, + ]), + any::(), + ] +} + +/// `encode(decode(wire)) == wire` for any version and any payload. +#[test] +fn capability_set_roundtrips() { + proptest!(|( + version in version(), + data in proptest::collection::vec(any::(), 0..32usize), + )| { + let wire = raw_advertise_one(version, &data); + let pdu: CapabilitiesAdvertisePdu = decode(&wire).expect("decode must tolerate unknown version"); + + let cap = &pdu.0[0]; + if cap.version.is_known() { + // `parsed()` may fail because of invalid data format (length, etc) for known versions. + if let Ok(parsed) = cap.parsed() { + prop_assert!(parsed.is_some()); + } + } else { + // `parsed()` never fails for unknown versions. + prop_assert!(cap.parsed().expect("parsed never errors for unknown versions").is_none()); + } + + let re_encoded = encode_vec(&pdu).expect("encode must succeed"); + prop_assert_eq!(re_encoded, wire); + }); +} diff --git a/crates/ironrdp-testsuite-core/tests/egfx/client.rs b/crates/ironrdp-testsuite-core/tests/egfx/client.rs new file mode 100644 index 0000000000..44bb079e49 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/egfx/client.rs @@ -0,0 +1,489 @@ +use ironrdp_core::{Decode as _, Encode, ReadCursor, WriteCursor, encode_vec}; +use ironrdp_dvc::DvcProcessor as _; +use ironrdp_egfx::client::{BitmapUpdate, GraphicsPipelineClient, GraphicsPipelineHandler, Surface}; +use ironrdp_egfx::decode::{DecodedFrame, DecoderResult, H264Decoder}; +use ironrdp_egfx::pdu::{ + CapabilitiesAdvertisePdu, CapabilitiesConfirmPdu, CapabilitiesV8Flags, CapabilitySet, CapabilityVersion, + Codec1Type, CreateSurfacePdu, DeleteSurfacePdu, EndFramePdu, GfxPdu, PixelFormat, ResetGraphicsPdu, StartFramePdu, + Timestamp, WireToSurface1Pdu, +}; +use ironrdp_graphics::zgfx::wrap_uncompressed; +use ironrdp_pdu::geometry::ExclusiveRectangle; + +// ============================================================================ +// Test Handler +// ============================================================================ + +struct TestHandler { + caps_confirmed: bool, + bitmaps_received: Vec<(u16, Codec1Type)>, + frames_completed: Vec, + reset_count: u32, +} + +impl TestHandler { + fn new() -> Self { + Self { + caps_confirmed: false, + bitmaps_received: Vec::new(), + frames_completed: Vec::new(), + reset_count: 0, + } + } +} + +impl GraphicsPipelineHandler for TestHandler { + fn on_capabilities_confirmed(&mut self, _caps: &CapabilitySet) { + self.caps_confirmed = true; + } + + fn on_reset_graphics(&mut self, _width: u32, _height: u32) { + self.reset_count += 1; + } + + fn on_surface_created(&mut self, _surface: &Surface) {} + fn on_surface_deleted(&mut self, _surface_id: u16) {} + fn on_surface_mapped(&mut self, _surface_id: u16, _x: u32, _y: u32) {} + + fn on_bitmap_updated(&mut self, update: &BitmapUpdate) { + self.bitmaps_received.push((update.surface_id, update.codec_id)); + } + + fn on_frame_complete(&mut self, frame_id: u32) { + self.frames_completed.push(frame_id); + } + + fn on_close(&mut self) {} + fn on_unhandled_pdu(&mut self, _pdu: &GfxPdu) {} +} + +// ============================================================================ +// Mock H.264 Decoder +// ============================================================================ + +struct MockH264Decoder; + +impl H264Decoder for MockH264Decoder { + fn decode(&mut self, _data: &[u8]) -> DecoderResult { + // Return a 16x16 solid red frame (macroblock-aligned minimum) + let mut data = vec![0u8; 16 * 16 * 4]; + for pixel in data.chunks_exact_mut(4) { + pixel[0] = 255; // R + pixel[3] = 255; // A + } + Ok(DecodedFrame::new(data, 16, 16)) + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +fn encode_pdu(pdu: &T) -> Vec { + let mut buf = vec![0u8; pdu.size()]; + let mut cursor = WriteCursor::new(&mut buf); + pdu.encode(&mut cursor).expect("encode failed"); + buf +} + +/// Encode a GfxPdu and wrap in a ZGFX uncompressed segment descriptor. +/// The client's process() expects ZGFX-segmented input (it runs decompression first). +fn encode_for_process(pdu: &GfxPdu) -> Vec { + let raw = encode_pdu(pdu); + wrap_uncompressed(&raw) +} + +fn decode_caps_from_message(msg: &ironrdp_dvc::DvcMessage) -> CapabilitiesAdvertisePdu { + let encoded = encode_vec(msg.as_ref()).expect("encode should succeed"); + let mut cursor = ReadCursor::new(&encoded); + let pdu = GfxPdu::decode(&mut cursor).expect("decode should succeed"); + match pdu { + GfxPdu::CapabilitiesAdvertise(caps) => caps, + other => panic!("expected CapabilitiesAdvertise, got {other:?}"), + } +} + +/// Create a client, send CapabilitiesConfirm V8 through process(), and create a surface. +fn setup_active_client_with_surface( + decoder: Option>, + surface_id: u16, + width: u16, + height: u16, +) -> GraphicsPipelineClient { + let handler = TestHandler::new(); + let mut client = GraphicsPipelineClient::new(Box::new(handler), decoder); + + // Activate via CapabilitiesConfirm + let confirm = GfxPdu::CapabilitiesConfirm(CapabilitiesConfirmPdu::from_typed(&CapabilitySet::V8 { + flags: CapabilitiesV8Flags::empty(), + })); + client + .process(0, &encode_for_process(&confirm)) + .expect("confirm should succeed"); + + // Create surface + let create = GfxPdu::CreateSurface(CreateSurfacePdu { + surface_id, + width, + height, + pixel_format: PixelFormat::XRgb, + }); + client + .process(0, &encode_for_process(&create)) + .expect("create surface should succeed"); + + client +} + +// ============================================================================ +// Tests: Capability Advertisement +// ============================================================================ + +#[test] +fn client_sends_capabilities_on_start() { + let handler = TestHandler::new(); + let mut client = GraphicsPipelineClient::new(Box::new(handler), None); + let messages = client.start(0).expect("start should succeed"); + assert_eq!(messages.len(), 1); +} + +#[test] +fn client_filters_avc_caps_without_decoder() { + let handler = TestHandler::new(); + let mut client = GraphicsPipelineClient::new(Box::new(handler), None); + let messages = client.start(0).expect("start should succeed"); + assert_eq!(messages.len(), 1); + + let caps_pdu = decode_caps_from_message(&messages[0]); + assert_eq!( + caps_pdu.0.len(), + 1, + "expected exactly one capability set when no decoder is present" + ); + assert!( + caps_pdu.0[0].version == CapabilityVersion::V8, + "expected only V8 capability set without decoder, got {:?}", + caps_pdu.0[0] + ); +} + +#[test] +fn client_keeps_avc_caps_with_decoder() { + let handler = TestHandler::new(); + let mut client = GraphicsPipelineClient::new(Box::new(handler), Some(Box::new(MockH264Decoder))); + let messages = client.start(0).expect("start should succeed"); + assert_eq!(messages.len(), 1); + + let caps_pdu = decode_caps_from_message(&messages[0]); + assert_eq!( + caps_pdu.0.len(), + 3, + "expected all three capability sets with decoder present" + ); + assert_eq!(caps_pdu.0[0].version, CapabilityVersion::V10_7); + assert_eq!(caps_pdu.0[1].version, CapabilityVersion::V8_1); + assert_eq!(caps_pdu.0[2].version, CapabilityVersion::V8); +} + +// ============================================================================ +// Tests: Frame Flow (via process() with encoded PDUs) +// ============================================================================ + +#[test] +fn client_sends_frame_ack_on_end_frame() { + let mut client = setup_active_client_with_surface(None, 1, 4, 4); + + let end = GfxPdu::EndFrame(EndFramePdu { frame_id: 42 }); + let responses = client + .process(0, &encode_for_process(&end)) + .expect("end frame should succeed"); + + assert_eq!(responses.len(), 1, "should produce exactly one FrameAcknowledge"); + assert_eq!(client.total_frames_decoded(), 1); +} + +#[test] +fn client_handles_uncompressed_via_process() { + let mut client = setup_active_client_with_surface(None, 1, 4, 4); + + let pdu = GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id: 1, + codec_id: Codec1Type::Uncompressed, + pixel_format: PixelFormat::XRgb, + destination_rectangle: ExclusiveRectangle { + left: 0, + top: 0, + right: 4, + bottom: 4, + }, + bitmap_data: vec![0u8; 4 * 4 * 4], + }); + client + .process(0, &encode_for_process(&pdu)) + .expect("uncompressed should succeed"); +} + +#[test] +fn client_dispatches_avc420_via_process() { + let mut client = setup_active_client_with_surface(Some(Box::new(MockH264Decoder)), 1, 16, 16); + + // Build minimal AVC420 bitmap stream + let mut bitmap_data = Vec::new(); + bitmap_data.extend_from_slice(&1u32.to_le_bytes()); // nRect = 1 + bitmap_data.extend_from_slice(&0u16.to_le_bytes()); // left + bitmap_data.extend_from_slice(&0u16.to_le_bytes()); // top + bitmap_data.extend_from_slice(&15u16.to_le_bytes()); // right + bitmap_data.extend_from_slice(&15u16.to_le_bytes()); // bottom + bitmap_data.push(22); // qp + bitmap_data.push(100); // quality + bitmap_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x01, 0x67]); // fake H.264 + + let pdu = GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id: 1, + codec_id: Codec1Type::Avc420, + pixel_format: PixelFormat::XRgb, + destination_rectangle: ExclusiveRectangle { + left: 0, + top: 0, + right: 16, + bottom: 16, + }, + bitmap_data, + }); + client + .process(0, &encode_for_process(&pdu)) + .expect("AVC420 should succeed"); +} + +#[test] +fn client_skips_avc420_without_decoder() { + let mut client = setup_active_client_with_surface(None, 1, 16, 16); + + let mut bitmap_data = Vec::new(); + bitmap_data.extend_from_slice(&1u32.to_le_bytes()); + bitmap_data.extend_from_slice(&0u16.to_le_bytes()); + bitmap_data.extend_from_slice(&0u16.to_le_bytes()); + bitmap_data.extend_from_slice(&15u16.to_le_bytes()); + bitmap_data.extend_from_slice(&15u16.to_le_bytes()); + bitmap_data.push(22); + bitmap_data.push(100); + bitmap_data.extend_from_slice(&[0x00, 0x00, 0x00, 0x01, 0x67]); + + let pdu = GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id: 1, + codec_id: Codec1Type::Avc420, + pixel_format: PixelFormat::XRgb, + destination_rectangle: ExclusiveRectangle { + left: 0, + top: 0, + right: 16, + bottom: 16, + }, + bitmap_data, + }); + client + .process(0, &encode_for_process(&pdu)) + .expect("should succeed without decoder"); +} + +#[test] +fn client_frame_ordering_via_process() { + let mut client = setup_active_client_with_surface(None, 1, 4, 4); + + // StartFrame + let start = GfxPdu::StartFrame(StartFramePdu { + timestamp: Timestamp { + milliseconds: 0, + seconds: 0, + minutes: 0, + hours: 0, + }, + frame_id: 1, + }); + client.process(0, &encode_for_process(&start)).expect("start frame"); + + // WireToSurface1 (uncompressed) + let wire = GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id: 1, + codec_id: Codec1Type::Uncompressed, + pixel_format: PixelFormat::XRgb, + destination_rectangle: ExclusiveRectangle { + left: 0, + top: 0, + right: 4, + bottom: 4, + }, + bitmap_data: vec![0u8; 4 * 4 * 4], + }); + client.process(0, &encode_for_process(&wire)).expect("wire to surface"); + + // EndFrame should produce FrameAcknowledge + let end = GfxPdu::EndFrame(EndFramePdu { frame_id: 1 }); + let responses = client.process(0, &encode_for_process(&end)).expect("end frame"); + + assert_eq!(responses.len(), 1); + assert_eq!(client.total_frames_decoded(), 1); +} + +// ============================================================================ +// Tests: Surface Lifecycle (via process()) +// ============================================================================ + +#[test] +fn client_creates_and_queries_surface() { + let client = setup_active_client_with_surface(None, 7, 1920, 1080); + + let surface = client.get_surface(7); + assert!(surface.is_some(), "surface 7 should exist after creation"); + assert_eq!(surface.unwrap().width, 1920); + assert_eq!(surface.unwrap().height, 1080); + + // Nonexistent surface + assert!(client.get_surface(99).is_none()); +} + +#[test] +fn client_deletes_surface_via_process() { + let mut client = setup_active_client_with_surface(None, 5, 100, 100); + assert!(client.get_surface(5).is_some()); + + let delete = GfxPdu::DeleteSurface(DeleteSurfacePdu { surface_id: 5 }); + client + .process(0, &encode_for_process(&delete)) + .expect("delete should succeed"); + + assert!(client.get_surface(5).is_none(), "surface should be gone after delete"); +} + +#[test] +fn client_resets_surfaces_via_process() { + let mut client = setup_active_client_with_surface(None, 1, 100, 100); + + // Create a second surface + let create2 = GfxPdu::CreateSurface(CreateSurfacePdu { + surface_id: 2, + width: 200, + height: 200, + pixel_format: PixelFormat::XRgb, + }); + client.process(0, &encode_for_process(&create2)).expect("create 2"); + assert!(client.get_surface(1).is_some()); + assert!(client.get_surface(2).is_some()); + + // ResetGraphics should clear all surfaces + let reset = GfxPdu::ResetGraphics(ResetGraphicsPdu { + width: 1920, + height: 1080, + monitors: vec![], + }); + client.process(0, &encode_for_process(&reset)).expect("reset"); + + assert!( + client.get_surface(1).is_none(), + "surface 1 should be cleared after reset" + ); + assert!( + client.get_surface(2).is_none(), + "surface 2 should be cleared after reset" + ); +} + +// ============================================================================ +// Tests: Error Handling +// ============================================================================ + +#[test] +fn client_rejects_wire_to_unknown_surface() { + let mut client = setup_active_client_with_surface(None, 1, 4, 4); + + let pdu = GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id: 99, // does not exist + codec_id: Codec1Type::Uncompressed, + pixel_format: PixelFormat::XRgb, + destination_rectangle: ExclusiveRectangle { + left: 0, + top: 0, + right: 4, + bottom: 4, + }, + bitmap_data: vec![0u8; 4 * 4 * 4], + }); + let result = client.process(0, &encode_for_process(&pdu)); + assert!(result.is_err(), "should reject write to nonexistent surface"); +} + +#[test] +fn client_rejects_invalid_rectangle_ordering() { + let mut client = setup_active_client_with_surface(None, 1, 100, 100); + + // left > right + let pdu = GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id: 1, + codec_id: Codec1Type::Uncompressed, + pixel_format: PixelFormat::XRgb, + destination_rectangle: ExclusiveRectangle { + left: 50, + top: 0, + right: 10, + bottom: 10, + }, + bitmap_data: vec![0u8; 4], + }); + let result = client.process(0, &encode_for_process(&pdu)); + assert!(result.is_err(), "left > right should be rejected"); +} + +#[test] +fn client_tolerates_out_of_bounds_rectangle() { + let mut client = setup_active_client_with_surface(None, 1, 100, 100); + + // Rectangle exceeds surface dimensions. The client logs a warning + // but continues processing (defensive: avoid disconnecting for a + // recoverable server-side error). + let pdu = GfxPdu::WireToSurface1(WireToSurface1Pdu { + surface_id: 1, + codec_id: Codec1Type::Uncompressed, + pixel_format: PixelFormat::XRgb, + destination_rectangle: ExclusiveRectangle { + left: 0, + top: 0, + right: 200, // exceeds surface width of 100 + bottom: 50, + }, + bitmap_data: vec![0u8; 200 * 50 * 4], + }); + let result = client.process(0, &encode_for_process(&pdu)); + assert!( + result.is_ok(), + "out-of-bounds rectangle should be tolerated (warn, not error)" + ); +} + +// ============================================================================ +// Tests: Multiple Frames +// ============================================================================ + +#[test] +fn client_tracks_frame_count_across_multiple_frames() { + let mut client = setup_active_client_with_surface(None, 1, 4, 4); + + for frame_id in 1..=5 { + let end = GfxPdu::EndFrame(EndFramePdu { frame_id }); + client.process(0, &encode_for_process(&end)).expect("end frame"); + } + + assert_eq!(client.total_frames_decoded(), 5); +} + +// ============================================================================ +// Tests: Close +// ============================================================================ + +#[test] +fn client_close_transitions_to_inactive() { + let mut client = setup_active_client_with_surface(None, 1, 4, 4); + assert!(client.is_active()); + + client.close(0); + assert!(!client.is_active(), "client should not be active after close"); +} diff --git a/crates/ironrdp-testsuite-core/tests/egfx/decode.rs b/crates/ironrdp-testsuite-core/tests/egfx/decode.rs new file mode 100644 index 0000000000..fb4d8aac67 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/egfx/decode.rs @@ -0,0 +1,179 @@ +use ironrdp_egfx::decode::{H264Decoder, OpenH264Decoder}; + +// ============================================================================ +// Test Helpers +// ============================================================================ + +/// Generate a minimal AVC-format H.264 bitstream by encoding a black 16x16 frame +/// +/// The encoder produces Annex B format (start code prefixed). This function +/// converts the output to AVC format (4-byte BE length prefixed) to exercise +/// the full decode pipeline including AVC-to-Annex-B conversion. +fn generate_test_avc_bitstream() -> Vec { + use openh264::encoder::Encoder; + use openh264::formats::YUVBuffer; + + let mut encoder = Encoder::new().expect("encoder should initialize"); + + // Black 16x16 YUV420p frame (all zeros) + let yuv = YUVBuffer::new(16, 16); + let bitstream = encoder.encode(&yuv).expect("encode should succeed"); + let annex_b = bitstream.to_vec(); + + annex_b_to_avc(&annex_b) +} + +/// Convert Annex B format NAL units to AVC format (4-byte BE length prefix) +fn annex_b_to_avc(data: &[u8]) -> Vec { + let mut avc = Vec::new(); + let mut i = 0; + + // Find NAL unit boundaries by scanning for start codes + let mut nal_starts = Vec::new(); + while i < data.len() { + if i + 3 < data.len() && data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 0 && data[i + 3] == 1 { + nal_starts.push(i + 4); + i += 4; + } else if i + 2 < data.len() && data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 { + nal_starts.push(i + 3); + i += 3; + } else { + i += 1; + } + } + + for (idx, &start) in nal_starts.iter().enumerate() { + let end = if idx + 1 < nal_starts.len() { + // Find the start code before the next NAL + let next_start = nal_starts[idx + 1]; + // Back up past the start code prefix + if next_start >= 4 && data[next_start - 4] == 0 && data[next_start - 3] == 0 && data[next_start - 2] == 0 { + next_start - 4 + } else { + next_start - 3 + } + } else { + data.len() + }; + + let nal_data = &data[start..end]; + + #[expect(clippy::as_conversions, reason = "NAL unit length for test data")] + let len = nal_data.len() as u32; + avc.extend_from_slice(&len.to_be_bytes()); + avc.extend_from_slice(nal_data); + } + + avc +} + +// ============================================================================ +// Happy Path Tests +// ============================================================================ + +#[test] +fn test_openh264_decoder_init() { + let _decoder = OpenH264Decoder::new().expect("decoder should initialize"); +} + +#[test] +fn test_openh264_decode_sps_pps() { + // Generate a full bitstream (SPS + PPS + IDR) and verify decode succeeds. + // SPS and PPS are always delivered together with the first I-frame + // in RFX_AVC420_BITMAP_STREAM payloads. + let avc_data = generate_test_avc_bitstream(); + assert!(!avc_data.is_empty(), "encoder should produce output"); + + let mut decoder = OpenH264Decoder::new().expect("decoder should initialize"); + let frame = decoder.decode(&avc_data).expect("decode should succeed"); + assert!(frame.width() >= 16, "decoded width should be at least 16"); + assert!(frame.height() >= 16, "decoded height should be at least 16"); +} + +#[test] +fn test_openh264_decode_iframe() { + let avc_data = generate_test_avc_bitstream(); + + let mut decoder = OpenH264Decoder::new().expect("decoder should initialize"); + let frame = decoder.decode(&avc_data).expect("decode should succeed"); + + // Verify RGBA output dimensions and data + assert_eq!(frame.width(), 16); + assert_eq!(frame.height(), 16); + assert_eq!(frame.data().len(), 16 * 16 * 4, "RGBA data should be 16x16x4 bytes"); +} + +#[test] +fn test_openh264_decoder_reset() { + let mut decoder = OpenH264Decoder::new().expect("decoder should initialize"); + + // Decode a frame to populate internal state + let avc_data = generate_test_avc_bitstream(); + let _ = decoder.decode(&avc_data); + + // Reset should not panic + decoder.reset(); + + // Decoder should still be usable after reset + let frame = decoder.decode(&avc_data).expect("decode after reset should succeed"); + assert_eq!(frame.width(), 16); + assert_eq!(frame.height(), 16); +} + +// ============================================================================ +// Error Path Tests +// ============================================================================ + +#[test] +fn test_decode_empty_input() { + let mut decoder = OpenH264Decoder::new().expect("decoder should initialize"); + + // Empty input has no NAL units -- the AVC-to-Annex-B converter + // produces nothing, and OpenH264 returns no picture. + let result = decoder.decode(&[]); + assert!(result.is_err(), "decoding empty input should fail"); +} + +#[test] +fn test_decode_truncated_nal_length() { + let mut decoder = OpenH264Decoder::new().expect("decoder should initialize"); + + // Less than 4 bytes: can't even read the NAL length prefix. + // The converter produces an empty Annex B buffer. + let result = decoder.decode(&[0x00, 0x00]); + assert!(result.is_err(), "truncated NAL length should fail"); +} + +#[test] +fn test_decode_nal_length_exceeds_buffer() { + let mut decoder = OpenH264Decoder::new().expect("decoder should initialize"); + + // NAL length says 100 bytes but only 2 bytes follow. + // The converter discards the malformed NAL and produces empty output. + let mut data = Vec::new(); + data.extend_from_slice(&100u32.to_be_bytes()); + data.extend_from_slice(&[0x67, 0x00]); // Partial NAL + let result = decoder.decode(&data); + assert!(result.is_err(), "oversized NAL length should fail"); +} + +#[test] +fn test_decode_garbage_input() { + let mut decoder = OpenH264Decoder::new().expect("decoder should initialize"); + + // Valid AVC framing (length prefix) but garbage NAL content. + // OpenH264 should either return an error or no picture. + let nal_data = [0xFF, 0xFE, 0xFD, 0xFC, 0xFB, 0xFA]; + let mut data = Vec::new(); + + #[expect(clippy::as_conversions, reason = "test data length")] + let len = nal_data.len() as u32; + data.extend_from_slice(&len.to_be_bytes()); + data.extend_from_slice(&nal_data); + + let result = decoder.decode(&data); + // OpenH264 may return Ok(no picture) which our decoder converts to an error, + // or it may return a decode error directly. Either way it shouldn't succeed + // with a valid frame. + assert!(result.is_err(), "garbage NAL content should not produce a valid frame"); +} diff --git a/crates/ironrdp-testsuite-core/tests/egfx/mod.rs b/crates/ironrdp-testsuite-core/tests/egfx/mod.rs new file mode 100644 index 0000000000..579c13bf9c --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/egfx/mod.rs @@ -0,0 +1,5 @@ +mod capabilities; +mod client; +#[cfg(feature = "openh264-bundled")] +mod decode; +mod server; diff --git a/crates/ironrdp-testsuite-core/tests/egfx/server.rs b/crates/ironrdp-testsuite-core/tests/egfx/server.rs new file mode 100644 index 0000000000..71964e30c4 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/egfx/server.rs @@ -0,0 +1,428 @@ +use ironrdp_core::{Encode, WriteCursor}; +use ironrdp_dvc::DvcProcessor as _; +use ironrdp_egfx::pdu::{ + Avc420Region, CapabilitiesAdvertisePdu, CapabilitiesV8Flags, CapabilitiesV10Flags, CapabilitiesV81Flags, + CapabilitySet, GfxPdu, +}; +use ironrdp_egfx::server::{GraphicsPipelineHandler, GraphicsPipelineServer, QoeMetrics, Surface}; + +// ============================================================================ +// Test Handler +// ============================================================================ + +struct TestHandler { + ready_called: bool, + negotiated: Option, + frame_acks: Vec<(u32, u32, u32)>, + surfaces_created: Vec, + surfaces_deleted: Vec, +} + +impl TestHandler { + fn new() -> Self { + Self { + ready_called: false, + negotiated: None, + frame_acks: Vec::new(), + surfaces_created: Vec::new(), + surfaces_deleted: Vec::new(), + } + } +} + +impl GraphicsPipelineHandler for TestHandler { + fn capabilities_advertise(&mut self, _pdu: &CapabilitiesAdvertisePdu) {} + + fn on_ready(&mut self, negotiated: &CapabilitySet) { + self.ready_called = true; + self.negotiated = Some(negotiated.clone()); + } + + fn on_frame_ack(&mut self, frame_id: u32, queue_depth: u32, total_frames_decoded: u32) { + self.frame_acks.push((frame_id, queue_depth, total_frames_decoded)); + } + + fn on_qoe_metrics(&mut self, _metrics: QoeMetrics) {} + + fn on_surface_created(&mut self, surface: &Surface) { + self.surfaces_created.push(surface.id); + } + + fn on_surface_deleted(&mut self, surface_id: u16) { + self.surfaces_deleted.push(surface_id); + } +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/// Encode a PDU to bytes for sending to server's process() method +fn encode_pdu(pdu: &T) -> Vec { + let mut buf = vec![0u8; pdu.size()]; + let mut cursor = WriteCursor::new(&mut buf); + pdu.encode(&mut cursor).expect("encode failed"); + buf +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[test] +fn test_server_creation() { + let handler = Box::new(TestHandler::new()); + let server = GraphicsPipelineServer::new(handler); + + assert!(!server.is_ready()); + assert_eq!(server.frames_in_flight(), 0); + assert!(!server.supports_avc420()); + assert!(!server.supports_avc444()); +} + +#[test] +fn test_capability_negotiation_v8() { + let handler = Box::new(TestHandler::new()); + let mut server = GraphicsPipelineServer::new(handler); + + // Simulate client sending CapabilitiesAdvertise + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8 { + flags: CapabilitiesV8Flags::SMALL_CACHE, + }])); + + let payload = encode_pdu(&client_caps_pdu); + let output = server.process(0, &payload).expect("process failed"); + + // Server should be ready now + assert!(server.is_ready()); + + // Should output CapabilitiesConfirm + assert_eq!(output.len(), 1); +} + +#[test] +fn test_capability_negotiation_v81_avc420() { + let handler = Box::new(TestHandler::new()); + let mut server = GraphicsPipelineServer::new(handler); + + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8_1 { + flags: CapabilitiesV81Flags::AVC420_ENABLED | CapabilitiesV81Flags::SMALL_CACHE, + }])); + + let payload = encode_pdu(&client_caps_pdu); + let _output = server.process(0, &payload).expect("process failed"); + + assert!(server.is_ready()); + assert!(server.supports_avc420()); + assert!(!server.supports_avc444()); +} + +#[test] +fn test_capability_negotiation_v10_avc444() { + let handler = Box::new(TestHandler::new()); + let mut server = GraphicsPipelineServer::new(handler); + + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V10 { + flags: CapabilitiesV10Flags::SMALL_CACHE, + }])); + + let payload = encode_pdu(&client_caps_pdu); + let _output = server.process(0, &payload).expect("process failed"); + + assert!(server.is_ready()); + assert!(server.supports_avc420()); + assert!(server.supports_avc444()); +} + +#[test] +fn test_server_not_ready_before_capabilities() { + let handler = Box::new(TestHandler::new()); + let mut server = GraphicsPipelineServer::new(handler); + + // Server should not accept frames before capability negotiation + let h264_data = vec![0x00, 0x00, 0x00, 0x01, 0x67]; + let regions = vec![Avc420Region::full_frame(1920, 1080, 22)]; + + let result = server.send_avc420_frame(0, &h264_data, ®ions, 0); + assert!(result.is_none()); +} + +#[test] +fn test_surface_lifecycle() { + let handler = Box::new(TestHandler::new()); + let mut server = GraphicsPipelineServer::new(handler); + + // Negotiate capabilities first + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8_1 { + flags: CapabilitiesV81Flags::AVC420_ENABLED, + }])); + let payload = encode_pdu(&client_caps_pdu); + let _output = server.process(0, &payload).expect("process failed"); + + assert!(server.is_ready()); + + // Create surface + let surface_id = server.create_surface(1920, 1080); + assert!(surface_id.is_some()); + let sid = surface_id.unwrap(); + + // Verify surface exists + let surface = server.get_surface(sid); + assert!(surface.is_some()); + assert_eq!(surface.unwrap().width, 1920); + assert_eq!(surface.unwrap().height, 1080); + + // Map to output + assert!(server.map_surface_to_output(sid, 0, 0)); + + // Delete surface + assert!(server.delete_surface(sid)); + assert!(server.get_surface(sid).is_none()); + + // Drain output: ResetGraphics (auto-sent before first surface), CreateSurface, + // MapSurfaceToOutput, DeleteSurface + let output = server.drain_output(); + assert_eq!(output.len(), 4); +} + +#[test] +fn test_resize() { + let handler = Box::new(TestHandler::new()); + let mut server = GraphicsPipelineServer::new(handler); + + // Negotiate capabilities + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8 { + flags: CapabilitiesV8Flags::SMALL_CACHE, + }])); + let payload = encode_pdu(&client_caps_pdu); + let _output = server.process(0, &payload).expect("process failed"); + + // Create a surface + let surface_id = server.create_surface(1920, 1080).unwrap(); + + // Resize + server.resize(2560, 1440); + + // Surface should be deleted + assert!(server.get_surface(surface_id).is_none()); + + // Output dimensions should be updated + assert_eq!(server.output_dimensions(), (2560, 1440)); + + // Should have output PDUs + assert!(server.has_pending_output()); +} + +#[test] +fn test_frame_flow_control() { + let handler = Box::new(TestHandler::new()); + let mut server = GraphicsPipelineServer::new(handler); + server.set_max_frames_in_flight(2); + + // Negotiate capabilities with AVC420 + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8_1 { + flags: CapabilitiesV81Flags::AVC420_ENABLED, + }])); + let payload = encode_pdu(&client_caps_pdu); + let _output = server.process(0, &payload).expect("process failed"); + + // Create surface + let surface_id = server.create_surface(1920, 1080).unwrap(); + server.drain_output(); // Clear setup PDUs + + let h264_data = vec![0x00, 0x00, 0x00, 0x01, 0x67]; + let regions = vec![Avc420Region::full_frame(1920, 1080, 22)]; + + // First two frames should succeed + let frame1 = server.send_avc420_frame(surface_id, &h264_data, ®ions, 0); + assert!(frame1.is_some()); + + let frame2 = server.send_avc420_frame(surface_id, &h264_data, ®ions, 16); + assert!(frame2.is_some()); + + // Check backpressure is active + assert!(server.should_backpressure()); + assert_eq!(server.frames_in_flight(), 2); + + // Third frame should fail due to backpressure + let frame3 = server.send_avc420_frame(surface_id, &h264_data, ®ions, 33); + assert!(frame3.is_none()); +} + +// ============================================================================ +// QoE Statistics Tests +// ============================================================================ + +#[test] +fn test_qoe_snapshot_none_before_data() { + let handler = Box::new(TestHandler::new()); + let server = GraphicsPipelineServer::new(handler); + + // No QoE reports yet. + assert!(server.qoe_snapshot().is_none()); +} + +#[test] +fn test_qoe_snapshot_after_frame_ack() { + use ironrdp_egfx::pdu::{FrameAcknowledgePdu, QueueDepth}; + + let handler = Box::new(TestHandler::new()); + let mut server = GraphicsPipelineServer::new(handler); + + // Negotiate capabilities. + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8_1 { + flags: CapabilitiesV81Flags::AVC420_ENABLED, + }])); + let payload = encode_pdu(&client_caps_pdu); + let _output = server.process(0, &payload).expect("process failed"); + + // Create surface and send a frame. + let surface_id = server.create_surface(1920, 1080).unwrap(); + server.drain_output(); + + let h264_data = vec![0x00, 0x00, 0x00, 0x01, 0x67]; + let regions = vec![Avc420Region::full_frame(1920, 1080, 22)]; + let frame_id = server.send_avc420_frame(surface_id, &h264_data, ®ions, 0); + assert!(frame_id.is_some()); + + // Simulate client frame acknowledgment. + let ack_pdu = GfxPdu::FrameAcknowledge(FrameAcknowledgePdu { + frame_id: frame_id.unwrap(), + queue_depth: QueueDepth::AvailableBytes(1), + total_frames_decoded: 1, + }); + let ack_payload = encode_pdu(&ack_pdu); + let _output = server.process(0, &ack_payload).expect("process failed"); + + // QoE snapshot should now have RTT data (no QoE reports, but RTT from ack). + let snapshot = server.qoe_snapshot(); + assert!(snapshot.is_some()); + + let snap = snapshot.unwrap(); + assert_eq!(snap.total_rtt_samples, 1); + // RTT should be some small value (frame was just sent). + assert!(snap.avg_rtt_ms < 1000.0); + // No QoE reports yet. + assert_eq!(snap.total_qoe_reports, 0); +} + +#[test] +fn test_qoe_snapshot_after_qoe_report() { + use ironrdp_egfx::pdu::QoeFrameAcknowledgePdu; + + let handler = Box::new(TestHandler::new()); + let mut server = GraphicsPipelineServer::new(handler); + + // Negotiate capabilities (V10 for QoE support). + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V10 { + flags: CapabilitiesV10Flags::SMALL_CACHE, + }])); + let payload = encode_pdu(&client_caps_pdu); + let _output = server.process(0, &payload).expect("process failed"); + + // Simulate QoE report. + let qoe_pdu = GfxPdu::QoeFrameAcknowledge(QoeFrameAcknowledgePdu { + frame_id: 0, + timestamp: 12345, + time_diff_se: 100, + time_diff_dr: 4500, + }); + let qoe_payload = encode_pdu(&qoe_pdu); + let _output = server.process(0, &qoe_payload).expect("process failed"); + + let snapshot = server.qoe_snapshot(); + assert!(snapshot.is_some()); + + let snap = snapshot.unwrap(); + assert_eq!(snap.total_qoe_reports, 1); + assert_eq!(snap.latest_decode_render_us, 4500); + assert!((snap.avg_decode_render_us - 4500.0).abs() < 0.1); + assert_eq!(snap.min_decode_render_us, 4500); + assert_eq!(snap.max_decode_render_us, 4500); +} + +#[test] +fn test_qoe_reset() { + use ironrdp_egfx::pdu::QoeFrameAcknowledgePdu; + + let handler = Box::new(TestHandler::new()); + let mut server = GraphicsPipelineServer::new(handler); + + // Negotiate. + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V10 { + flags: CapabilitiesV10Flags::SMALL_CACHE, + }])); + let payload = encode_pdu(&client_caps_pdu); + let _output = server.process(0, &payload).expect("process failed"); + + // Add a QoE report. + let qoe_pdu = GfxPdu::QoeFrameAcknowledge(QoeFrameAcknowledgePdu { + frame_id: 0, + timestamp: 1000, + time_diff_se: 50, + time_diff_dr: 3000, + }); + let qoe_payload = encode_pdu(&qoe_pdu); + let _output = server.process(0, &qoe_payload).expect("process failed"); + assert!(server.qoe_snapshot().is_some()); + + // Reset clears all statistics. + server.reset_qoe(); + assert!(server.qoe_snapshot().is_none()); +} + +// ============================================================================ +// Uncompressed Frame Tests +// ============================================================================ + +#[test] +fn test_send_uncompressed_frame_queues_correctly() { + let handler = Box::new(TestHandler::new()); + let mut server = GraphicsPipelineServer::new(handler); + + // V8 client: EGFX but no H.264 + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8 { + flags: CapabilitiesV8Flags::SMALL_CACHE, + }])); + let payload = encode_pdu(&client_caps_pdu); + let _output = server.process(0, &payload).expect("process failed"); + + let surface_id = server.create_surface(64, 64).unwrap(); + server.map_surface_to_output(surface_id, 0, 0); + server.drain_output(); // Clear setup PDUs + + // 64x64 XRGB = 16384 bytes + let pixel_data = vec![0xFFu8; 64 * 64 * 4]; + let frame_id = server.send_uncompressed_frame(surface_id, &pixel_data, 64, 64, 0); + assert!(frame_id.is_some()); + + // Output: StartFrame + WireToSurface1 + EndFrame + let output = server.drain_output(); + assert_eq!(output.len(), 3); +} + +#[test] +fn test_send_uncompressed_frame_backpressure() { + let handler = Box::new(TestHandler::new()); + let mut server = GraphicsPipelineServer::new(handler); + server.set_max_frames_in_flight(1); + + let client_caps_pdu = GfxPdu::CapabilitiesAdvertise(CapabilitiesAdvertisePdu::from_typed(&[CapabilitySet::V8 { + flags: CapabilitiesV8Flags::SMALL_CACHE, + }])); + let payload = encode_pdu(&client_caps_pdu); + let _output = server.process(0, &payload).expect("process failed"); + + let surface_id = server.create_surface(64, 64).unwrap(); + server.drain_output(); + + let pixel_data = vec![0xFFu8; 64 * 64 * 4]; + + // First frame succeeds + let frame1 = server.send_uncompressed_frame(surface_id, &pixel_data, 64, 64, 0); + assert!(frame1.is_some()); + + // Second frame blocked by backpressure + let frame2 = server.send_uncompressed_frame(surface_id, &pixel_data, 64, 64, 16); + assert!(frame2.is_none()); +} diff --git a/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs b/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs index 1d6e62ea2c..becbe6ab9b 100644 --- a/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs +++ b/crates/ironrdp-testsuite-core/tests/fuzz_regression.rs @@ -26,3 +26,33 @@ fn check_pdu_decode() { fn check_cliprdr_format() { check!(cliprdr_format); } + +#[test] +fn check_bulk_decompress_mppc() { + check!(bulk_decompress_mppc); +} + +#[test] +fn check_bulk_decompress_ncrush() { + check!(bulk_decompress_ncrush); +} + +#[test] +fn check_bulk_decompress_xcrush() { + check!(bulk_decompress_xcrush); +} + +#[test] +fn check_bulk_round_trip() { + check!(bulk_round_trip); +} + +#[test] +fn check_pdu_round_trip() { + check!(pdu_round_trip); +} + +#[test] +fn check_egfx_round_trip() { + check!(egfx_round_trip); +} diff --git a/crates/ironrdp-testsuite-core/tests/graphics/clearcodec.rs b/crates/ironrdp-testsuite-core/tests/graphics/clearcodec.rs new file mode 100644 index 0000000000..a0275083b2 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/graphics/clearcodec.rs @@ -0,0 +1,539 @@ +use ironrdp_core::ReadCursor; +use ironrdp_graphics::clearcodec::{ClearCodecDecoder, ClearCodecEncoder}; +use ironrdp_pdu::codecs::clearcodec::{ + ClearCodecBitmapStream, FLAG_CACHE_RESET, FLAG_GLYPH_HIT, FLAG_GLYPH_INDEX, RgbRunSegment, encode_residual_layer, +}; + +// ============================================================================ +// Helpers +// ============================================================================ + +/// Build a residual-only ClearCodec stream (no bands, no subcodec). +fn make_residual_stream(seq: u8, flags: u8, glyph_index: Option, residual: &[u8]) -> Vec { + let mut data = Vec::new(); + data.push(flags); + data.push(seq); + if let Some(idx) = glyph_index { + data.extend_from_slice(&idx.to_le_bytes()); + } + let residual_len = u32::try_from(residual.len()).unwrap(); + data.extend_from_slice(&residual_len.to_le_bytes()); + data.extend_from_slice(&0u32.to_le_bytes()); // bands + data.extend_from_slice(&0u32.to_le_bytes()); // subcodec + data.extend_from_slice(residual); + data +} + +/// Build a solid-color residual payload for width*height pixels. +fn make_solid_residual(b: u8, g: u8, r: u8, pixel_count: u32) -> Vec { + encode_residual_layer(&[RgbRunSegment { + blue: b, + green: g, + red: r, + run_length: pixel_count, + }]) +} + +/// Build BGRA pixel data for a solid color. +fn solid_bgra(b: u8, g: u8, r: u8, pixel_count: usize) -> Vec { + (0..pixel_count).flat_map(|_| [b, g, r, 0xFF]).collect() +} + +// ============================================================================ +// Codec Round-Trip (encode -> decode, pixel-perfect) +// ============================================================================ + +#[test] +fn round_trip_1x1_single_pixel() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x00, 0x00, 0x00, 1); + let wire = enc.encode(&bgra, 1, 1); + let result = dec.decode(&wire, 1, 1).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_4x4_solid_color() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x00, 0x00, 0xFF, 16); + let wire = enc.encode(&bgra, 4, 4); + let result = dec.decode(&wire, 4, 4).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_checkerboard_alternating_pixels() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let mut bgra = Vec::with_capacity(16 * 4); + for i in 0..16 { + if i % 2 == 0 { + bgra.extend_from_slice(&[0x00, 0x00, 0x00, 0xFF]); + } else { + bgra.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]); + } + } + let wire = enc.encode(&bgra, 4, 4); + let result = dec.decode(&wire, 4, 4).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_8x1_all_unique_colors() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra: Vec = (0..8u8).flat_map(|i| [i * 30, i * 20, i * 10, 0xFF]).collect(); + let wire = enc.encode(&bgra, 8, 1); + let result = dec.decode(&wire, 8, 1).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_100x100_triggers_medium_run_encoding() { + // 10,000 pixels requires factor2 (u16) encoding tier in residual layer + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x42, 0x84, 0xC6, 10_000); + let wire = enc.encode(&bgra, 100, 100); + let result = dec.decode(&wire, 100, 100).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_asymmetric_1x1000() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0xAB, 0xCD, 0xEF, 1000); + let wire = enc.encode(&bgra, 1, 1000); + let result = dec.decode(&wire, 1, 1000).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_asymmetric_1000x1() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x11, 0x22, 0x33, 1000); + let wire = enc.encode(&bgra, 1000, 1); + let result = dec.decode(&wire, 1000, 1).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_at_glyph_cache_boundary_1024_pixels() { + // 32x32 = 1024 pixels: maximum size eligible for glyph caching + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x80, 0x80, 0x80, 1024); + let wire = enc.encode(&bgra, 32, 32); + let result = dec.decode(&wire, 32, 32).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_over_glyph_threshold_no_caching() { + // 33x32 = 1056 pixels: too large for glyph caching + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0x80, 0x80, 0x80, 1056); + let wire = enc.encode(&bgra, 33, 32); + let result = dec.decode(&wire, 33, 32).unwrap(); + assert_eq!(result, bgra); +} + +#[test] +fn round_trip_two_color_stripe() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let mut bgra = Vec::new(); + for _ in 0..50 { + bgra.extend_from_slice(&[0x00, 0x00, 0xFF, 0xFF]); + } + for _ in 0..50 { + bgra.extend_from_slice(&[0xFF, 0x00, 0x00, 0xFF]); + } + let wire = enc.encode(&bgra, 100, 1); + let result = dec.decode(&wire, 100, 1).unwrap(); + assert_eq!(result, bgra); +} + +// ============================================================================ +// Adversarial Input (no panic, no hang, correct errors) +// ============================================================================ + +#[test] +fn adversarial_residual_max_run_length_completes_quickly() { + // run_length = u32::MAX in a 1x1 surface: must not spin for 4B iterations + let mut dec = ClearCodecDecoder::new(); + let residual = [0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + let stream = make_residual_stream(0, 0, None, &residual); + let result = dec.decode(&stream, 1, 1).unwrap(); + assert_eq!(result.len(), 4); + assert_eq!(&result[..3], &[0xFF, 0x00, 0x00]); // BGR written correctly +} + +#[test] +fn adversarial_residual_zero_run_produces_empty_output() { + let mut dec = ClearCodecDecoder::new(); + let residual = [0xFF, 0x00, 0x00, 0x00]; // run = 0 + let stream = make_residual_stream(0, 0, None, &residual); + let result = dec.decode(&stream, 1, 1).unwrap(); + assert_eq!(result, vec![0; 4]); // output stays zeroed +} + +#[test] +fn adversarial_glyph_hit_for_uncached_index() { + let mut dec = ClearCodecDecoder::new(); + let mut data = vec![FLAG_GLYPH_INDEX | FLAG_GLYPH_HIT, 0x00]; + data.extend_from_slice(&42u16.to_le_bytes()); + assert!(dec.decode(&data, 1, 1).is_err()); +} + +#[test] +fn adversarial_glyph_hit_without_glyph_index_flag() { + let mut dec = ClearCodecDecoder::new(); + let data = [FLAG_GLYPH_HIT, 0x00]; + assert!(dec.decode(&data, 1, 1).is_err()); +} + +#[test] +fn adversarial_glyph_index_out_of_spec_range() { + let mut dec = ClearCodecDecoder::new(); + // glyphIndex = 4000 (spec requires 0-3999) + let residual = make_solid_residual(0, 0, 0, 1); + let stream = make_residual_stream(0, FLAG_GLYPH_INDEX, Some(4000), &residual); + assert!(dec.decode(&stream, 1, 1).is_err()); +} + +#[test] +fn adversarial_glyph_index_max_u16() { + let mut dec = ClearCodecDecoder::new(); + let residual = make_solid_residual(0, 0, 0, 1); + let stream = make_residual_stream(0, FLAG_GLYPH_INDEX, Some(u16::MAX), &residual); + assert!(dec.decode(&stream, 1, 1).is_err()); +} + +#[test] +fn adversarial_composite_byte_count_overflow() { + let mut data = vec![0x00, 0x00]; // flags, seq + // residualByteCount + bandsByteCount overflows usize + data.extend_from_slice(&0xFFFFFFFFu32.to_le_bytes()); + data.extend_from_slice(&1u32.to_le_bytes()); + data.extend_from_slice(&0u32.to_le_bytes()); + let mut cursor = ReadCursor::new(&data); + assert!(ClearCodecBitmapStream::decode(&mut cursor).is_err()); +} + +#[test] +fn adversarial_sequence_number_wraps_at_256() { + let mut dec = ClearCodecDecoder::new(); + // Drive sequence through 0..255 and back to 0 (wrapping) + for seq in 0..=255u8 { + let residual = make_solid_residual(0, 0, 0, 1); + let stream = make_residual_stream(seq, 0, None, &residual); + dec.decode(&stream, 1, 1).unwrap(); + } + // Wrap back to 0 + let residual = make_solid_residual(0, 0, 0, 1); + let stream = make_residual_stream(0, 0, None, &residual); + dec.decode(&stream, 1, 1).unwrap(); +} + +#[test] +fn adversarial_stream_truncated_to_1_byte() { + let data = [0x00]; + let mut cursor = ReadCursor::new(&data); + assert!(ClearCodecBitmapStream::decode(&mut cursor).is_err()); +} + +#[test] +fn adversarial_stream_empty() { + let data = []; + let mut cursor = ReadCursor::new(&data); + assert!(ClearCodecBitmapStream::decode(&mut cursor).is_err()); +} + +// ============================================================================ +// Cache State Management +// ============================================================================ + +#[test] +fn glyph_cache_store_then_hit() { + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0xFF, 0x00, 0x00, 1); + + // Frame 1: store glyph at index 42 + let residual = make_solid_residual(0xFF, 0x00, 0x00, 1); + let stream1 = make_residual_stream(0, FLAG_GLYPH_INDEX, Some(42), &residual); + let p1 = dec.decode(&stream1, 1, 1).unwrap(); + assert_eq!(p1, bgra); + + // Frame 2: glyph hit at index 42 + let mut stream2 = vec![FLAG_GLYPH_INDEX | FLAG_GLYPH_HIT, 0x01]; + stream2.extend_from_slice(&42u16.to_le_bytes()); + let p2 = dec.decode(&stream2, 1, 1).unwrap(); + assert_eq!(p2, bgra); +} + +#[test] +fn glyph_cache_overwrite_at_same_index() { + let mut dec = ClearCodecDecoder::new(); + + // Store red at index 0 + let red_residual = make_solid_residual(0x00, 0x00, 0xFF, 1); + let stream1 = make_residual_stream(0, FLAG_GLYPH_INDEX, Some(0), &red_residual); + dec.decode(&stream1, 1, 1).unwrap(); + + // Overwrite with blue at index 0 + let blue_residual = make_solid_residual(0xFF, 0x00, 0x00, 1); + let stream2 = make_residual_stream(1, FLAG_GLYPH_INDEX, Some(0), &blue_residual); + dec.decode(&stream2, 1, 1).unwrap(); + + // Hit should return blue + let mut stream3 = vec![FLAG_GLYPH_INDEX | FLAG_GLYPH_HIT, 0x02]; + stream3.extend_from_slice(&0u16.to_le_bytes()); + let result = dec.decode(&stream3, 1, 1).unwrap(); + assert_eq!(result[0], 0xFF); // blue channel +} + +#[test] +fn cache_reset_does_not_panic() { + let mut dec = ClearCodecDecoder::new(); + let residual = make_solid_residual(0, 0, 0, 1); + let stream1 = make_residual_stream(0, 0, None, &residual); + dec.decode(&stream1, 1, 1).unwrap(); + + let stream2 = [FLAG_CACHE_RESET, 0x01]; + let _ = dec.decode(&stream2, 0, 0); +} + +#[test] +fn encoder_glyph_hit_produces_smaller_output() { + let mut enc = ClearCodecEncoder::new(); + let bgra = solid_bgra(0xAA, 0xBB, 0xCC, 1); + + let first = enc.encode(&bgra, 1, 1); + let second = enc.encode(&bgra, 1, 1); // should be glyph hit + + assert!(second.len() < first.len(), "glyph hit should be smaller"); +} + +#[test] +fn encoder_glyph_miss_after_content_change() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let red = solid_bgra(0x00, 0x00, 0xFF, 1); + let blue = solid_bgra(0xFF, 0x00, 0x00, 1); + + let first = enc.encode(&red, 1, 1); + let second = enc.encode(&blue, 1, 1); // different content, full encode + + // Verify both are full encodes (not glyph hits) by checking they + // contain a composite header (minimum 14 bytes: flags + seq + 3*u32) + assert!(second.len() >= 14, "changed content should produce a full encode"); + + // Verify they decode to the correct distinct colors + let decoded_red = dec.decode(&first, 1, 1).unwrap(); + let decoded_blue = dec.decode(&second, 1, 1).unwrap(); + assert_eq!(decoded_red, red); + assert_eq!(decoded_blue, blue); +} + +#[test] +fn encoder_sequence_numbers_increment_correctly() { + let mut enc = ClearCodecEncoder::new(); + let bgra = solid_bgra(0, 0, 0, 1); + + let e1 = enc.encode(&bgra, 1, 1); + let e2 = enc.encode(&bgra, 1, 1); // glyph hit + let e3 = enc.encode(&solid_bgra(0xFF, 0xFF, 0xFF, 1), 1, 1); // different + + assert_eq!(e1[1], 0); // seq byte at offset 1 + assert_eq!(e2[1], 1); + assert_eq!(e3[1], 2); +} + +#[test] +fn encoder_cache_reset_round_trips() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let reset = enc.encode_cache_reset(); + let _ = dec.decode(&reset, 0, 0); +} + +// ============================================================================ +// Compression Quality +// ============================================================================ + +#[test] +fn solid_color_compresses_below_30_bytes() { + let mut enc = ClearCodecEncoder::new(); + let bgra = solid_bgra(0x42, 0x84, 0xC6, 10_000); + let wire = enc.encode(&bgra, 100, 100); + // 10,000 pixels = 40,000 bytes raw. Solid color: header + 1 run segment. + assert!( + wire.len() < 30, + "solid 100x100 should compress to <30 bytes, got {}", + wire.len() + ); +} + +#[test] +fn unique_pixels_do_not_expand_beyond_raw() { + let mut enc = ClearCodecEncoder::new(); + let bgra: Vec = (0..100u8) + .flat_map(|i| [i, i.wrapping_mul(2), i.wrapping_mul(3), 0xFF]) + .collect(); + let wire = enc.encode(&bgra, 100, 1); + // Worst case: each pixel is unique, 1 segment per pixel. + // Should not be larger than raw + header overhead. + assert!(wire.len() < bgra.len() + 50); +} + +// ============================================================================ +// Multi-Frame Session Simulation +// ============================================================================ + +#[test] +fn session_10_frames_mixed_colors() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + + let colors: Vec<(u8, u8, u8)> = vec![ + (0, 0, 0), + (0xFF, 0, 0), + (0, 0xFF, 0), + (0, 0, 0xFF), + (0xFF, 0xFF, 0), + (0xFF, 0, 0xFF), + (0, 0xFF, 0xFF), + (0x80, 0x80, 0x80), + (0xFF, 0xFF, 0xFF), + (0, 0, 0), + ]; + + for (b, g, r) in &colors { + let bgra = solid_bgra(*b, *g, *r, 4); + let wire = enc.encode(&bgra, 2, 2); + let result = dec.decode(&wire, 2, 2).unwrap(); + assert_eq!(result, bgra); + } +} + +#[test] +fn session_repeated_frames_hit_glyph_cache() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + let bgra = solid_bgra(0xDE, 0xAD, 0xBE, 4); + + let wire1 = enc.encode(&bgra, 2, 2); + let len1 = wire1.len(); + dec.decode(&wire1, 2, 2).unwrap(); + + // Subsequent encodes should be glyph hits (smaller) + for _ in 0..5 { + let wire = enc.encode(&bgra, 2, 2); + assert!(wire.len() < len1, "repeated frame should use glyph cache"); + let result = dec.decode(&wire, 2, 2).unwrap(); + assert_eq!(result, bgra); + } +} + +#[test] +fn session_encoder_decoder_stay_synchronized_across_50_frames() { + let mut enc = ClearCodecEncoder::new(); + let mut dec = ClearCodecDecoder::new(); + + for i in 0u8..50 { + let bgra = solid_bgra(i, i.wrapping_mul(3), i.wrapping_mul(7), 9); + let wire = enc.encode(&bgra, 3, 3); + let result = dec.decode(&wire, 3, 3).unwrap(); + assert_eq!(result, bgra, "mismatch at frame {i}"); + } +} + +// ============================================================================ +// Bands Layer Compositing (integration through decoder) +// ============================================================================ + +#[test] +fn decode_stream_with_bands_layer_short_vbar_cache_miss() { + // Construct a minimal ClearCodec stream with a bands layer containing + // one band, one column, using a ShortVBarCacheMiss. This exercises the + // full decode_composite -> resolve_vbar -> blit path. + let mut dec = ClearCodecDecoder::new(); + + // Surface: 4 pixels wide, 4 pixels tall + let width: u16 = 4; + let height: u16 = 4; + + // Build bands layer data: one band covering column 1, rows 0-3 + let mut bands_data = Vec::new(); + bands_data.extend_from_slice(&1u16.to_le_bytes()); // x_start = 1 + bands_data.extend_from_slice(&1u16.to_le_bytes()); // x_end = 1 (1 column) + bands_data.extend_from_slice(&0u16.to_le_bytes()); // y_start = 0 + bands_data.extend_from_slice(&3u16.to_le_bytes()); // y_end = 3 (height = 4) + bands_data.extend_from_slice(&[0x00, 0x00, 0x00]); // background BGR = black + + // V-bar: ShortCacheMiss with y_on=1, y_off=3 (2 pixels at rows 1-2) + // bits 13:6 = y_on (1), bits 5:0 = y_off (3) + let vbar_word: u16 = (1 << 6) | 3; + bands_data.extend_from_slice(&vbar_word.to_le_bytes()); + // 2 pixels * 3 bytes = 6 bytes of BGR pixel data (red) + bands_data.extend_from_slice(&[0x00, 0x00, 0xFF]); // row 1: red + bands_data.extend_from_slice(&[0x00, 0x00, 0xFF]); // row 2: red + + // Build the full stream: no residual, bands only, no subcodec + let mut stream = Vec::new(); + stream.push(0x00); // flags + stream.push(0x00); // seq + stream.extend_from_slice(&0u32.to_le_bytes()); // residualByteCount = 0 + let bands_len = u32::try_from(bands_data.len()).unwrap(); + stream.extend_from_slice(&bands_len.to_le_bytes()); // bandsByteCount + stream.extend_from_slice(&0u32.to_le_bytes()); // subcodecByteCount = 0 + stream.extend_from_slice(&bands_data); + + let pixels = dec.decode(&stream, width, height).unwrap(); + assert_eq!(pixels.len(), usize::from(width) * usize::from(height) * 4); + + // Check column 1, row 1: should be red (from short V-bar pixel data) + let row1_col1 = (usize::from(width) + 1) * 4; + assert_eq!(pixels[row1_col1], 0x00, "blue channel at (1,1)"); + assert_eq!(pixels[row1_col1 + 1], 0x00, "green channel at (1,1)"); + assert_eq!(pixels[row1_col1 + 2], 0xFF, "red channel at (1,1)"); + + // Check column 1, row 0: should be background (black, from band bkg) + let row0_col1 = 4; // row=0, col=1 -> offset 4 + assert_eq!(pixels[row0_col1], 0x00, "blue channel at (1,0)"); + assert_eq!(pixels[row0_col1 + 1], 0x00, "green channel at (1,0)"); + assert_eq!(pixels[row0_col1 + 2], 0x00, "red channel at (1,0)"); + + // Check column 1, row 3: should also be background + let idx3 = (3 * usize::from(width) + 1) * 4; + assert_eq!(pixels[idx3], 0x00, "blue channel at (1,3)"); + assert_eq!(pixels[idx3 + 1], 0x00, "green channel at (1,3)"); + assert_eq!(pixels[idx3 + 2], 0x00, "red channel at (1,3)"); +} + +#[test] +fn adversarial_large_dimensions_rejected() { + // 65535x65535 would allocate ~17GB. The decoder should reject it. + let mut dec = ClearCodecDecoder::new(); + let residual = make_solid_residual(0, 0, 0, 1); + let stream = make_residual_stream(0, 0, None, &residual); + assert!(dec.decode(&stream, u16::MAX, u16::MAX).is_err()); +} + +#[test] +fn large_but_reasonable_dimensions_accepted() { + // 1920x1080 = 2,073,600 pixels should work fine + let mut dec = ClearCodecDecoder::new(); + let residual = make_solid_residual(0x42, 0x42, 0x42, 1920 * 1080); + let stream = make_residual_stream(0, 0, None, &residual); + let result = dec.decode(&stream, 1920, 1080).unwrap(); + assert_eq!(result.len(), 1920 * 1080 * 4); + // Spot-check first pixel + assert_eq!(&result[..4], &[0x42, 0x42, 0x42, 0xFF]); +} diff --git a/crates/ironrdp-testsuite-core/tests/graphics/color_conversion.rs b/crates/ironrdp-testsuite-core/tests/graphics/color_conversion.rs index 585eb6a9ad..8e6bee3249 100644 --- a/crates/ironrdp-testsuite-core/tests/graphics/color_conversion.rs +++ b/crates/ironrdp-testsuite-core/tests/graphics/color_conversion.rs @@ -8,7 +8,7 @@ fn to_64x64_ycbcr() { let mut y = [0; 64 * 64]; let mut cb = [0; 64 * 64]; let mut cr = [0; 64 * 64]; - to_64x64_ycbcr_tile(&input, 1, 1, 4, PixelFormat::ABgr32, &mut y, &mut cb, &mut cr); + to_64x64_ycbcr_tile(&input, 1, 1, 4, PixelFormat::ABgr32, &mut y, &mut cb, &mut cr).unwrap(); } #[ignore] @@ -24,7 +24,7 @@ fn rgb_to_ycbcr_converts_large_buffer() { let mut y = [0; 4096]; let mut cb = [0; 4096]; let mut cr = [0; 4096]; - to_64x64_ycbcr_tile(xrgb, 64, 64, 64 * 4, PixelFormat::XRgb32, &mut y, &mut cb, &mut cr); + to_64x64_ycbcr_tile(xrgb, 64, 64, 64 * 4, PixelFormat::XRgb32, &mut y, &mut cb, &mut cr).unwrap(); assert_eq!(expected.y, y.as_slice()); } @@ -1595,3 +1595,59 @@ const XRGB_BUFFER: [u8; 4 * 64 * 64] = [ 0xf7, 0x00, 0x14, 0x9d, 0xf7, 0x00, 0x13, 0x9c, 0xf6, 0x00, 0x12, 0x9b, 0xf5, 0x00, 0x12, 0x9b, 0xf5, 0x00, 0x12, 0x9b, 0xf5, 0x00, 0x12, 0x9b, 0xf5, ]; + +#[test] +fn rdp_15bit_black() { + assert_eq!(rdp_15bit_to_rgb(0x0000), [0, 0, 0]); +} + +#[test] +fn rdp_15bit_white() { + assert_eq!(rdp_15bit_to_rgb(0x7FFF), [255, 255, 255]); +} + +#[test] +fn rdp_15bit_pure_red() { + // R=31, G=0, B=0: 0_11111_00000_00000 = 0x7C00 + assert_eq!(rdp_15bit_to_rgb(0x7C00), [255, 0, 0]); +} + +#[test] +fn rdp_15bit_pure_green() { + // R=0, G=31, B=0: 0_00000_11111_00000 = 0x03E0 + assert_eq!(rdp_15bit_to_rgb(0x03E0), [0, 255, 0]); +} + +#[test] +fn rdp_15bit_pure_blue() { + // R=0, G=0, B=31: 0_00000_00000_11111 = 0x001F + assert_eq!(rdp_15bit_to_rgb(0x001F), [0, 0, 255]); +} + +#[test] +fn rdp_16bit_black() { + assert_eq!(rdp_16bit_to_rgb(0x0000), [0, 0, 0]); +} + +#[test] +fn rdp_16bit_white() { + assert_eq!(rdp_16bit_to_rgb(0xFFFF), [255, 255, 255]); +} + +#[test] +fn rdp_16bit_pure_red() { + // R=31, G=0, B=0: 11111_000000_00000 = 0xF800 + assert_eq!(rdp_16bit_to_rgb(0xF800), [255, 0, 0]); +} + +#[test] +fn rdp_16bit_pure_green() { + // R=0, G=63, B=0: 00000_111111_00000 = 0x07E0 + assert_eq!(rdp_16bit_to_rgb(0x07E0), [0, 255, 0]); +} + +#[test] +fn rdp_16bit_pure_blue() { + // R=0, G=0, B=31: 00000_000000_11111 = 0x001F + assert_eq!(rdp_16bit_to_rgb(0x001F), [0, 0, 255]); +} diff --git a/crates/ironrdp-testsuite-core/tests/graphics/mod.rs b/crates/ironrdp-testsuite-core/tests/graphics/mod.rs index 50aa61f6e7..9846336c25 100644 --- a/crates/ironrdp-testsuite-core/tests/graphics/mod.rs +++ b/crates/ironrdp-testsuite-core/tests/graphics/mod.rs @@ -1,3 +1,4 @@ +mod clearcodec; mod color_conversion; mod dwt; mod image_processing; diff --git a/crates/ironrdp-testsuite-core/tests/graphics/rle.rs b/crates/ironrdp-testsuite-core/tests/graphics/rle.rs deleted file mode 100644 index a13a8cbaa0..0000000000 --- a/crates/ironrdp-testsuite-core/tests/graphics/rle.rs +++ /dev/null @@ -1,57 +0,0 @@ -use rstest::rstest; - -/// 64x64 tile samples were generated using rdp-rs crate -#[rstest] -#[case::x27019fd9f222cebce9dfebcddb12bfa0( - include_bytes!("../../test_data/rle/tile-27019fd9f222cebce9dfebcddb12bfa0-compressed.bin"), - include_bytes!("../../test_data/rle/tile-27019fd9f222cebce9dfebcddb12bfa0-decompressed.bin"), -)] -#[case::x284f668a9366a95e45f15b6bf634a633( - include_bytes!("../../test_data/rle/tile-284f668a9366a95e45f15b6bf634a633-compressed.bin"), - include_bytes!("../../test_data/rle/tile-284f668a9366a95e45f15b6bf634a633-decompressed.bin"), -)] -#[case::x28c08e75c82ab598c5ab85d1bfc00253( - include_bytes!("../../test_data/rle/tile-28c08e75c82ab598c5ab85d1bfc00253-compressed.bin"), - include_bytes!("../../test_data/rle/tile-28c08e75c82ab598c5ab85d1bfc00253-decompressed.bin"), -)] -#[case::x2de3f3262a5eeecc3152552c178b782a( - include_bytes!("../../test_data/rle/tile-2de3f3262a5eeecc3152552c178b782a-compressed.bin"), - include_bytes!("../../test_data/rle/tile-2de3f3262a5eeecc3152552c178b782a-decompressed.bin"), -)] -#[case::x3fc8124af9be2fe88b445db60c36eddc( - include_bytes!("../../test_data/rle/tile-3fc8124af9be2fe88b445db60c36eddc-compressed.bin"), - include_bytes!("../../test_data/rle/tile-3fc8124af9be2fe88b445db60c36eddc-decompressed.bin"), -)] -#[case::x4d75aa6a18c435c6230ba739b802a861( - include_bytes!("../../test_data/rle/tile-4d75aa6a18c435c6230ba739b802a861-compressed.bin"), - include_bytes!("../../test_data/rle/tile-4d75aa6a18c435c6230ba739b802a861-decompressed.bin"), -)] -#[case::x8b8ccc77526730d0cd8989901cc031ec( - include_bytes!("../../test_data/rle/tile-8b8ccc77526730d0cd8989901cc031ec-compressed.bin"), - include_bytes!("../../test_data/rle/tile-8b8ccc77526730d0cd8989901cc031ec-decompressed.bin"), -)] -#[case::x94bb5b131eb3bc110905dfcb0f60da79( - include_bytes!("../../test_data/rle/tile-94bb5b131eb3bc110905dfcb0f60da79-compressed.bin"), - include_bytes!("../../test_data/rle/tile-94bb5b131eb3bc110905dfcb0f60da79-decompressed.bin"), -)] -#[case::x9b06660a1da806d2d48ce3f46b45d571( - include_bytes!("../../test_data/rle/tile-9b06660a1da806d2d48ce3f46b45d571-compressed.bin"), - include_bytes!("../../test_data/rle/tile-9b06660a1da806d2d48ce3f46b45d571-decompressed.bin"), -)] -#[case::xa412fbe2b435ac627ce39048aa3d3fb3( - include_bytes!("../../test_data/rle/tile-a412fbe2b435ac627ce39048aa3d3fb3-compressed.bin"), - include_bytes!("../../test_data/rle/tile-a412fbe2b435ac627ce39048aa3d3fb3-decompressed.bin"), -)] -#[case::xaa326e7a536cc8a0420c44bdf4ef8d97( - include_bytes!("../../test_data/rle/tile-aa326e7a536cc8a0420c44bdf4ef8d97-compressed.bin"), - include_bytes!("../../test_data/rle/tile-aa326e7a536cc8a0420c44bdf4ef8d97-decompressed.bin"), -)] -#[case::xfbcefc9af4db651aefd91bcabc8ea9fc( - include_bytes!("../../test_data/rle/tile-fbcefc9af4db651aefd91bcabc8ea9fc-compressed.bin"), - include_bytes!("../../test_data/rle/tile-fbcefc9af4db651aefd91bcabc8ea9fc-decompressed.bin"), -)] -fn decompress_bpp_16(#[case] src: &[u8], #[case] expected: &[u8]) { - let mut out = Vec::new(); - ironrdp_graphics::rle::decompress_16_bpp(src, &mut out, 64, 64).expect("decompress 16 bpp"); - assert_eq!(out, expected); -} diff --git a/crates/ironrdp-testsuite-core/tests/graphics/rle/mod.rs b/crates/ironrdp-testsuite-core/tests/graphics/rle/mod.rs new file mode 100644 index 0000000000..6b1f155aa6 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/graphics/rle/mod.rs @@ -0,0 +1,57 @@ +use rstest::rstest; + +/// 64x64 tile samples were generated using rdp-rs crate +#[rstest] +#[case::x27019fd9f222cebce9dfebcddb12bfa0( + include_bytes!("../../../test_data/rle/tile-27019fd9f222cebce9dfebcddb12bfa0-compressed.bin"), + include_bytes!("../../../test_data/rle/tile-27019fd9f222cebce9dfebcddb12bfa0-decompressed.bin"), +)] +#[case::x284f668a9366a95e45f15b6bf634a633( + include_bytes!("../../../test_data/rle/tile-284f668a9366a95e45f15b6bf634a633-compressed.bin"), + include_bytes!("../../../test_data/rle/tile-284f668a9366a95e45f15b6bf634a633-decompressed.bin"), +)] +#[case::x28c08e75c82ab598c5ab85d1bfc00253( + include_bytes!("../../../test_data/rle/tile-28c08e75c82ab598c5ab85d1bfc00253-compressed.bin"), + include_bytes!("../../../test_data/rle/tile-28c08e75c82ab598c5ab85d1bfc00253-decompressed.bin"), +)] +#[case::x2de3f3262a5eeecc3152552c178b782a( + include_bytes!("../../../test_data/rle/tile-2de3f3262a5eeecc3152552c178b782a-compressed.bin"), + include_bytes!("../../../test_data/rle/tile-2de3f3262a5eeecc3152552c178b782a-decompressed.bin"), +)] +#[case::x3fc8124af9be2fe88b445db60c36eddc( + include_bytes!("../../../test_data/rle/tile-3fc8124af9be2fe88b445db60c36eddc-compressed.bin"), + include_bytes!("../../../test_data/rle/tile-3fc8124af9be2fe88b445db60c36eddc-decompressed.bin"), +)] +#[case::x4d75aa6a18c435c6230ba739b802a861( + include_bytes!("../../../test_data/rle/tile-4d75aa6a18c435c6230ba739b802a861-compressed.bin"), + include_bytes!("../../../test_data/rle/tile-4d75aa6a18c435c6230ba739b802a861-decompressed.bin"), +)] +#[case::x8b8ccc77526730d0cd8989901cc031ec( + include_bytes!("../../../test_data/rle/tile-8b8ccc77526730d0cd8989901cc031ec-compressed.bin"), + include_bytes!("../../../test_data/rle/tile-8b8ccc77526730d0cd8989901cc031ec-decompressed.bin"), +)] +#[case::x94bb5b131eb3bc110905dfcb0f60da79( + include_bytes!("../../../test_data/rle/tile-94bb5b131eb3bc110905dfcb0f60da79-compressed.bin"), + include_bytes!("../../../test_data/rle/tile-94bb5b131eb3bc110905dfcb0f60da79-decompressed.bin"), +)] +#[case::x9b06660a1da806d2d48ce3f46b45d571( + include_bytes!("../../../test_data/rle/tile-9b06660a1da806d2d48ce3f46b45d571-compressed.bin"), + include_bytes!("../../../test_data/rle/tile-9b06660a1da806d2d48ce3f46b45d571-decompressed.bin"), +)] +#[case::xa412fbe2b435ac627ce39048aa3d3fb3( + include_bytes!("../../../test_data/rle/tile-a412fbe2b435ac627ce39048aa3d3fb3-compressed.bin"), + include_bytes!("../../../test_data/rle/tile-a412fbe2b435ac627ce39048aa3d3fb3-decompressed.bin"), +)] +#[case::xaa326e7a536cc8a0420c44bdf4ef8d97( + include_bytes!("../../../test_data/rle/tile-aa326e7a536cc8a0420c44bdf4ef8d97-compressed.bin"), + include_bytes!("../../../test_data/rle/tile-aa326e7a536cc8a0420c44bdf4ef8d97-decompressed.bin"), +)] +#[case::xfbcefc9af4db651aefd91bcabc8ea9fc( + include_bytes!("../../../test_data/rle/tile-fbcefc9af4db651aefd91bcabc8ea9fc-compressed.bin"), + include_bytes!("../../../test_data/rle/tile-fbcefc9af4db651aefd91bcabc8ea9fc-decompressed.bin"), +)] +fn decompress_bpp_16(#[case] src: &[u8], #[case] expected: &[u8]) { + let mut out = Vec::new(); + ironrdp_graphics::rle::decompress_16_bpp(src, &mut out, 64, 64).expect("decompress 16 bpp"); + assert_eq!(out, expected); +} diff --git a/crates/ironrdp-testsuite-core/tests/graphics/rlgr.rs b/crates/ironrdp-testsuite-core/tests/graphics/rlgr.rs index 507808aa30..9297ec2532 100644 --- a/crates/ironrdp-testsuite-core/tests/graphics/rlgr.rs +++ b/crates/ironrdp-testsuite-core/tests/graphics/rlgr.rs @@ -19,7 +19,7 @@ fn encode_works_with_rlgr3() { let output_len = expected.len(); let mut output = vec![0u8; output_len]; encode(mode, input, &mut output).unwrap(); - assert_eq!(&expected[..], &output[..]); + assert_eq!(*expected, &output); } } @@ -41,7 +41,7 @@ fn decode_works_with_rlgr3() { let mut output = vec![0i16; expected.len() * output_len]; for (i, (input, expected)) in input.iter().zip(expected.iter()).enumerate() { decode(mode, input, &mut output[i * output_len..(i + 1) * output_len]).unwrap(); - assert_eq!(&expected[..], &output[i * 4096..(i + 1) * 4096]); + assert_eq!(**expected, output[i * 4096..(i + 1) * 4096]); } } diff --git a/crates/ironrdp-testsuite-core/tests/main.rs b/crates/ironrdp-testsuite-core/tests/main.rs index 246feea82c..c389806e2b 100644 --- a/crates/ironrdp-testsuite-core/tests/main.rs +++ b/crates/ironrdp-testsuite-core/tests/main.rs @@ -1,5 +1,6 @@ #![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary #![allow(clippy::panic, reason = "panic is acceptable in tests")] +#![allow(clippy::unwrap_used, reason = "unwrap is fine in tests")] //! Integration Tests (IT) //! //! Integration tests are all contained in this single crate, and organized in modules. @@ -11,9 +12,13 @@ //! Cargo will run all tests from a single binary in parallel, but //! binaries themselves are run sequentially. +mod cfg; mod clipboard; +mod connector; mod displaycontrol; mod dvc; +mod echo; +mod egfx; mod fuzz_regression; mod graphics; mod input; @@ -21,7 +26,10 @@ mod pcb; mod pdu; mod propertyset; mod rdcleanpath; +mod rdpdr; +mod rdpeusb; mod rdpsnd; mod server; mod server_name; mod session; +mod str_types; diff --git a/crates/ironrdp-testsuite-core/tests/pdu/gcc.rs b/crates/ironrdp-testsuite-core/tests/pdu/gcc.rs index 718ccca947..a080939a6a 100644 --- a/crates/ironrdp-testsuite-core/tests/pdu/gcc.rs +++ b/crates/ironrdp-testsuite-core/tests/pdu/gcc.rs @@ -1,4 +1,4 @@ -use ironrdp_core::{decode, encode_vec, DecodeErrorKind, Encode as _, EncodeErrorKind, ReadCursor}; +use ironrdp_core::{DecodeErrorKind, Encode as _, EncodeErrorKind, ReadCursor, decode, encode_vec}; use ironrdp_pdu::gcc::*; use ironrdp_testsuite_core::cluster_data::*; use ironrdp_testsuite_core::conference_create::*; diff --git a/crates/ironrdp-testsuite-core/tests/pdu/gfx.rs b/crates/ironrdp-testsuite-core/tests/pdu/gfx.rs index 098d3c1d76..86f0eff298 100644 --- a/crates/ironrdp-testsuite-core/tests/pdu/gfx.rs +++ b/crates/ironrdp-testsuite-core/tests/pdu/gfx.rs @@ -1,4 +1,4 @@ -use ironrdp_core::{decode, decode_cursor, encode_vec, Encode as _, ReadCursor}; +use ironrdp_core::{Encode as _, ReadCursor, decode, decode_cursor, encode_vec}; use ironrdp_testsuite_core::gfx::*; use ironrdp_testsuite_core::graphics_messages::*; diff --git a/crates/ironrdp-testsuite-core/tests/pdu/input.rs b/crates/ironrdp-testsuite-core/tests/pdu/input.rs index 6bc1b54d9c..f316471b41 100644 --- a/crates/ironrdp-testsuite-core/tests/pdu/input.rs +++ b/crates/ironrdp-testsuite-core/tests/pdu/input.rs @@ -1,7 +1,9 @@ -use ironrdp_core::{decode_cursor, encode_vec, ReadCursor}; +use std::sync::LazyLock; + +use ironrdp_core::{ReadCursor, decode_cursor, encode_vec}; +use ironrdp_pdu::input::MousePdu; use ironrdp_pdu::input::fast_path::{FastPathInput, FastPathInputEvent}; use ironrdp_pdu::input::mouse::PointerFlags; -use ironrdp_pdu::input::MousePdu; const FASTPATH_INPUT_MESSAGE: [u8; 44] = [ 0x18, 0x2c, 0x20, 0x0, 0x90, 0x1a, 0x0, 0x26, 0x4, 0x20, 0x0, 0x8, 0x1b, 0x0, 0x26, 0x4, 0x20, 0x0, 0x10, 0x1b, @@ -9,46 +11,47 @@ const FASTPATH_INPUT_MESSAGE: [u8; 44] = [ 0x0, 0x28, 0x4, ]; -lazy_static::lazy_static! { - pub static ref FASTPATH_INPUT: FastPathInput = FastPathInput(vec![ +static FASTPATH_INPUT: LazyLock = LazyLock::new(|| { + FastPathInput::new(vec![ FastPathInputEvent::MouseEvent(MousePdu { flags: PointerFlags::DOWN | PointerFlags::LEFT_BUTTON, number_of_wheel_rotation_units: 0, x_position: 26, - y_position: 1062 + y_position: 1062, }), FastPathInputEvent::MouseEvent(MousePdu { flags: PointerFlags::MOVE, number_of_wheel_rotation_units: 0, x_position: 27, - y_position: 1062 + y_position: 1062, }), FastPathInputEvent::MouseEvent(MousePdu { flags: PointerFlags::LEFT_BUTTON, number_of_wheel_rotation_units: 0, x_position: 27, - y_position: 1062 + y_position: 1062, }), FastPathInputEvent::MouseEvent(MousePdu { flags: PointerFlags::MOVE, number_of_wheel_rotation_units: 0, x_position: 26, - y_position: 1063 + y_position: 1063, }), FastPathInputEvent::MouseEvent(MousePdu { flags: PointerFlags::MOVE, number_of_wheel_rotation_units: 0, x_position: 25, - y_position: 1063 + y_position: 1063, }), FastPathInputEvent::MouseEvent(MousePdu { flags: PointerFlags::MOVE, number_of_wheel_rotation_units: 0, x_position: 25, - y_position: 1064 - }) - ]); -} + y_position: 1064, + }), + ]) + .expect("can't panic") +}); #[test] fn from_buffer_correctly_parses_fastpath_input_message() { diff --git a/crates/ironrdp-testsuite-core/tests/pdu/mcs.rs b/crates/ironrdp-testsuite-core/tests/pdu/mcs.rs index fc2a3c370e..c3ae4914b4 100644 --- a/crates/ironrdp-testsuite-core/tests/pdu/mcs.rs +++ b/crates/ironrdp-testsuite-core/tests/pdu/mcs.rs @@ -1,5 +1,5 @@ use expect_test::expect; -use ironrdp_core::{decode, encode_vec, Encode as _}; +use ironrdp_core::{Encode as _, decode, encode_vec}; use ironrdp_pdu::mcs::*; use ironrdp_testsuite_core::mcs::*; use ironrdp_testsuite_core::mcs_encode_decode_test; diff --git a/crates/ironrdp-testsuite-core/tests/pdu/mod.rs b/crates/ironrdp-testsuite-core/tests/pdu/mod.rs index 7f0610d2e7..6626264b0b 100644 --- a/crates/ironrdp-testsuite-core/tests/pdu/mod.rs +++ b/crates/ironrdp-testsuite-core/tests/pdu/mod.rs @@ -9,4 +9,5 @@ mod mcs; mod pointer; mod rdp; mod rfx; +mod slow_path; mod x224; diff --git a/crates/ironrdp-testsuite-core/tests/pdu/pointer.rs b/crates/ironrdp-testsuite-core/tests/pdu/pointer/mod.rs similarity index 94% rename from crates/ironrdp-testsuite-core/tests/pdu/pointer.rs rename to crates/ironrdp-testsuite-core/tests/pdu/pointer/mod.rs index 68728b1595..5c0d02165e 100644 --- a/crates/ironrdp-testsuite-core/tests/pdu/pointer.rs +++ b/crates/ironrdp-testsuite-core/tests/pdu/pointer/mod.rs @@ -1,3 +1,5 @@ +use std::io::Cursor; + use expect_test::expect; use ironrdp_graphics::pointer::{DecodedPointer, PointerBitmapTarget}; use ironrdp_pdu::pointer::{ @@ -27,8 +29,8 @@ fn expect_pointer_png(pointer: &DecodedPointer, expected_file_path: &str) { } let png_buffer = std::fs::read(path).unwrap(); - let mut png_reader = png::Decoder::new(&png_buffer[..]).read_info().unwrap(); - let mut png_reader_buffer = vec![0u8; png_reader.output_buffer_size()]; + let mut png_reader = png::Decoder::new(Cursor::new(&png_buffer)).read_info().unwrap(); + let mut png_reader_buffer = vec![0u8; png_reader.output_buffer_size().unwrap()]; let frame_size = png_reader.next_frame(&mut png_reader_buffer).unwrap().buffer_size(); let expected = &png_reader_buffer[..frame_size]; assert_eq!(expected, &pointer.bitmap_data); @@ -36,7 +38,7 @@ fn expect_pointer_png(pointer: &DecodedPointer, expected_file_path: &str) { #[test] fn new_pointer_32bpp() { - let data = include_bytes!("../../test_data/pdu/pointer/new_pointer_32bpp.bin"); + let data = include_bytes!("../../../test_data/pdu/pointer/new_pointer_32bpp.bin"); let mut parsed = ironrdp_core::decode::>(data).unwrap(); let decoded = DecodedPointer::decode_pointer_attribute(&parsed, PointerBitmapTarget::Software).unwrap(); expect_pointer_png(&decoded, "pdu/pointer/new_pointer_32bpp.png"); @@ -67,7 +69,7 @@ fn new_pointer_32bpp() { #[test] fn large_pointer_32bpp() { - let data = include_bytes!("../../test_data/pdu/pointer/large_pointer_32bpp.bin"); + let data = include_bytes!("../../../test_data/pdu/pointer/large_pointer_32bpp.bin"); let mut parsed = ironrdp_core::decode::>(data).unwrap(); let decoded = DecodedPointer::decode_large_pointer_attribute(&parsed, PointerBitmapTarget::Software).unwrap(); expect_pointer_png(&decoded, "pdu/pointer/large_pointer_32bpp.png"); @@ -96,7 +98,7 @@ fn large_pointer_32bpp() { #[test] fn color_pointer_24bpp() { - let data = include_bytes!("../../test_data/pdu/pointer/color_pointer_24bpp.bin"); + let data = include_bytes!("../../../test_data/pdu/pointer/color_pointer_24bpp.bin"); let mut parsed = ironrdp_core::decode::>(data).unwrap(); let decoded = DecodedPointer::decode_color_pointer_attribute(&parsed, PointerBitmapTarget::Software).unwrap(); expect_pointer_png(&decoded, "pdu/pointer/color_pointer_24bpp.png"); diff --git a/crates/ironrdp-testsuite-core/tests/pdu/rdp.rs b/crates/ironrdp-testsuite-core/tests/pdu/rdp.rs index f9fc45df17..e255765d81 100644 --- a/crates/ironrdp-testsuite-core/tests/pdu/rdp.rs +++ b/crates/ironrdp-testsuite-core/tests/pdu/rdp.rs @@ -1,4 +1,4 @@ -use ironrdp_core::{decode, encode_vec, Encode as _}; +use ironrdp_core::{Encode as _, decode, encode_vec}; use ironrdp_testsuite_core::capsets::*; use ironrdp_testsuite_core::client_info::*; use ironrdp_testsuite_core::rdp::*; @@ -447,3 +447,22 @@ fn buffer_length_is_correct_for_client_demand_active() { assert_eq!(expected_buffer_len, len); } + +/// Regression for issue #1292: decoding a `BitmapCacheV3` capability set then re-encoding it +/// must not reach `unreachable!()` in the `Encode` impl. The decoder accepts +/// `CapabilitySetType::BitmapCacheV3CodecID` (0x06) and stores the body in +/// `CapabilitySet::BitmapCacheV3(Vec)`; before the fix the inner `match` of the encoder's +/// catch-all arm did not cover that variant. +#[test] +fn bitmap_cache_v3_round_trip_does_not_panic() { + use ironrdp_pdu::rdp::capability_sets::CapabilitySet; + + // 4-byte capability-set header with type=BitmapCacheV3CodecID(0x06) and length=4 (header only) + let input: [u8; 4] = [0x06, 0x00, 0x04, 0x00]; + + let decoded: CapabilitySet = decode(&input).expect("decode BitmapCacheV3 capability set"); + assert!(matches!(decoded, CapabilitySet::BitmapCacheV3(_))); + + let encoded = encode_vec(&decoded).expect("re-encode must not panic"); + assert_eq!(encoded, input, "round-trip must reproduce the original bytes"); +} diff --git a/crates/ironrdp-testsuite-core/tests/pdu/rfx.rs b/crates/ironrdp-testsuite-core/tests/pdu/rfx.rs index 9a8f968bbc..c62219339f 100644 --- a/crates/ironrdp-testsuite-core/tests/pdu/rfx.rs +++ b/crates/ironrdp-testsuite-core/tests/pdu/rfx.rs @@ -1,3 +1,5 @@ +use std::sync::LazyLock; + use ironrdp_pdu::codecs::rfx::*; use ironrdp_pdu::decode; use ironrdp_testsuite_core::encode_decode_test; @@ -252,12 +254,14 @@ const FRAME_BEGIN_PDU: Block<'_> = Block::CodecChannel(CodecChannel::FrameBegin( const FRAME_END_PDU: Block<'_> = Block::CodecChannel(CodecChannel::FrameEnd(FrameEndPdu)); -lazy_static::lazy_static! { - static ref CHANNELS_PDU: Block<'static> = Block::Channels(ChannelsPdu(vec![ +static CHANNELS_PDU: LazyLock> = LazyLock::new(|| { + Block::Channels(ChannelsPdu(vec![ RfxChannel { width: 64, height: 64 }, - RfxChannel { width: 32, height: 32 } - ])); - static ref REGION_PDU: Block<'static> = Block::CodecChannel(CodecChannel::Region(RegionPdu { + RfxChannel { width: 32, height: 32 }, + ])) +}); +static REGION_PDU: LazyLock> = LazyLock::new(|| { + Block::CodecChannel(CodecChannel::Region(RegionPdu { rectangles: vec![ RfxRectangle { x: 0, @@ -271,9 +275,11 @@ lazy_static::lazy_static! { width: 0xff, height: 0xff, }, - ] - })); - static ref TILESET_PDU: Block<'static> = Block::CodecChannel(CodecChannel::TileSet(TileSetPdu { + ], + })) +}); +static TILESET_PDU: LazyLock> = LazyLock::new(|| { + Block::CodecChannel(CodecChannel::TileSet(TileSetPdu { entropy_algorithm: EntropyAlgorithm::Rlgr3, quants: vec![ Quant { @@ -316,8 +322,8 @@ lazy_static::lazy_static! { cr_data: &TILE2_CR_DATA, }, ], - })); -} + })) +}); #[test] fn from_buffer_for_block_header_returns_error_on_zero_data_length() { diff --git a/crates/ironrdp-testsuite-core/tests/pdu/slow_path.rs b/crates/ironrdp-testsuite-core/tests/pdu/slow_path.rs new file mode 100644 index 0000000000..c578697e7d --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/pdu/slow_path.rs @@ -0,0 +1,144 @@ +use ironrdp_core::ReadCursor; +use ironrdp_pdu::slow_path::{self, GraphicsUpdateType}; + +// --- GraphicsUpdateType parsing --- + +#[test] +fn read_graphics_update_type_orders() { + let buf = 0x0000u16.to_le_bytes(); + let mut cursor = ReadCursor::new(&buf); + assert_eq!( + slow_path::read_graphics_update_type(&mut cursor).unwrap(), + GraphicsUpdateType::Orders, + ); +} + +#[test] +fn read_graphics_update_type_bitmap() { + let buf = 0x0001u16.to_le_bytes(); + let mut cursor = ReadCursor::new(&buf); + assert_eq!( + slow_path::read_graphics_update_type(&mut cursor).unwrap(), + GraphicsUpdateType::Bitmap, + ); +} + +#[test] +fn read_graphics_update_type_palette() { + let buf = 0x0002u16.to_le_bytes(); + let mut cursor = ReadCursor::new(&buf); + assert_eq!( + slow_path::read_graphics_update_type(&mut cursor).unwrap(), + GraphicsUpdateType::Palette, + ); +} + +#[test] +fn read_graphics_update_type_synchronize() { + let buf = 0x0003u16.to_le_bytes(); + let mut cursor = ReadCursor::new(&buf); + assert_eq!( + slow_path::read_graphics_update_type(&mut cursor).unwrap(), + GraphicsUpdateType::Synchronize, + ); +} + +#[test] +fn read_graphics_update_type_unknown_value_errors() { + let buf = 0x00FFu16.to_le_bytes(); + let mut cursor = ReadCursor::new(&buf); + assert!(slow_path::read_graphics_update_type(&mut cursor).is_err()); +} + +#[test] +fn read_graphics_update_type_short_buffer_errors() { + let buf = [0x01]; // only 1 byte, need 2 + let mut cursor = ReadCursor::new(&buf); + assert!(slow_path::read_graphics_update_type(&mut cursor).is_err()); +} + +// --- Pointer messageType parsing --- + +#[test] +fn decode_pointer_system_hidden() { + // messageType(u16) + pad(u16) + systemPointerType(u32) + let buf: [u8; 8] = [ + 0x01, 0x00, // messageType = System (0x0001) + 0x00, 0x00, // pad + 0x00, 0x00, 0x00, 0x00, // SYSPTR_NULL + ]; + let mut cursor = ReadCursor::new(&buf); + let result = slow_path::decode_slow_path_pointer(&mut cursor).unwrap(); + assert!(matches!(result, ironrdp_pdu::pointer::PointerUpdateData::SetHidden)); +} + +#[test] +fn decode_pointer_system_default() { + let buf: [u8; 8] = [ + 0x01, 0x00, // messageType = System (0x0001) + 0x00, 0x00, // pad + 0x00, 0x7F, 0x00, 0x00, // SYSPTR_DEFAULT + ]; + let mut cursor = ReadCursor::new(&buf); + let result = slow_path::decode_slow_path_pointer(&mut cursor).unwrap(); + assert!(matches!(result, ironrdp_pdu::pointer::PointerUpdateData::SetDefault)); +} + +#[test] +fn decode_pointer_position() { + let buf: [u8; 8] = [ + 0x03, 0x00, // messageType = Position (0x0003) + 0x00, 0x00, // pad + 0x40, 0x00, // x = 64 + 0x80, 0x00, // y = 128 + ]; + let mut cursor = ReadCursor::new(&buf); + let result = slow_path::decode_slow_path_pointer(&mut cursor).unwrap(); + match result { + ironrdp_pdu::pointer::PointerUpdateData::SetPosition(pos) => { + assert_eq!(pos.x, 64); + assert_eq!(pos.y, 128); + } + other => panic!("Expected SetPosition, got: {other:?}"), + } +} + +#[test] +fn decode_pointer_cached() { + let buf: [u8; 6] = [ + 0x07, 0x00, // messageType = Cached (0x0007) + 0x00, 0x00, // pad + 0x05, 0x00, // cacheIndex = 5 + ]; + let mut cursor = ReadCursor::new(&buf); + let result = slow_path::decode_slow_path_pointer(&mut cursor).unwrap(); + assert!(matches!(result, ironrdp_pdu::pointer::PointerUpdateData::Cached(_))); +} + +#[test] +fn decode_pointer_unknown_message_type_errors() { + let buf: [u8; 4] = [ + 0xFF, 0x00, // unknown messageType + 0x00, 0x00, // pad + ]; + let mut cursor = ReadCursor::new(&buf); + assert!(slow_path::decode_slow_path_pointer(&mut cursor).is_err()); +} + +#[test] +fn decode_pointer_short_buffer_errors() { + let buf: [u8; 2] = [0x01, 0x00]; // only messageType, no pad + let mut cursor = ReadCursor::new(&buf); + assert!(slow_path::decode_slow_path_pointer(&mut cursor).is_err()); +} + +#[test] +fn decode_pointer_system_unknown_type_errors() { + let buf: [u8; 8] = [ + 0x01, 0x00, // messageType = System + 0x00, 0x00, // pad + 0xFF, 0xFF, 0x00, 0x00, // unknown systemPointerType + ]; + let mut cursor = ReadCursor::new(&buf); + assert!(slow_path::decode_slow_path_pointer(&mut cursor).is_err()); +} diff --git a/crates/ironrdp-testsuite-core/tests/pdu/x224.rs b/crates/ironrdp-testsuite-core/tests/pdu/x224.rs index fcbde2c485..dfb85dcf2c 100644 --- a/crates/ironrdp-testsuite-core/tests/pdu/x224.rs +++ b/crates/ironrdp-testsuite-core/tests/pdu/x224.rs @@ -6,7 +6,7 @@ use ironrdp_pdu::nego::{ }; use ironrdp_pdu::tpdu::{TpduCode, TpduHeader}; use ironrdp_pdu::tpkt::TpktHeader; -use ironrdp_pdu::x224::{user_data_size, X224}; +use ironrdp_pdu::x224::{X224, user_data_size}; use ironrdp_testsuite_core::encode_decode_test; const SAMPLE_TPKT_HEADER_BINARY: [u8; 4] = [ @@ -170,7 +170,7 @@ encode_decode_test! { nego_confirm_response: X224(ConnectionConfirm::Response { - flags: ResponseFlags::from_bits_truncate(0x1F), + flags: ResponseFlags::from_bits_retain(0x1F), protocol: SecurityProtocol::HYBRID, }), [ diff --git a/crates/ironrdp-testsuite-core/tests/propertyset.rs b/crates/ironrdp-testsuite-core/tests/propertyset.rs index bbd4b7987d..f963511bb0 100644 --- a/crates/ironrdp-testsuite-core/tests/propertyset.rs +++ b/crates/ironrdp-testsuite-core/tests/propertyset.rs @@ -57,16 +57,15 @@ fn parse_file() { expect![[r#" [ Error { - kind: MalformedLine { - line: "MalformedLine:s", - }, - line: 9, + kind: MalformedLine, + line: 10, }, Error { kind: UnknownType { + key: "UnknownType", ty: "z", }, - line: 10, + line: 11, }, ] "#]] diff --git a/crates/ironrdp-testsuite-core/tests/rdcleanpath.rs b/crates/ironrdp-testsuite-core/tests/rdcleanpath.rs index 4c91f3b346..d51aff955f 100644 --- a/crates/ironrdp-testsuite-core/tests/rdcleanpath.rs +++ b/crates/ironrdp-testsuite-core/tests/rdcleanpath.rs @@ -1,4 +1,7 @@ -use ironrdp_rdcleanpath::{DetectionResult, RDCleanPathPdu, VERSION_1}; +use expect_test::{Expect, expect}; +use ironrdp_rdcleanpath::{ + DetectionResult, GENERAL_ERROR_CODE, NEGOTIATION_ERROR_CODE, RDCleanPathErr, RDCleanPathPdu, VERSION_1, +}; use rstest::rstest; fn request() -> RDCleanPathPdu { @@ -123,3 +126,62 @@ fn detect_not_enough(#[case] payload: &[u8]) { let result = RDCleanPathPdu::detect(payload); assert_eq!(result, DetectionResult::NotEnoughBytes); } + +#[rstest] +#[case::http( + RDCleanPathErr { + error_code: GENERAL_ERROR_CODE, + http_status_code: Some(404), + wsa_last_error: None, + tls_alert_code: None, + }, + expect!["general error (code 1); HTTP 404 not found"], +)] +#[case::wsa( + RDCleanPathErr { + error_code: GENERAL_ERROR_CODE, + http_status_code: None, + wsa_last_error: Some(10061), + tls_alert_code: None, + }, + expect!["general error (code 1); WSA 10061 connection refused"], +)] +#[case::tls( + RDCleanPathErr { + error_code: GENERAL_ERROR_CODE, + http_status_code: None, + wsa_last_error: None, + tls_alert_code: Some(40), + }, + expect!["general error (code 1); TLS alert 40 handshake failure"], +)] +#[case::nego( + RDCleanPathErr { + error_code: NEGOTIATION_ERROR_CODE, + http_status_code: None, + wsa_last_error: None, + tls_alert_code: None, + }, + expect!["negotiation error (code 2)"], +)] +#[case::combined( + RDCleanPathErr { + error_code: GENERAL_ERROR_CODE, + http_status_code: Some(502), + wsa_last_error: Some(10060), + tls_alert_code: Some(45), + }, + expect!["general error (code 1); HTTP 502 bad gateway; WSA 10060 connection timed out; TLS alert 45 certificate expired"], +)] +#[case::unknown_codes( + RDCleanPathErr { + error_code: 99, + http_status_code: Some(999), + wsa_last_error: Some(65000), + tls_alert_code: Some(255), + }, + expect!["unknown error (code 99); HTTP 999 unknown HTTP status; WSA 65000 unknown WSA error; TLS alert 255 unknown TLS alert"], +)] +fn error_display(#[case] error: RDCleanPathErr, #[case] expected: Expect) { + expected.assert_eq(&error.to_string()); +} diff --git a/crates/ironrdp-testsuite-core/tests/rdpdr/mod.rs b/crates/ironrdp-testsuite-core/tests/rdpdr/mod.rs new file mode 100644 index 0000000000..aba17e3cfe --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpdr/mod.rs @@ -0,0 +1,334 @@ +use ironrdp_core::encode_vec; +use ironrdp_rdpdr::pdu::RdpdrPdu; +use ironrdp_rdpdr::pdu::efs::{ + Capabilities, CapabilityMessage, ClientDeviceListAnnounce, CoreCapability, CoreCapabilityKind, + DEFAULT_PRINTER_DRIVER_NAME, DeviceAnnounceHeader, DeviceIoRequest, DeviceType, Devices, MajorFunction, + MinorFunction, NtStatus, PRINTER_CAPABILITY_VERSION_01, RDPDR_PRINTER_ANNOUNCE_FLAG_DEFAULTPRINTER, + RDPDR_PRINTER_ANNOUNCE_FLAG_NETWORKPRINTER, VERSION_MINOR_RDP51, VersionAndIdPdu, VersionAndIdPduKind, +}; +use ironrdp_rdpdr::{NoopRdpdrBackend, Rdpdr}; +use ironrdp_svc::{SvcMessage, SvcProcessor as _}; + +fn read_u16(bytes: &[u8]) -> u16 { + u16::from_le_bytes(bytes[..2].try_into().unwrap()) +} + +fn read_u32(bytes: &[u8]) -> u32 { + u32::from_le_bytes(bytes[..4].try_into().unwrap()) +} + +fn read_u32_as_usize(bytes: &[u8]) -> usize { + usize::try_from(read_u32(bytes)).expect("u32 fits in usize on supported targets") +} + +fn ntstatus_at(bytes: &[u8], offset: usize) -> NtStatus { + NtStatus::from(read_u32(&bytes[offset..])) +} + +fn utf16le_to_string(bytes: &[u8]) -> String { + assert_eq!(bytes.len() % 2, 0, "UTF-16LE buffers must be even length"); + let units: Vec = bytes + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .take_while(|&u| u != 0) + .collect(); + String::from_utf16(&units).expect("round-trip UTF-16LE decode") +} + +fn encoded_printer_announce(device: DeviceAnnounceHeader) -> Vec { + encode_vec(&RdpdrPdu::ClientDeviceListAnnounce(ClientDeviceListAnnounce { + device_list: vec![device], + })) + .unwrap() +} + +fn encoded_server_client_id_confirm() -> Vec { + encode_vec(&RdpdrPdu::VersionAndIdPdu(VersionAndIdPdu { + version_major: 1, + version_minor: 12, + client_id: 0x1234, + kind: VersionAndIdPduKind::ServerClientIdConfirm, + })) + .unwrap() +} + +fn encoded_server_client_id_confirm_with_minor(version_minor: u16) -> Vec { + encode_vec(&RdpdrPdu::VersionAndIdPdu(VersionAndIdPdu { + version_major: 1, + version_minor, + client_id: 0x1234, + kind: VersionAndIdPduKind::ServerClientIdConfirm, + })) + .unwrap() +} + +fn encoded_printer_device_io_request(major_function: MajorFunction) -> Vec { + encode_vec(&RdpdrPdu::DeviceIoRequest(DeviceIoRequest { + device_id: 42, + file_id: 1, + completion_id: 0x100, + major_function, + minor_function: MinorFunction::from(0), + })) + .unwrap() +} + +fn encoded_printer_device_control_request() -> Vec { + let mut encoded = encoded_printer_device_io_request(MajorFunction::DeviceControl); + encoded.extend_from_slice(&0u32.to_le_bytes()); // OutputBufferLength + encoded.extend_from_slice(&0u32.to_le_bytes()); // InputBufferLength + encoded.extend_from_slice(&0u32.to_le_bytes()); // IoControlCode + encoded.extend_from_slice(&[0; 20]); // Padding + encoded +} + +fn announced_devices(message: &SvcMessage) -> Vec<(u32, DeviceType)> { + let encoded = message.encode_unframed_pdu().unwrap(); + assert_eq!(&encoded[..4], &[0x72, 0x44, 0x41, 0x44]); // RDPDR + DEVICELIST_ANNOUNCE + + let mut offset = 4; + let device_count = read_u32_as_usize(&encoded[offset..]); + offset += 4; + + let mut devices = Vec::with_capacity(device_count); + for _ in 0..device_count { + let device_type = DeviceType::try_from(read_u32(&encoded[offset..])).unwrap(); + offset += 4; + + let device_id = read_u32(&encoded[offset..]); + offset += 4; + + offset += 8; // PreferredDosName + + let device_data_length = read_u32_as_usize(&encoded[offset..]); + offset += 4 + device_data_length; + + devices.push((device_id, device_type)); + } + + assert_eq!(offset, encoded.len()); + devices +} + +fn printer_device_data(encoded: &[u8]) -> &[u8] { + assert_eq!(&encoded[..4], &[0x72, 0x44, 0x41, 0x44]); // RDPDR + DEVICELIST_ANNOUNCE + + let mut offset = 4; + assert_eq!(read_u32(&encoded[offset..]), 1); + offset += 4; + + assert_eq!(read_u32(&encoded[offset..]), u32::from(DeviceType::Print)); + offset += 4; + + assert_eq!(read_u32(&encoded[offset..]), 42); + offset += 4; + + assert_eq!(&encoded[offset..offset + 8], b"PRN1\0\0\0\0"); + offset += 8; + + let device_data_length = read_u32_as_usize(&encoded[offset..]); + offset += 4; + + let body = &encoded[offset..offset + device_data_length]; + assert_eq!(offset + device_data_length, encoded.len()); + body +} + +#[test] +fn printer_capability_wire_layout() { + let mut caps = Capabilities::new(); + caps.add_printer(); + + let pdu = RdpdrPdu::CoreCapability(CoreCapability::new_response(caps.clone_inner())); + let encoded = encode_vec(&pdu).unwrap(); + + assert_eq!(&encoded[..4], &[0x72, 0x44, 0x50, 0x43]); // RDPDR + CLIENT_CAPABILITY + assert_eq!(read_u16(&encoded[4..]), 2); + + let general_cap_offset = 8; + assert_eq!(read_u16(&encoded[general_cap_offset..]), 0x0001); + let general_cap_length = usize::from(read_u16(&encoded[general_cap_offset + 2..])); + assert_eq!(general_cap_length, 44); + assert_eq!(read_u32(&encoded[general_cap_offset + 4..]), 0x0000_0002); + assert_eq!(read_u32(&encoded[general_cap_offset + general_cap_length - 4..]), 0); + + let printer_cap_offset = general_cap_offset + general_cap_length; + assert_eq!(read_u16(&encoded[printer_cap_offset..]), 0x0002); + assert_eq!(read_u16(&encoded[printer_cap_offset + 2..]), 8); + assert_eq!( + read_u32(&encoded[printer_cap_offset + 4..]), + PRINTER_CAPABILITY_VERSION_01 + ); + assert_eq!(printer_cap_offset + 8, encoded.len()); +} + +#[test] +fn printer_announce_body_layout_matches_freerdp_postscript_defaults() { + let encoded = encoded_printer_announce(DeviceAnnounceHeader::new_printer(42, "PrintMe".to_owned())); + let body = printer_device_data(&encoded); + + assert!(body.len() >= 24); + + let flags = read_u32(&body[0..]); + let code_page = read_u32(&body[4..]); + let pnp_name_len = read_u32_as_usize(&body[8..]); + let driver_name_len = read_u32_as_usize(&body[12..]); + let print_name_len = read_u32_as_usize(&body[16..]); + let cached_fields_len = read_u32_as_usize(&body[20..]); + + assert_eq!( + flags, + RDPDR_PRINTER_ANNOUNCE_FLAG_DEFAULTPRINTER | RDPDR_PRINTER_ANNOUNCE_FLAG_NETWORKPRINTER + ); + assert_eq!(code_page, 0); + assert_eq!(pnp_name_len, 0); + assert_eq!(cached_fields_len, 0); + + let mut offset = 24; + let pnp_bytes = &body[offset..offset + pnp_name_len]; + offset += pnp_name_len; + let driver_bytes = &body[offset..offset + driver_name_len]; + offset += driver_name_len; + let print_bytes = &body[offset..offset + print_name_len]; + offset += print_name_len; + + assert_eq!(offset, body.len()); + assert_eq!(utf16le_to_string(pnp_bytes), ""); + assert_eq!(utf16le_to_string(driver_bytes), DEFAULT_PRINTER_DRIVER_NAME); + assert_eq!(utf16le_to_string(print_bytes), "PrintMe"); +} + +#[test] +fn printer_capability_is_not_echoed_when_server_does_not_advertise_it() { + let mut rdpdr = Rdpdr::new(Box::new(NoopRdpdrBackend), "IronRDP".to_owned()).with_printer(42, "PrintMe".to_owned()); + let server_capability = RdpdrPdu::CoreCapability(CoreCapability { + capabilities: vec![CapabilityMessage::new_general(0)], + kind: CoreCapabilityKind::ServerCoreCapabilityRequest, + }); + + let responses = rdpdr.process(&encode_vec(&server_capability).unwrap()).unwrap(); + assert_eq!(responses.len(), 1); + + let encoded = responses[0].encode_unframed_pdu().unwrap(); + assert_eq!(&encoded[..4], &[0x72, 0x44, 0x50, 0x43]); // RDPDR + CLIENT_CAPABILITY + assert_eq!(read_u16(&encoded[4..]), 1); +} + +#[test] +fn printer_announce_respects_explicit_driver() { + let encoded = encoded_printer_announce(DeviceAnnounceHeader::new_printer_with_driver( + 42, + "PDF Printer".to_owned(), + "Microsoft Print To PDF".to_owned(), + )); + let body = printer_device_data(&encoded); + + let pnp_name_len = read_u32_as_usize(&body[8..]); + let driver_name_len = read_u32_as_usize(&body[12..]); + let driver_bytes = &body[24 + pnp_name_len..24 + pnp_name_len + driver_name_len]; + + assert_eq!(utf16le_to_string(driver_bytes), "Microsoft Print To PDF"); +} + +#[test] +fn devices_add_printer_appends_printer_entry() { + let mut devices = Devices::new(); + devices.add_printer(9, "Lobby Printer".to_owned()); + + assert_eq!(devices.for_device_type(9).unwrap(), DeviceType::Print); +} + +#[test] +fn printer_device_announce_is_deferred_until_user_loggedon() { + let mut rdpdr = Rdpdr::new(Box::new(NoopRdpdrBackend), "IronRDP".to_owned()).with_printer(42, "PrintMe".to_owned()); + + assert!(rdpdr.process(&encoded_server_client_id_confirm()).unwrap().is_empty()); + + let responses = rdpdr.process(&encode_vec(&RdpdrPdu::UserLoggedon).unwrap()).unwrap(); + assert_eq!(responses.len(), 1); + + assert_eq!(announced_devices(&responses[0]), vec![(42, DeviceType::Print)]); + + assert!( + rdpdr + .process(&encode_vec(&RdpdrPdu::UserLoggedon).unwrap()) + .unwrap() + .is_empty() + ); +} + +#[test] +fn smartcard_device_announce_remains_pre_logon() { + let mut rdpdr = Rdpdr::new(Box::new(NoopRdpdrBackend), "IronRDP".to_owned()) + .with_smartcard(1) + .with_printer(42, "PrintMe".to_owned()); + + let responses = rdpdr.process(&encoded_server_client_id_confirm()).unwrap(); + assert_eq!(responses.len(), 1); + + assert_eq!(announced_devices(&responses[0]), vec![(1, DeviceType::Smartcard)]); + + let responses = rdpdr.process(&encode_vec(&RdpdrPdu::UserLoggedon).unwrap()).unwrap(); + assert_eq!(responses.len(), 1); + + assert_eq!(announced_devices(&responses[0]), vec![(42, DeviceType::Print)]); +} + +#[test] +fn rdp51_client_id_confirm_announces_all_devices_pre_logon() { + let mut rdpdr = Rdpdr::new(Box::new(NoopRdpdrBackend), "IronRDP".to_owned()) + .with_smartcard(1) + .with_printer(42, "PrintMe".to_owned()); + + let responses = rdpdr + .process(&encoded_server_client_id_confirm_with_minor(VERSION_MINOR_RDP51)) + .unwrap(); + assert_eq!(responses.len(), 1); + + assert_eq!( + announced_devices(&responses[0]), + vec![(1, DeviceType::Smartcard), (42, DeviceType::Print)] + ); + + assert!( + rdpdr + .process(&encode_vec(&RdpdrPdu::UserLoggedon).unwrap()) + .unwrap() + .is_empty() + ); +} + +#[test] +fn printer_device_control_is_completed_with_empty_success_response() { + let mut rdpdr = Rdpdr::new(Box::new(NoopRdpdrBackend), "IronRDP".to_owned()).with_printer(42, "PrintMe".to_owned()); + + let responses = rdpdr.process(&encoded_printer_device_control_request()).unwrap(); + assert_eq!(responses.len(), 1); + + let encoded = responses[0].encode_unframed_pdu().unwrap(); + assert_eq!(&encoded[..4], &[0x72, 0x44, 0x43, 0x49]); // RDPDR + DEVICE_IOCOMPLETION + assert_eq!(ntstatus_at(&encoded, 12), NtStatus::SUCCESS); + assert_eq!(read_u32(&encoded[16..]), 0); // OutputBufferLength +} + +#[test] +fn unsupported_printer_irp_is_completed_by_svc_processor() { + let mut rdpdr = Rdpdr::new(Box::new(NoopRdpdrBackend), "IronRDP".to_owned()).with_printer(42, "PrintMe".to_owned()); + + let responses = rdpdr + .process(&encoded_printer_device_io_request(MajorFunction::Read)) + .unwrap(); + assert_eq!(responses.len(), 1); + + let encoded = responses[0].encode_unframed_pdu().unwrap(); + assert_eq!(&encoded[..4], &[0x72, 0x44, 0x43, 0x49]); // RDPDR + DEVICE_IOCOMPLETION + assert_eq!(ntstatus_at(&encoded, 12), NtStatus::NOT_SUPPORTED); +} + +#[test] +fn printer_cache_pdu_is_ignored_before_decode() { + let mut rdpdr = Rdpdr::new(Box::new(NoopRdpdrBackend), "IronRDP".to_owned()).with_printer(42, "PrintMe".to_owned()); + let pdu = [0x72, 0x44, 0x43, 0x50, 1, 2, 3, 4]; // RDPDR + PAKID_PRN_CACHE_DATA + ignored body + + assert!(rdpdr.process(&pdu).unwrap().is_empty()); +} diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/client.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/client.rs new file mode 100644 index 0000000000..40294643bd --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/client.rs @@ -0,0 +1,324 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use ironrdp_core::{decode, encode_vec}; +use ironrdp_dvc::{DvcChannelListener as _, DvcMessage, DvcProcessor as _}; +use ironrdp_pdu::PduResult; +use ironrdp_rdpeusb::CHANNEL_NAME; +use ironrdp_rdpeusb::client::{ + DeviceManagerBackend, UrbdrcControlClient, UrbdrcDeviceBackend, UrbdrcDeviceClient, UrbdrcListener, +}; +use ironrdp_rdpeusb::io::*; +use ironrdp_rdpeusb::pdu::caps::{Capability, RimExchangeCapabilityRequest}; +use ironrdp_rdpeusb::pdu::header::InterfaceId; +use ironrdp_rdpeusb::pdu::iface_manipulation::InterfaceRelease; +use ironrdp_rdpeusb::pdu::notify::{ChannelCreated, Direction}; +use ironrdp_rdpeusb::pdu::sink::AddVirtualChannel; +use ironrdp_rdpeusb::pdu::{ + UrbdrcClientControlPdu, UrbdrcClientDevicePdu, UrbdrcServerControlPdu, UrbdrcServerDevicePdu, +}; + +use super::{encode_pdu, proxy_iface_id, simple_device_info}; + +fn decode_control_msg(message: &DvcMessage) -> UrbdrcClientControlPdu { + let encoded = encode_vec(message.as_ref()).expect("encode should succeed"); + decode(&encoded).expect("decode should succeed") +} + +fn decode_device_msg(message: &DvcMessage) -> UrbdrcClientDevicePdu { + let encoded = encode_vec(message.as_ref()).expect("encode should succeed"); + decode(&encoded).expect("decode should succeed") +} + +#[derive(Default)] +struct DeviceManagerState { + control_channel: Option, + device_channels: Vec, + pending_devices: VecDeque>, +} + +struct TestDeviceManager { + state: Arc>, +} + +impl TestDeviceManager { + fn new(state: Arc>) -> Self { + Self { state } + } +} + +impl DeviceManagerBackend for TestDeviceManager { + fn control_channel_assigned(&mut self, channel_id: u32) { + let mut state = self + .state + .lock() + .expect("device manager state lock should not be poisoned"); + assert!( + state.control_channel.replace(channel_id).is_none(), + "control channel should only be assigned once" + ); + } + + fn take_device_for_channel(&mut self, channel_id: u32) -> Option> { + let mut state = self + .state + .lock() + .expect("device manager state lock should not be poisoned"); + + state.pending_devices.pop_front().inspect(|_| { + state.device_channels.push(channel_id); + }) + } +} + +struct NoopDeviceClientBackend { + device_info: DeviceInfo, +} + +impl NoopDeviceClientBackend { + fn new(device_info: DeviceInfo) -> Self { + Self { device_info } + } +} + +impl UrbdrcDeviceBackend for NoopDeviceClientBackend { + fn device_info(&mut self, _channel_id: u32) -> PduResult { + Ok(self.device_info.clone()) + } + + fn cancel_request(&mut self, _request_id: RequestId, _channel_id: u32) {} + + fn query_device_text(&mut self, _channel_id: u32, _text_type: u32, _locale_id: u32) -> PduResult { + Ok(DeviceText { + hresult: 0, + description: String::new(), + }) + } + + fn io_control( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _request: IoControlPacket, + ) -> PduResult> { + Ok(None) + } + + fn internal_io_control( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _request: InternalIoControlPacket, + ) -> PduResult> { + Ok(None) + } + + fn transfer_in( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _request: TransferInPacket, + ) -> PduResult> { + Ok(None) + } + + fn transfer_out( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _request: TransferOutPacket, + ) -> PduResult> { + Ok(None) + } + + fn transfer_out_no_ack( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _request: TransferOutPacket, + ) -> PduResult<()> { + Ok(()) + } + + fn retract(&mut self, _channel_id: u32) -> PduResult<()> { + Ok(()) + } +} + +// Ref: [Channel Setup Sequence][1.3.1.1] +// [1.3.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/55bb34fc-7fd0-4aca-8739-5fb6759b66fc +#[test] +fn channel_setup_sequence() { + let manager_state = Arc::new(Mutex::new(DeviceManagerState::default())); + + { + let mut state = manager_state + .lock() + .expect("device manager state lock should not be poisoned"); + state + .pending_devices + .push_back(Box::new(NoopDeviceClientBackend::new(simple_device_info()))); + state + .pending_devices + .push_back(Box::new(NoopDeviceClientBackend::new(simple_device_info()))); + } + + let callback_manager_state = Arc::clone(&manager_state); + + // when channel is settled, send `ADD_VIRTUAL_CHANNEL` + let on_capability_exchanged = Box::new(move || { + let pending_device_count = callback_manager_state + .lock() + .expect("device manager state lock should not be poisoned") + .pending_devices + .len(); + let mut messages = Vec::with_capacity(pending_device_count); + for _ in 0..pending_device_count { + let message: DvcMessage = Box::new(AddVirtualChannel { msg_id: 0 }); + messages.push(message); + } + + Ok(messages) + }); + + let manager = TestDeviceManager::new(Arc::clone(&manager_state)); + let mut listener = UrbdrcListener::new(on_capability_exchanged, Box::new(manager)); + + assert_eq!(listener.channel_name(), CHANNEL_NAME); + + let mut control = listener + .create(10) + .expect("first URBDRC create should return control client"); + + let control = &mut control + .as_any_mut() + .downcast_mut::() + .expect("first processor should be a control client"); + + assert!(!control.ready()); + + let resp = control.start(10).expect("start should succeed"); + assert_eq!(resp.len(), 0); + + let resp = control + .process( + 10, + &encode_pdu(&UrbdrcServerControlPdu::Caps(RimExchangeCapabilityRequest { + msg_id: 7, + capability: Capability::RimCapabilityVersion01, + })), + ) + .expect("capability exchange should succeed"); + + assert_eq!(resp.len(), 1); + let UrbdrcClientControlPdu::Caps(response) = decode_control_msg(&resp[0]) else { + panic!("expected capability response"); + }; + assert_eq!(response.msg_id, 7); + assert_eq!(response.capability, Capability::RimCapabilityVersion01); + assert_eq!(response.result, 0); + + let resp = control + .process( + 10, + &encode_pdu(&UrbdrcServerControlPdu::ChanCreated(ChannelCreated { + msg_id: 8, + direction: Direction::ToClient, + })), + ) + .expect("channel-created notification should succeed"); + assert_eq!(resp.len(), 1); + let UrbdrcClientControlPdu::ChanCreated(response) = decode_control_msg(&resp[0]) else { + panic!("expected channel-created response"); + }; + assert_eq!(response.msg_id, 8); + assert_eq!(response.direction, Direction::ToServer); + + let resp = control + .process( + 10, + &encode_pdu(&UrbdrcServerControlPdu::IfaceRelease(InterfaceRelease { + iface_id: proxy_iface_id(InterfaceId::NOTIFY_CLIENT), + msg_id: 9, + })), + ) + .expect("notification release should succeed"); + + // on capability exchanged message + assert_eq!(resp.len(), 2); + assert!(control.ready()); + + for message in &resp { + assert!(matches!( + decode_control_msg(message), + UrbdrcClientControlPdu::AddChan(_) + )); + } + + let device = listener + .create(11) + .expect("second URBDRC create should return device client"); + assert!(device.as_any().downcast_ref::().is_some()); + + let device = listener + .create(12) + .expect("third URBDRC create should return device client"); + assert!(device.as_any().downcast_ref::().is_some()); + + assert!( + listener.create(13).is_none(), + "listener should reject extra URBDRC creates when no device backend is pending" + ); + + let state = manager_state + .lock() + .expect("device manager state lock should not be poisoned"); + assert_eq!(state.control_channel, Some(10)); + assert_eq!(state.device_channels, [11, 12]); + assert!(state.pending_devices.is_empty()); +} + +// Ref: [New Device Sequence][1.3.1.2] +// [1.3.1.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/7e3da218-9cdc-4ebd-bb76-e70202c7f264 +#[test] +fn new_device_sequence() { + let udev_iface = InterfaceId::try_from(4).expect("valid device interface id"); + let backend = Box::new(NoopDeviceClientBackend::new(simple_device_info())); + let mut client = UrbdrcDeviceClient::new(udev_iface, backend).expect("device client should be created"); + + assert!(!client.ready_for_io()); + + let resp = client + .process( + 99, + &encode_pdu(&UrbdrcServerDevicePdu::ChanCreated(ChannelCreated { + msg_id: 21, + direction: Direction::ToClient, + })), + ) + .expect("channel-created notification should succeed"); + assert_eq!(resp.len(), 1); + let UrbdrcClientDevicePdu::ChanCreated(response) = decode_device_msg(&resp[0]) else { + panic!("expected channel-created response"); + }; + assert_eq!(response.msg_id, 21); + assert_eq!(response.direction, Direction::ToServer); + assert!(!client.ready_for_io()); + + let resp = client + .process( + 99, + &encode_pdu(&UrbdrcServerDevicePdu::IfaceRelease(InterfaceRelease { + iface_id: proxy_iface_id(InterfaceId::NOTIFY_CLIENT), + msg_id: 22, + })), + ) + .expect("notification release should succeed"); + assert_eq!(resp.len(), 1); + assert!(client.ready_for_io()); + + let UrbdrcClientDevicePdu::AddDev(add_device) = decode_device_msg(&resp[0]) else { + panic!("expected add device"); + }; + assert_eq!(add_device.usb_device, udev_iface); +} diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/device.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/device.rs new file mode 100644 index 0000000000..01c8f5c940 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/device.rs @@ -0,0 +1,108 @@ +use ironrdp_core::encode_vec; +use ironrdp_rdpeusb::io::device::{ + DeviceInfo, UsbBcdVersion, UsbClassCodes, UsbConfigInfo, UsbConnectionSpeed, UsbDeviceDescriptorInfo, + UsbDeviceLocation, UsbInterfaceInfo, add_device_from_info, +}; +use ironrdp_rdpeusb::pdu::header::InterfaceId; +use rstest::rstest; + +use super::simple_device_info; + +fn composite_device_info() -> DeviceInfo { + DeviceInfo { + active_config: Some(UsbConfigInfo { + interfaces: vec![ + UsbInterfaceInfo { + class_codes: UsbClassCodes { + class_code: 0x03, + sub_class_code: 0x01, + protocol_code: 0x02, + }, + }, + UsbInterfaceInfo { + class_codes: UsbClassCodes { + class_code: 0xff, + sub_class_code: 0x00, + protocol_code: 0x00, + }, + }, + ], + }), + ..simple_device_info() + } +} + +fn iad_composite_device_info() -> DeviceInfo { + let mut info = simple_device_info(); + info.descriptor.class_codes = UsbClassCodes { + class_code: 0xef, + sub_class_code: 0x02, + protocol_code: 0x01, + }; + info +} + +fn no_active_config_device_info() -> DeviceInfo { + DeviceInfo { + active_config: None, + descriptor: UsbDeviceDescriptorInfo { + class_codes: UsbClassCodes { + class_code: 0x08, + sub_class_code: 0x06, + protocol_code: 0x50, + }, + ..simple_device_info().descriptor + }, + ..simple_device_info() + } +} + +fn no_port_numbers_device_info() -> DeviceInfo { + DeviceInfo { + location: UsbDeviceLocation { + bus_number: 7, + address: 2, + port_numbers: Vec::new(), + }, + ..simple_device_info() + } +} + +fn usb_version_device_info(usb_version: UsbBcdVersion) -> DeviceInfo { + DeviceInfo { + descriptor: UsbDeviceDescriptorInfo { + usb_version, + ..simple_device_info().descriptor + }, + ..simple_device_info() + } +} + +fn speed_device_info(speed: UsbConnectionSpeed) -> DeviceInfo { + DeviceInfo { + speed, + ..simple_device_info() + } +} + +#[rstest] +#[case::simple(simple_device_info())] +#[case::composite_multiple_interfaces(composite_device_info())] +#[case::composite_iad(iad_composite_device_info())] +#[case::no_active_config(no_active_config_device_info())] +#[case::no_port_numbers(no_port_numbers_device_info())] +#[case::usb10(usb_version_device_info(UsbBcdVersion::from_bcd(0x0100)))] +#[case::usb11(usb_version_device_info(UsbBcdVersion::from_bcd(0x0110)))] +#[case::usb20(usb_version_device_info(UsbBcdVersion::from_bcd(0x0200)))] +#[case::low_speed(speed_device_info(UsbConnectionSpeed::Low))] +#[case::full_speed(speed_device_info(UsbConnectionSpeed::Full))] +#[case::high_speed(speed_device_info(UsbConnectionSpeed::High))] +#[case::super_speed(speed_device_info(UsbConnectionSpeed::Super))] +#[case::unknown_speed(speed_device_info(UsbConnectionSpeed::Unknown))] +fn add_device_from_protocol_agnostic_device_info(#[case] info: DeviceInfo) { + let udev_iface = InterfaceId::try_from(4).expect("valid device interface id"); + let add_device = add_device_from_info(udev_iface, &info).expect("ADD_DEVICE should be generated"); + + assert_eq!(add_device.usb_device, udev_iface); + encode_vec(&add_device).expect("ADD_DEVICE should encode"); +} diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/io/mod.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/io/mod.rs new file mode 100644 index 0000000000..7453d84d6d --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/io/mod.rs @@ -0,0 +1,355 @@ +use std::sync::mpsc::{self, Receiver, Sender}; + +use ironrdp_core::encode_vec; +use ironrdp_dvc::{DvcMessage, DvcProcessor as _}; +use ironrdp_pdu::PduResult; +use ironrdp_rdpeusb::client::{UrbdrcDeviceBackend, UrbdrcDeviceClient}; +use ironrdp_rdpeusb::io::{ + DeviceAnnounce, DeviceText, InternalIoControlPacket, IoControlCompletionResult, IoControlPacket, RequestId, + TransferInCompletionResult, TransferInPacket, TransferOutCompletionResult, TransferOutPacket, +}; +use ironrdp_rdpeusb::pdu::header::InterfaceId; +use ironrdp_rdpeusb::server::{UrbdrcDeviceServer, UrbdrcDeviceServerBackend}; + +use super::simple_device_info; + +const CHANNEL_ID: u32 = 11; +const DEVICE_TEXT_DESCRIPTION: &str = "Test USB device"; +const DEVICE_TEXT_HRESULT: u32 = 0; + +#[derive(Debug)] +enum ClientEvent { + QueryDeviceText { + channel_id: u32, + text_type: u32, + locale_id: u32, + }, + IoControl { + channel_id: u32, + request_id: RequestId, + request: IoControlPacket, + }, + InternalIoControl { + channel_id: u32, + request_id: RequestId, + request: InternalIoControlPacket, + }, + TransferIn { + channel_id: u32, + request_id: RequestId, + request: TransferInPacket, + }, + TransferOut { + channel_id: u32, + request_id: RequestId, + request: TransferOutPacket, + }, + TransferOutNoAck { + channel_id: u32, + request_id: RequestId, + request: TransferOutPacket, + }, + Cancel { + channel_id: u32, + request_id: RequestId, + }, +} + +#[derive(Debug)] +enum ServerEvent { + DeviceText(DeviceText), + IoControlCompleted { + channel_id: u32, + request_id: RequestId, + completion: IoControlCompletionResult, + }, + InternalIoControlCompleted { + channel_id: u32, + request_id: RequestId, + completion: IoControlCompletionResult, + }, + TransferInCompleted { + channel_id: u32, + request_id: RequestId, + completion: TransferInCompletionResult, + }, + TransferOutCompleted { + channel_id: u32, + request_id: RequestId, + completion: TransferOutCompletionResult, + }, +} + +struct ChannelClientBackend { + events: Sender, +} + +impl ChannelClientBackend { + fn send(&self, event: ClientEvent) { + self.events + .send(event) + .expect("client event receiver should remain connected"); + } +} + +impl UrbdrcDeviceBackend for ChannelClientBackend { + fn device_info(&mut self, _channel_id: u32) -> PduResult { + Ok(simple_device_info()) + } + + fn cancel_request(&mut self, request_id: RequestId, channel_id: u32) { + self.send(ClientEvent::Cancel { channel_id, request_id }); + } + + fn query_device_text(&mut self, channel_id: u32, text_type: u32, locale_id: u32) -> PduResult { + self.send(ClientEvent::QueryDeviceText { + channel_id, + text_type, + locale_id, + }); + Ok(DeviceText { + hresult: DEVICE_TEXT_HRESULT, + description: DEVICE_TEXT_DESCRIPTION.to_owned(), + }) + } + + fn io_control( + &mut self, + channel_id: u32, + request_id: RequestId, + request: IoControlPacket, + ) -> PduResult> { + self.send(ClientEvent::IoControl { + channel_id, + request_id, + request, + }); + Ok(None) + } + + fn internal_io_control( + &mut self, + channel_id: u32, + request_id: RequestId, + request: InternalIoControlPacket, + ) -> PduResult> { + self.send(ClientEvent::InternalIoControl { + channel_id, + request_id, + request, + }); + Ok(None) + } + + fn transfer_in( + &mut self, + channel_id: u32, + request_id: RequestId, + request: TransferInPacket, + ) -> PduResult> { + self.send(ClientEvent::TransferIn { + channel_id, + request_id, + request, + }); + Ok(None) + } + + fn transfer_out( + &mut self, + channel_id: u32, + request_id: RequestId, + request: TransferOutPacket, + ) -> PduResult> { + self.send(ClientEvent::TransferOut { + channel_id, + request_id, + request, + }); + Ok(None) + } + + fn transfer_out_no_ack( + &mut self, + channel_id: u32, + request_id: RequestId, + request: TransferOutPacket, + ) -> PduResult<()> { + self.send(ClientEvent::TransferOutNoAck { + channel_id, + request_id, + request, + }); + Ok(()) + } + + fn retract(&mut self, _channel_id: u32) -> PduResult<()> { + Ok(()) + } +} + +struct ChannelDeviceServerBackend { + events: Sender, +} + +impl ChannelDeviceServerBackend { + fn send(&self, event: ServerEvent) { + self.events + .send(event) + .expect("server event receiver should remain connected"); + } +} + +impl UrbdrcDeviceServerBackend for ChannelDeviceServerBackend { + fn add_device(&mut self, _device: DeviceAnnounce) -> PduResult<()> { + Ok(()) + } + + fn device_text(&mut self, device_text: DeviceText) { + self.send(ServerEvent::DeviceText(device_text)); + } + + fn io_control_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: IoControlCompletionResult, + ) -> PduResult<()> { + self.send(ServerEvent::IoControlCompleted { + channel_id, + request_id, + completion, + }); + Ok(()) + } + + fn internal_io_control_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: IoControlCompletionResult, + ) -> PduResult<()> { + self.send(ServerEvent::InternalIoControlCompleted { + channel_id, + request_id, + completion, + }); + Ok(()) + } + + fn transfer_in_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: TransferInCompletionResult, + ) -> PduResult<()> { + self.send(ServerEvent::TransferInCompleted { + channel_id, + request_id, + completion, + }); + Ok(()) + } + + fn transfer_out_completed( + &mut self, + channel_id: u32, + request_id: RequestId, + completion: TransferOutCompletionResult, + ) -> PduResult<()> { + self.send(ServerEvent::TransferOutCompleted { + channel_id, + request_id, + completion, + }); + Ok(()) + } +} + +struct ConnectedDevice { + client: UrbdrcDeviceClient, + server: UrbdrcDeviceServer, + client_events: Receiver, + server_events: Receiver, +} + +impl ConnectedDevice { + fn new() -> Self { + let udev_iface = InterfaceId::try_from(4).expect("valid device interface id"); + let completion_iface = InterfaceId::try_from(5).expect("valid completion interface id"); + let (client_events_tx, client_events) = mpsc::channel(); + let (server_events_tx, server_events) = mpsc::channel(); + + let client_backend = Box::new(ChannelClientBackend { + events: client_events_tx, + }); + let server_backend = Box::new(ChannelDeviceServerBackend { + events: server_events_tx, + }); + let mut client = UrbdrcDeviceClient::new(udev_iface, client_backend).expect("device client should be created"); + let mut server = + UrbdrcDeviceServer::new(server_backend, completion_iface).expect("device server should be created"); + + let mut to_client = server.start(CHANNEL_ID).expect("server start should succeed"); + let mut settled = false; + for _ in 0..16 { + let mut to_server = Vec::new(); + for message in to_client { + to_server.extend(process_message(&mut client, message)); + } + if to_server.is_empty() { + settled = true; + break; + } + + to_client = Vec::new(); + for message in to_server { + to_client.extend(process_message(&mut server, message)); + } + if to_client.is_empty() { + settled = true; + break; + } + } + assert!(settled, "device DVC setup should settle"); + assert!(client.ready_for_io()); + + Self { + client, + server, + client_events, + server_events, + } + } + + fn send_to_client(&mut self, message: DvcMessage) -> Vec { + process_message(&mut self.client, message) + } + + fn send_to_server(&mut self, message: DvcMessage) -> Vec { + process_message(&mut self.server, message) + } + + fn next_client_event(&self) -> ClientEvent { + self.client_events.try_recv().expect("client backend should be called") + } + + fn next_server_event(&self) -> ServerEvent { + self.server_events.try_recv().expect("server backend should be called") + } +} + +fn process_message(processor: &mut dyn ironrdp_dvc::DvcProcessor, message: DvcMessage) -> Vec { + let payload = encode_vec(message.as_ref()).expect("DVC message should encode"); + processor + .process(CHANNEL_ID, &payload) + .expect("DVC message should process") +} + +fn only_message(mut messages: Vec) -> DvcMessage { + assert_eq!(messages.len(), 1); + messages.pop().expect("one message should be present") +} + +mod requests; +mod transfers; diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/io/requests.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/io/requests.rs new file mode 100644 index 0000000000..d3cbdd8819 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/io/requests.rs @@ -0,0 +1,217 @@ +use ironrdp_rdpeusb::io::{InternalIoControlPacket, IoControlCompletionResult, IoControlPacket, IoctlInternalUsb}; +use rstest::rstest; + +use super::{ + CHANNEL_ID, ClientEvent, ConnectedDevice, DEVICE_TEXT_DESCRIPTION, DEVICE_TEXT_HRESULT, ServerEvent, only_message, +}; + +// Refs: [Query Device Text][2.2.6.5] and [Query Device Text Response][2.2.6.6]. +// [2.2.6.5]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/d03a7696-2d56-4f20-b7a9-a5e72a045956 +// [2.2.6.6]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/acffdcfa-c792-40a4-a8ee-c545ea5b0a38 +#[test] +fn query_device_text_round_trip() { + let mut device = ConnectedDevice::new(); + + let request = device + .server + .query_device_text(1, 0x0409) + .expect("query device text should succeed"); + let response = only_message(device.send_to_client(request)); + + let ClientEvent::QueryDeviceText { + channel_id, + text_type, + locale_id, + } = device.next_client_event() + else { + panic!("expected query device text event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(text_type, 1); + assert_eq!(locale_id, 0x0409); + + assert!(device.send_to_server(response).is_empty()); + let ServerEvent::DeviceText(device_text) = device.next_server_event() else { + panic!("expected device text event"); + }; + assert_eq!(device_text.hresult, DEVICE_TEXT_HRESULT); + assert_eq!(device_text.description, DEVICE_TEXT_DESCRIPTION); +} + +// Ref: [IO Control Completion][2.2.7.1]. +// [2.2.7.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/b1722374-0658-47ba-8368-87bf9d3db4d4 +#[rstest] +#[case::reset_port( + IoControlPacket { + ioctl_code: IoctlInternalUsb::ResetPort, + input_buffer: Vec::new(), + output_buffer_size: 0, + }, + IoControlCompletionResult { + hresult: 0, + information: 0, + output_buffer: Vec::new(), + }, +)] +#[case::get_port_status( + IoControlPacket { + ioctl_code: IoctlInternalUsb::GetPortStatus, + input_buffer: Vec::new(), + output_buffer_size: 4, + }, + IoControlCompletionResult { + hresult: 0, + information: 4, + output_buffer: vec![1, 0, 0, 0], + }, +)] +#[case::get_hub_name( + IoControlPacket { + ioctl_code: IoctlInternalUsb::GetHubName, + input_buffer: Vec::new(), + output_buffer_size: 8, + }, + IoControlCompletionResult { + hresult: 0, + information: 4, + output_buffer: vec![b'H', 0, b'1', 0], + }, +)] +fn io_control_pending_completion_round_trip( + #[case] packet: IoControlPacket, + #[case] completion: IoControlCompletionResult, +) { + let mut device = ConnectedDevice::new(); + let expected_ioctl_code = packet.ioctl_code; + let expected_input_buffer = packet.input_buffer.clone(); + let expected_output_buffer_size = packet.output_buffer_size; + let expected_hresult = completion.hresult; + let expected_information = completion.information; + let expected_completion_output = completion.output_buffer.clone(); + + let request = device.server.io_control(packet).expect("IO control should succeed"); + assert!(request.expects_completion); + let request_id = request.request_id; + assert!(device.send_to_client(request.message).is_empty()); + + let ClientEvent::IoControl { + channel_id, + request_id: backend_request_id, + request, + } = device.next_client_event() + else { + panic!("expected IO control event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert_eq!(request.ioctl_code, expected_ioctl_code); + assert_eq!(request.input_buffer, expected_input_buffer); + assert_eq!(request.output_buffer_size, expected_output_buffer_size); + + let response = device + .client + .io_ctl_completion(request_id, completion) + .expect("IO control completion should succeed"); + assert!(device.send_to_server(response).is_empty()); + + let ServerEvent::IoControlCompleted { + channel_id, + request_id: backend_request_id, + completion, + } = device.next_server_event() + else { + panic!("expected IO control completion event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert_eq!(completion.hresult, expected_hresult); + assert_eq!(completion.information, expected_information); + assert_eq!(completion.output_buffer, expected_completion_output); +} + +// Ref: [Internal IO Control Message][2.2.6.4]. +// [2.2.6.4]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/c3f3e320-336d-4d1b-84c9-51e0ed330ffe +#[test] +fn internal_io_control_pending_completion_round_trip() { + let mut device = ConnectedDevice::new(); + let request = InternalIoControlPacket::QueryBusTime; + + let request = device + .server + .internal_io_control(request) + .expect("internal IO control should succeed"); + assert!(request.expects_completion); + let request_id = request.request_id; + assert!(device.send_to_client(request.message).is_empty()); + + let ClientEvent::InternalIoControl { + channel_id, + request_id: backend_request_id, + request, + } = device.next_client_event() + else { + panic!("expected internal IO control event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert!(matches!(request, InternalIoControlPacket::QueryBusTime)); + + let completion = IoControlCompletionResult { + hresult: 0, + information: 4, + output_buffer: vec![42, 0, 0, 0], + }; + let response = device + .client + .internal_io_ctl_completion(request_id, completion) + .expect("internal IO control completion should succeed"); + assert!(device.send_to_server(response).is_empty()); + + let ServerEvent::InternalIoControlCompleted { + channel_id, + request_id: backend_request_id, + completion, + } = device.next_server_event() + else { + panic!("expected internal IO control completion event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert_eq!(completion.hresult, 0); + assert_eq!(completion.information, 4); + assert_eq!(completion.output_buffer, [42, 0, 0, 0]); +} + +// Ref: [Processing a Cancel Request Message][3.3.5.3.1]. +// [3.3.5.3.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/d5315234-d9ba-42dc-bc1b-b421c57a21ae +#[test] +fn cancel_pending_request() { + let mut device = ConnectedDevice::new(); + let request = device + .server + .io_control(IoControlPacket { + ioctl_code: IoctlInternalUsb::GetPortStatus, + input_buffer: Vec::new(), + output_buffer_size: 4, + }) + .expect("IO control should succeed"); + let request_id = request.request_id; + assert!(device.send_to_client(request.message).is_empty()); + assert!(matches!(device.next_client_event(), ClientEvent::IoControl { .. })); + + let cancel = device + .server + .cancel_request(request_id) + .expect("cancel request should succeed"); + assert!(device.send_to_client(cancel).is_empty()); + + let ClientEvent::Cancel { + channel_id, + request_id: backend_request_id, + } = device.next_client_event() + else { + panic!("expected cancel event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); +} diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/io/transfers.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/io/transfers.rs new file mode 100644 index 0000000000..84e4f95c86 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/io/transfers.rs @@ -0,0 +1,289 @@ +use ironrdp_rdpeusb::io::{ + TransferInCompletionResult, TransferInPacket, TransferOutCompletionResult, TransferOutPacket, TsUrbInKind, + TsUrbInPacket, TsUrbOutKind, TsUrbOutPacket, UrbFunction, +}; +use ironrdp_rdpeusb::pdu::completion::ts_urb_result::{TsUrbResult, TsUrbResultHeader, TsUrbResultPayload}; +use ironrdp_rdpeusb::pdu::usb_dev::ts_urb::utils::SetupPacket; +use ironrdp_rdpeusb::pdu::usb_dev::ts_urb::{ + TsUrbBulkOrInterruptTransfer, TsUrbControlGetConfigRequest, TsUrbControlGetInterfaceRequest, + TsUrbControlGetStatusRequest, TsUrbControlTransfer, TsUrbControlVendorClassRequest, TsUrbIsochTransfer, +}; +use ironrdp_rdpeusb::pdu::utils::UsbdIsoPacketDesc; +use rstest::rstest; + +use super::{CHANNEL_ID, ClientEvent, ConnectedDevice, ServerEvent}; + +fn successful_urb_result() -> TsUrbResult { + TsUrbResult { + header: TsUrbResultHeader { usbd_status: 0 }, + payload: TsUrbResultPayload::Raw(Vec::new()), + } +} + +// Refs: [URB Completion][2.2.7.2] and [URB Completion No Data][2.2.7.3]. +// [2.2.7.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/5bfa9c84-a74b-4942-9d09-e770b21081eb +// [2.2.7.3]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/994fac8f-d258-47a6-aa35-48783abe49ec +#[rstest] +#[case::get_configuration( + TransferInPacket { + ts_urb: TsUrbInPacket { + kind: TsUrbInKind::CtlGetConfig(TsUrbControlGetConfigRequest), + func: UrbFunction::URB_FUNCTION_GET_CONFIGURATION, + }, + output_buffer_size: 1, + }, + vec![0x01], +)] +#[case::get_configuration_without_data( + TransferInPacket { + ts_urb: TsUrbInPacket { + kind: TsUrbInKind::CtlGetConfig(TsUrbControlGetConfigRequest), + func: UrbFunction::URB_FUNCTION_GET_CONFIGURATION, + }, + output_buffer_size: 1, + }, + Vec::new(), +)] +#[case::get_interface( + TransferInPacket { + ts_urb: TsUrbInPacket { + kind: TsUrbInKind::CtlGetIface(TsUrbControlGetInterfaceRequest { interface: 2 }), + func: UrbFunction::URB_FUNCTION_GET_INTERFACE, + }, + output_buffer_size: 1, + }, + vec![0x02], +)] +#[case::get_status( + TransferInPacket { + ts_urb: TsUrbInPacket { + kind: TsUrbInKind::CtlGetStatus(TsUrbControlGetStatusRequest { index: 0x81 }), + func: UrbFunction::URB_FUNCTION_GET_STATUS_FROM_ENDPOINT, + }, + output_buffer_size: 2, + }, + vec![0x01, 0x00], +)] +fn transfer_in_completion_round_trip(#[case] packet: TransferInPacket, #[case] output_buffer: Vec) { + let mut device = ConnectedDevice::new(); + let expected_kind = packet.ts_urb.kind.clone(); + let expected_func = packet.ts_urb.func; + let expected_output_buffer_size = packet.output_buffer_size; + let request = device.server.transfer_in(packet).expect("transfer in should succeed"); + assert!(request.expects_completion); + let request_id = request.request_id; + assert!(device.send_to_client(request.message).is_empty()); + + let ClientEvent::TransferIn { + channel_id, + request_id: backend_request_id, + request, + } = device.next_client_event() + else { + panic!("expected transfer in event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert_eq!(request.ts_urb.kind, expected_kind); + assert_eq!(request.ts_urb.func, expected_func); + assert_eq!(request.output_buffer_size, expected_output_buffer_size); + + let response = device + .client + .transfer_in_completion( + request_id, + TransferInCompletionResult { + ts_urb_result: successful_urb_result(), + hresult: 0, + output_buffer: output_buffer.clone(), + }, + ) + .expect("transfer in completion should succeed"); + assert!(device.send_to_server(response).is_empty()); + + let ServerEvent::TransferInCompleted { + channel_id, + request_id: backend_request_id, + completion, + } = device.next_server_event() + else { + panic!("expected transfer in completion event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert_eq!(completion.ts_urb_result, successful_urb_result()); + assert_eq!(completion.hresult, 0); + assert_eq!(completion.output_buffer, output_buffer); +} + +// Ref: [Transfer Out Request][2.2.6.8]. +// [2.2.6.8]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/6d6c85b2-47bb-4674-975a-dc7d8ed684cd +#[rstest] +#[case::bulk_or_interrupt( + TransferOutPacket { + ts_urb: TsUrbOutPacket { + kind: TsUrbOutKind::BulkInterruptTransfer(TsUrbBulkOrInterruptTransfer { + pipe_handle: 7, + transfer_flags: 0, + }), + no_ack: false, + func: UrbFunction::URB_FUNCTION_BULK_OR_INTERRUPT_TRANSFER, + }, + output_buffer: vec![1, 2, 3], + }, + TransferOutCompletionResult { + ts_urb_result: successful_urb_result(), + hresult: 0, + output_buffer_size: 3, + }, +)] +#[case::control( + TransferOutPacket { + ts_urb: TsUrbOutPacket { + kind: TsUrbOutKind::CtlTransfer(TsUrbControlTransfer { + pipe: 0, + transfer_flags: 0, + setup_packet: SetupPacket { + request_type: 0, + request: 9, + value: 1, + index: 0, + length: 0, + }, + }), + no_ack: false, + func: UrbFunction::URB_FUNCTION_CONTROL_TRANSFER, + }, + output_buffer: Vec::new(), + }, + TransferOutCompletionResult { + ts_urb_result: successful_urb_result(), + hresult: 0, + output_buffer_size: 0, + }, +)] +#[case::vendor( + TransferOutPacket { + ts_urb: TsUrbOutPacket { + kind: TsUrbOutKind::VendorClassReq(TsUrbControlVendorClassRequest { + transfer_flags: 0, + request: 1, + value: 2, + index: 3, + }), + no_ack: false, + func: UrbFunction::URB_FUNCTION_VENDOR_DEVICE, + }, + output_buffer: vec![4, 5], + }, + TransferOutCompletionResult { + ts_urb_result: successful_urb_result(), + hresult: 0, + output_buffer_size: 2, + }, +)] +fn transfer_out_completion_round_trip( + #[case] packet: TransferOutPacket, + #[case] completion: TransferOutCompletionResult, +) { + let mut device = ConnectedDevice::new(); + let expected_kind = packet.ts_urb.kind.clone(); + let expected_func = packet.ts_urb.func; + let expected_output_buffer = packet.output_buffer.clone(); + let expected_ts_urb_result = completion.ts_urb_result.clone(); + let expected_hresult = completion.hresult; + let expected_output_buffer_size = completion.output_buffer_size; + let request = device.server.transfer_out(packet).expect("transfer out should succeed"); + assert!(request.expects_completion); + let request_id = request.request_id; + assert!(device.send_to_client(request.message).is_empty()); + + let ClientEvent::TransferOut { + channel_id, + request_id: backend_request_id, + request, + } = device.next_client_event() + else { + panic!("expected transfer out event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert_eq!(request.ts_urb.kind, expected_kind); + assert!(!request.ts_urb.no_ack); + assert_eq!(request.ts_urb.func, expected_func); + assert_eq!(request.output_buffer, expected_output_buffer); + + let response = device + .client + .transfer_out_completion(request_id, completion) + .expect("transfer out completion should succeed"); + assert!(device.send_to_server(response).is_empty()); + + let ServerEvent::TransferOutCompleted { + channel_id, + request_id: backend_request_id, + completion, + } = device.next_server_event() + else { + panic!("expected transfer out completion event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + assert_eq!(completion.ts_urb_result, expected_ts_urb_result); + assert_eq!(completion.hresult, expected_hresult); + assert_eq!(completion.output_buffer_size, expected_output_buffer_size); +} + +#[test] +fn transfer_out_no_ack() { + let mut device = ConnectedDevice::new(); + let request = device + .server + .transfer_out(TransferOutPacket { + ts_urb: TsUrbOutPacket { + kind: TsUrbOutKind::IsochTransfer(TsUrbIsochTransfer { + pipe_handle: 7, + transfer_flags: 0, + start_frame: 100, + error_count: 0, + iso_packet: vec![UsbdIsoPacketDesc { + offset: 0, + length: 3, + status: 0, + }], + }), + no_ack: true, + func: UrbFunction::URB_FUNCTION_ISOCH_TRANSFER, + }, + output_buffer: vec![1, 2, 3], + }) + .expect("no-ack transfer out should succeed"); + assert!(!request.expects_completion); + let request_id = request.request_id; + assert!(device.send_to_client(request.message).is_empty()); + + let ClientEvent::TransferOutNoAck { + channel_id, + request_id: backend_request_id, + request, + } = device.next_client_event() + else { + panic!("expected no-ack transfer out event"); + }; + assert_eq!(channel_id, CHANNEL_ID); + assert_eq!(backend_request_id, request_id); + let TsUrbOutKind::IsochTransfer(urb) = request.ts_urb.kind else { + panic!("expected isochronous transfer"); + }; + assert_eq!(urb.pipe_handle, 7); + assert_eq!(urb.transfer_flags, 0); + assert_eq!(urb.start_frame, 100); + assert_eq!(urb.error_count, 0); + assert_eq!(urb.iso_packet.len(), 1); + assert_eq!(urb.iso_packet[0].offset, 0); + assert_eq!(urb.iso_packet[0].length, 3); + assert_eq!(urb.iso_packet[0].status, 0); + assert!(request.ts_urb.no_ack); + assert_eq!(request.ts_urb.func, UrbFunction::URB_FUNCTION_ISOCH_TRANSFER); + assert_eq!(request.output_buffer, [1, 2, 3]); +} diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/mod.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/mod.rs new file mode 100644 index 0000000000..d92fe641b8 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/mod.rs @@ -0,0 +1,51 @@ +use ironrdp_core::encode_vec; +use ironrdp_rdpeusb::{ + io::device::{ + DeviceInfo, UsbBcdVersion, UsbClassCodes, UsbConfigInfo, UsbConnectionSpeed, UsbDeviceDescriptorInfo, + UsbDeviceLocation, UsbInterfaceInfo, + }, + pdu::header::InterfaceId, +}; + +fn simple_device_info() -> DeviceInfo { + DeviceInfo { + location: UsbDeviceLocation { + bus_number: 7, + address: 2, + port_numbers: vec![1, 4], + }, + descriptor: UsbDeviceDescriptorInfo { + vendor_id: 0x1234, + product_id: 0xabcd, + device_version: 0x0210, + usb_version: UsbBcdVersion::from_bcd(0x0200), + class_codes: UsbClassCodes::PER_INTERFACE, + num_configurations: 1, + }, + active_config: Some(UsbConfigInfo { + interfaces: vec![UsbInterfaceInfo { + class_codes: UsbClassCodes { + class_code: 0x03, + sub_class_code: 0x01, + protocol_code: 0x02, + }, + }], + }), + speed: UsbConnectionSpeed::Unknown, + } +} + +const STREAM_ID_PROXY: u32 = 1; + +fn proxy_iface_id(iface: InterfaceId) -> u32 { + u32::from(iface) | (STREAM_ID_PROXY << 30) +} + +fn encode_pdu(pdu: &T) -> Vec { + encode_vec(pdu).expect("encode should succeed") +} + +mod client; +mod device; +mod io; +mod server; diff --git a/crates/ironrdp-testsuite-core/tests/rdpeusb/server.rs b/crates/ironrdp-testsuite-core/tests/rdpeusb/server.rs new file mode 100644 index 0000000000..234d65a410 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpeusb/server.rs @@ -0,0 +1,226 @@ +use std::sync::mpsc::{self, Sender, TryRecvError}; + +use ironrdp_core::{decode, encode_vec}; +use ironrdp_dvc::{DvcMessage, DvcProcessor as _}; +use ironrdp_pdu::PduResult; +use ironrdp_rdpeusb::CHANNEL_NAME; +use ironrdp_rdpeusb::io::device::add_device_from_info; +use ironrdp_rdpeusb::io::{ + DeviceAnnounce, DeviceText, IoControlCompletionResult, RequestId, TransferInCompletionResult, + TransferOutCompletionResult, +}; +use ironrdp_rdpeusb::pdu::caps::{Capability, RimExchangeCapabilityResponse}; +use ironrdp_rdpeusb::pdu::header::InterfaceId; +use ironrdp_rdpeusb::pdu::notify::{ChannelCreated, Direction}; +use ironrdp_rdpeusb::pdu::sink::AddVirtualChannel; +use ironrdp_rdpeusb::pdu::{ + UrbdrcClientControlPdu, UrbdrcClientDevicePdu, UrbdrcServerControlPdu, UrbdrcServerDevicePdu, +}; +use ironrdp_rdpeusb::server::{ + UrbdrcControlServer, UrbdrcControlServerBackend, UrbdrcDeviceServer, UrbdrcDeviceServerBackend, +}; + +use super::{encode_pdu, proxy_iface_id, simple_device_info}; + +fn decode_control_msg(message: &DvcMessage) -> UrbdrcServerControlPdu { + let encoded = encode_vec(message.as_ref()).expect("encode should succeed"); + decode(&encoded).expect("decode should succeed") +} + +fn decode_device_msg(message: &DvcMessage) -> UrbdrcServerDevicePdu { + let encoded = encode_vec(message.as_ref()).expect("encode should succeed"); + decode(&encoded).expect("decode should succeed") +} + +struct TestControlBackend { + device_channel_created: Sender<()>, +} + +impl UrbdrcControlServerBackend for TestControlBackend { + fn create_device_chan(&mut self) -> PduResult<()> { + self.device_channel_created + .send(()) + .expect("device channel receiver should remain connected"); + Ok(()) + } +} + +struct TestDeviceBackend { + device_announced: Sender, +} + +impl UrbdrcDeviceServerBackend for TestDeviceBackend { + fn add_device(&mut self, device: DeviceAnnounce) -> PduResult<()> { + self.device_announced + .send(device) + .expect("device announcement receiver should remain connected"); + Ok(()) + } + + fn device_text(&mut self, _device_text: DeviceText) {} + + fn io_control_completed( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _completion: IoControlCompletionResult, + ) -> PduResult<()> { + Ok(()) + } + + fn internal_io_control_completed( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _completion: IoControlCompletionResult, + ) -> PduResult<()> { + Ok(()) + } + + fn transfer_in_completed( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _completion: TransferInCompletionResult, + ) -> PduResult<()> { + Ok(()) + } + + fn transfer_out_completed( + &mut self, + _channel_id: u32, + _request_id: RequestId, + _completion: TransferOutCompletionResult, + ) -> PduResult<()> { + Ok(()) + } +} + +// Ref: [Channel Setup Sequence][1.3.1.1] +// [1.3.1.1]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/55bb34fc-7fd0-4aca-8739-5fb6759b66fc +#[test] +fn capability_exchange_sequence() { + let (device_channel_created, channel_created_rx) = mpsc::channel(); + let backend = Box::new(TestControlBackend { device_channel_created }); + let mut server = UrbdrcControlServer::new(backend); + + assert_eq!(server.channel_name(), CHANNEL_NAME); + + let resp = server.start(10).expect("start should succeed"); + assert_eq!(resp.len(), 1); + let UrbdrcServerControlPdu::Caps(request) = decode_control_msg(&resp[0]) else { + panic!("expected capability request"); + }; + assert_eq!(request.capability, Capability::RimCapabilityVersion01); + + let resp = server + .process( + 10, + &encode_pdu(&UrbdrcClientControlPdu::Caps(RimExchangeCapabilityResponse { + msg_id: request.msg_id, + capability: Capability::RimCapabilityVersion01, + result: 0, + })), + ) + .expect("capability response should succeed"); + assert_eq!(resp.len(), 2); + + let UrbdrcServerControlPdu::IfaceRelease(release) = decode_control_msg(&resp[0]) else { + panic!("expected capabilities interface release"); + }; + assert_eq!(release.iface_id, u32::from(InterfaceId::CAPABILITIES)); + + let UrbdrcServerControlPdu::ChanCreated(channel_created_request) = decode_control_msg(&resp[1]) else { + panic!("expected channel-created request"); + }; + assert_eq!(channel_created_request.direction, Direction::ToClient); + + let resp = server + .process( + 10, + &encode_pdu(&UrbdrcClientControlPdu::ChanCreated(ChannelCreated { + msg_id: channel_created_request.msg_id, + direction: Direction::ToServer, + })), + ) + .expect("channel-created response should succeed"); + assert_eq!(resp.len(), 1); + + let UrbdrcServerControlPdu::IfaceRelease(release) = decode_control_msg(&resp[0]) else { + panic!("expected notification interface release"); + }; + assert_eq!(release.iface_id, proxy_iface_id(InterfaceId::NOTIFY_CLIENT)); + + let resp = server + .process( + 10, + &encode_pdu(&UrbdrcClientControlPdu::AddChan(AddVirtualChannel { msg_id: 0 })), + ) + .expect("add virtual channel should succeed"); + assert!(resp.is_empty()); + channel_created_rx.try_recv().expect("backend should be notified"); + assert!( + matches!(channel_created_rx.try_recv(), Err(TryRecvError::Empty)), + "backend should be notified exactly once" + ); +} + +// Ref: [New Device Sequence][1.3.1.2] +// [1.3.1.2]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpeusb/7e3da218-9cdc-4ebd-bb76-e70202c7f264 +#[test] +fn new_device_sequence() { + let udev_iface = InterfaceId::try_from(4).expect("valid device interface id"); + let completion_iface = InterfaceId::try_from(5).expect("valid completion interface id"); + let (device_announced, announcement_rx) = mpsc::channel(); + let backend = Box::new(TestDeviceBackend { device_announced }); + let mut server = UrbdrcDeviceServer::new(backend, completion_iface).expect("device server should be created"); + + assert_eq!(server.channel_name(), CHANNEL_NAME); + + let resp = server.start(11).expect("start should succeed"); + assert_eq!(resp.len(), 1); + let UrbdrcServerDevicePdu::ChanCreated(channel_created_request) = decode_device_msg(&resp[0]) else { + panic!("expected channel-created request"); + }; + assert_eq!(channel_created_request.direction, Direction::ToClient); + + let resp = server + .process( + 11, + &encode_pdu(&UrbdrcClientDevicePdu::ChanCreated(ChannelCreated { + msg_id: channel_created_request.msg_id, + direction: Direction::ToServer, + })), + ) + .expect("channel-created response should succeed"); + assert_eq!(resp.len(), 1); + let UrbdrcServerDevicePdu::IfaceRelease(release) = decode_device_msg(&resp[0]) else { + panic!("expected notification interface release"); + }; + assert_eq!(release.iface_id, proxy_iface_id(InterfaceId::NOTIFY_CLIENT)); + + let add_device = add_device_from_info(udev_iface, &simple_device_info()).expect("ADD_DEVICE should be generated"); + let resp = server + .process(11, &encode_pdu(&UrbdrcClientDevicePdu::AddDev(add_device))) + .expect("add device should succeed"); + assert_eq!(resp.len(), 2); + + let UrbdrcServerDevicePdu::IfaceRelease(release) = decode_device_msg(&resp[0]) else { + panic!("expected device sink interface release"); + }; + assert_eq!(release.iface_id, proxy_iface_id(InterfaceId::DEVICE_SINK)); + + let UrbdrcServerDevicePdu::RegReqCb(register) = decode_device_msg(&resp[1]) else { + panic!("expected request callback registration"); + }; + assert_eq!(register.udev_iface, udev_iface); + assert_eq!(register.request_completion, Some(completion_iface)); + + announcement_rx + .try_recv() + .expect("backend should receive device announcement"); + assert!( + matches!(announcement_rx.try_recv(), Err(TryRecvError::Empty)), + "device should be announced exactly once" + ); +} diff --git a/crates/ironrdp-testsuite-core/tests/rdpsnd/client.rs b/crates/ironrdp-testsuite-core/tests/rdpsnd/client.rs new file mode 100644 index 0000000000..5c9a616a48 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpsnd/client.rs @@ -0,0 +1,283 @@ +use std::borrow::Cow; + +use ironrdp_core::{decode, encode_vec}; +use ironrdp_rdpsnd::client::{NoopRdpsndBackend, Rdpsnd}; +use ironrdp_rdpsnd::pdu; +use ironrdp_svc::SvcProcessor as _; +use rstest::rstest; + +// ============================================================================ +// Encoding helpers +// ============================================================================ + +fn encoded_server_formats(version: pdu::Version) -> Vec { + encode_vec(&pdu::ServerAudioOutputPdu::AudioFormat(pdu::ServerAudioFormatPdu { + version, + formats: vec![pdu::AudioFormat { + format: pdu::WaveFormat::PCM, + n_channels: 2, + n_samples_per_sec: 44100, + n_avg_bytes_per_sec: 176400, + n_block_align: 4, + bits_per_sample: 16, + data: None, + }], + })) + .unwrap() +} + +fn encoded_training() -> Vec { + encode_vec(&pdu::ServerAudioOutputPdu::Training(pdu::TrainingPdu { + timestamp: 0x1234, + data: vec![], + })) + .unwrap() +} + +fn encoded_wave2(block_no: u8) -> Vec { + encode_vec(&pdu::ServerAudioOutputPdu::Wave2(pdu::Wave2Pdu { + timestamp: 0xA116, + format_no: 0, + block_no, + audio_timestamp: 0xDACB8C2, + data: Cow::Borrowed(&[0x01, 0x02, 0x03, 0x04]), + })) + .unwrap() +} + +fn encoded_volume() -> Vec { + encode_vec(&pdu::ServerAudioOutputPdu::Volume(pdu::VolumePdu { + volume_left: 0x8000, + volume_right: 0x8000, + })) + .unwrap() +} + +fn encoded_pitch() -> Vec { + encode_vec(&pdu::ServerAudioOutputPdu::Pitch(pdu::PitchPdu { pitch: 0x00010000 })).unwrap() +} + +fn encoded_close() -> Vec { + encode_vec(&pdu::ServerAudioOutputPdu::Close).unwrap() +} + +fn encoded_crypt_key() -> Vec { + encode_vec(&pdu::ServerAudioOutputPdu::CryptKey(pdu::CryptKeyPdu { + seed: [0xAB; 32], + })) + .unwrap() +} + +fn encoded_wave_encrypt() -> Vec { + encode_vec(&pdu::ServerAudioOutputPdu::WaveEncrypt(pdu::WaveEncryptPdu { + timestamp: 0x1234, + format_no: 0, + block_no: 1, + signature: Some([0xCC; 8]), + data: vec![0x01, 0x02], + })) + .unwrap() +} + +fn encoded_wave() -> Vec { + encode_vec(&pdu::ServerAudioOutputPdu::Wave(pdu::WavePdu { + timestamp: 0xADD7, + format_no: 0, + block_no: 1, + data: Cow::Borrowed(&[0x01, 0x02, 0x03, 0x04]), + })) + .unwrap() +} + +// ============================================================================ +// State constructors +// ============================================================================ + +// Drive the client state machine from Start through to Ready. +fn client_in_ready(version: pdu::Version) -> Rdpsnd { + let mut client = Rdpsnd::new(Box::new(NoopRdpsndBackend)); + client.process(&encoded_server_formats(version)).unwrap(); + client.process(&encoded_training()).unwrap(); + client +} + +fn client_in_start() -> Rdpsnd { + Rdpsnd::new(Box::new(NoopRdpsndBackend)) +} + +fn client_in_waiting() -> Rdpsnd { + let mut client = Rdpsnd::new(Box::new(NoopRdpsndBackend)); + client.process(&encoded_server_formats(pdu::Version::V8)).unwrap(); + client +} + +fn client_in_stop() -> Rdpsnd { + let mut client = Rdpsnd::new(Box::new(NoopRdpsndBackend)); + // Training is invalid in Start state, transitions to Stop. + client.process(&encoded_training()).unwrap(); + client +} + +// ============================================================================ +// Verification helpers +// ============================================================================ + +// Verify the client is in the Stop state by confirming that a valid PDU +// is silently ignored (empty response, no error). +fn assert_in_stop_state(client: &mut Rdpsnd) { + let responses = client.process(&encoded_server_formats(pdu::Version::V8)).unwrap(); + assert!(responses.is_empty(), "Stop state should produce no responses"); +} + +fn decode_single_response(responses: &[ironrdp_svc::SvcMessage]) -> pdu::ClientAudioOutputPdu { + assert_eq!(responses.len(), 1); + let encoded = responses[0].encode_unframed_pdu().unwrap(); + decode(&encoded).unwrap() +} + +// ============================================================================ +// Error-path tests: invalid PDU in a given state transitions to Stop +// ============================================================================ + +#[rstest] +#[case::start_training(client_in_start(), encoded_training())] +#[case::start_close(client_in_start(), encoded_close())] +#[case::start_volume(client_in_start(), encoded_volume())] +#[case::start_pitch(client_in_start(), encoded_pitch())] +#[case::start_wave(client_in_start(), encoded_wave())] +#[case::start_wave2(client_in_start(), encoded_wave2(0))] +#[case::start_crypt_key(client_in_start(), encoded_crypt_key())] +#[case::start_wave_encrypt(client_in_start(), encoded_wave_encrypt())] +#[case::waiting_volume(client_in_waiting(), encoded_volume())] +#[case::waiting_pitch(client_in_waiting(), encoded_pitch())] +#[case::waiting_close(client_in_waiting(), encoded_close())] +#[case::waiting_wave(client_in_waiting(), encoded_wave())] +#[case::waiting_wave2(client_in_waiting(), encoded_wave2(0))] +#[case::waiting_audio_format(client_in_waiting(), encoded_server_formats(pdu::Version::V8))] +#[case::waiting_crypt_key(client_in_waiting(), encoded_crypt_key())] +#[case::waiting_wave_encrypt(client_in_waiting(), encoded_wave_encrypt())] +fn transitions_to_stop_on_invalid_pdu(#[case] mut client: Rdpsnd, #[case] payload: Vec) { + let responses = client.process(&payload).unwrap(); + assert!(responses.is_empty(), "invalid PDU should produce no responses"); + assert_in_stop_state(&mut client); +} + +// ============================================================================ +// Happy-path tests: Ready state +// ============================================================================ + +#[rstest] +#[case::volume(encoded_volume())] +#[case::pitch(encoded_pitch())] +#[case::close(encoded_close())] +fn ready_silent_pdus_keep_state(#[case] payload: Vec) { + let mut client = client_in_ready(pdu::Version::V8); + + let responses = client.process(&payload).unwrap(); + assert!(responses.is_empty(), "silent PDU should produce no responses"); + + // Verify the client remains in Ready by processing a Wave2. + let responses = client.process(&encoded_wave2(1)).unwrap(); + assert_eq!(responses.len(), 1, "wave2 should still produce WaveConfirm"); +} + +#[test] +fn ready_training_sends_confirm() { + let mut client = client_in_ready(pdu::Version::V8); + + let confirm = decode_single_response(&client.process(&encoded_training()).unwrap()); + assert!(matches!(confirm, pdu::ClientAudioOutputPdu::TrainingConfirm(_))); + + // Verify the client remains in Ready. + let responses = client.process(&encoded_wave2(1)).unwrap(); + assert_eq!(responses.len(), 1); +} + +// Ready -> AudioFormat -> QualityMode -> Training -> Wave2 +// +// Verifies that receiving a new AudioFormat PDU in Ready state restarts +// the negotiation sequence and that audio resumes normally afterward. +#[test] +fn ready_audio_format_v6_restarts_negotiation() { + let mut client = client_in_ready(pdu::Version::V6); + + let responses = client.process(&encoded_server_formats(pdu::Version::V6)).unwrap(); + + // V6 >= V6: client should reply with AudioFormat + QualityMode. + assert_eq!(responses.len(), 2); + let encoded = responses[0].encode_unframed_pdu().unwrap(); + assert!(matches!( + decode::(&encoded).unwrap(), + pdu::ClientAudioOutputPdu::AudioFormat(_) + )); + let encoded = responses[1].encode_unframed_pdu().unwrap(); + assert!(matches!( + decode::(&encoded).unwrap(), + pdu::ClientAudioOutputPdu::QualityMode(_) + )); + + let confirm = decode_single_response(&client.process(&encoded_training()).unwrap()); + assert!(matches!(confirm, pdu::ClientAudioOutputPdu::TrainingConfirm(_))); + + let confirm = decode_single_response(&client.process(&encoded_wave2(1)).unwrap()); + assert!(matches!(confirm, pdu::ClientAudioOutputPdu::WaveConfirm(_))); +} + +// Renegotiation with version < V6 should not send QualityMode. +#[test] +fn ready_audio_format_v5_skips_quality_mode() { + let mut client = client_in_ready(pdu::Version::V5); + + let confirm = decode_single_response(&client.process(&encoded_server_formats(pdu::Version::V5)).unwrap()); + assert!(matches!(confirm, pdu::ClientAudioOutputPdu::AudioFormat(_))); + + let confirm = decode_single_response(&client.process(&encoded_training()).unwrap()); + assert!(matches!(confirm, pdu::ClientAudioOutputPdu::TrainingConfirm(_))); + + let confirm = decode_single_response(&client.process(&encoded_wave2(1)).unwrap()); + assert!(matches!(confirm, pdu::ClientAudioOutputPdu::WaveConfirm(_))); +} + +// Repeated renegotiation: Ready -> AudioFormat -> Training -> Ready -> AudioFormat -> ... +// +// Ensures that multiple consecutive renegotiation cycles do not corrupt +// internal state. +#[test] +fn ready_repeated_renegotiation_is_stable() { + let mut client = client_in_ready(pdu::Version::V6); + + let server_formats = encoded_server_formats(pdu::Version::V6); + let training = encoded_training(); + + for cycle in 0u8..3 { + let responses = client.process(&server_formats).unwrap(); + assert_eq!(responses.len(), 2, "cycle {cycle}: expected AudioFormat + QualityMode"); + + let responses = client.process(&training).unwrap(); + assert_eq!(responses.len(), 1, "cycle {cycle}: expected TrainingConfirm"); + + let confirm = decode_single_response(&client.process(&encoded_wave2(cycle)).unwrap()); + assert!(matches!(confirm, pdu::ClientAudioOutputPdu::WaveConfirm(_))); + } +} + +// ============================================================================ +// Terminal state: Stop ignores every PDU type +// ============================================================================ + +#[rstest] +#[case::audio_format(encoded_server_formats(pdu::Version::V8))] +#[case::training(encoded_training())] +#[case::wave(encoded_wave())] +#[case::wave2(encoded_wave2(0))] +#[case::volume(encoded_volume())] +#[case::pitch(encoded_pitch())] +#[case::close(encoded_close())] +#[case::crypt_key(encoded_crypt_key())] +#[case::wave_encrypt(encoded_wave_encrypt())] +fn stop_ignores_all_pdus(#[case] payload: Vec) { + let mut client = client_in_stop(); + + let responses = client.process(&payload).unwrap(); + assert!(responses.is_empty(), "Stop state should ignore all PDUs"); +} diff --git a/crates/ironrdp-testsuite-core/tests/rdpsnd/mod.rs b/crates/ironrdp-testsuite-core/tests/rdpsnd/mod.rs index a175e100c7..64ec4134d5 100644 --- a/crates/ironrdp-testsuite-core/tests/rdpsnd/mod.rs +++ b/crates/ironrdp-testsuite-core/tests/rdpsnd/mod.rs @@ -1,3 +1,6 @@ +mod client; +mod server; + use std::borrow::Cow; use ironrdp_rdpsnd::pdu; diff --git a/crates/ironrdp-testsuite-core/tests/rdpsnd/server.rs b/crates/ironrdp-testsuite-core/tests/rdpsnd/server.rs new file mode 100644 index 0000000000..fddd202db9 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/rdpsnd/server.rs @@ -0,0 +1,244 @@ +//! Server-side tests for `ironrdp-rdpsnd`. +//! +//! Two layers: +//! - the crate-private `negotiate_formats` / `audio_format_eq` helpers, exposed +//! to this testsuite via the rdpsnd crate's private `__test` feature (the lib +//! itself has no inline test harness — `test = false`); +//! - the `SvcProcessor` negotiation wiring, driven black-box through the public +//! surface (no `__test` shim needed). + +use std::sync::{Arc, Mutex}; + +use ironrdp_core::encode_vec; +use ironrdp_rdpsnd::pdu::{ + AudioFormat, AudioFormatFlags, ClientAudioFormatPdu, ClientAudioOutputPdu, TrainingConfirmPdu, Version, WaveFormat, +}; +use ironrdp_rdpsnd::server::{ + NegotiatedFormat, RdpsndError, RdpsndServer, RdpsndServerHandler, audio_format_eq, negotiate_formats, +}; +use ironrdp_svc::SvcProcessor as _; + +fn fmt(format: WaveFormat, rate: u32) -> AudioFormat { + AudioFormat { + format, + n_channels: 2, + n_samples_per_sec: rate, + n_avg_bytes_per_sec: rate * 4, + n_block_align: 4, + bits_per_sample: 16, + data: None, + } +} + +// ============================================================================ +// `negotiate_formats` / `audio_format_eq` helpers (via the `__test` feature) +// ============================================================================ + +#[test] +fn wformat_no_addresses_the_client_list_not_the_server_list() { + // Server prefers AAC over PCM; the client lists them in the opposite + // order. wFormatNo must follow the CLIENT's indices. + let server = [fmt(WaveFormat::AAC_MS, 44100), fmt(WaveFormat::PCM, 44100)]; + let client = [fmt(WaveFormat::PCM, 44100), fmt(WaveFormat::AAC_MS, 44100)]; + + let common = negotiate_formats(&server, &client); + + // Ordering follows the server's preference (AAC first)... + assert_eq!(common.len(), 2); + assert_eq!(common[0].format().format, WaveFormat::AAC_MS); + assert_eq!(common[1].format().format, WaveFormat::PCM); + // ...but each wFormatNo is the position in the CLIENT list. + assert_eq!(common[0].wformat_no(), 1); // AAC is client index 1 + assert_eq!(common[1].wformat_no(), 0); // PCM is client index 0 +} + +#[test] +fn pcm_only_client_gets_a_valid_client_index() { + // Regression for the --enable-aac trap: server advertises [AAC, PCM] + // but a PCM-only client must get wFormatNo 0 (its sole index), not + // PCM's server-list index of 1 (which the client would reject). + let server = [fmt(WaveFormat::AAC_MS, 44100), fmt(WaveFormat::PCM, 44100)]; + let client = [fmt(WaveFormat::PCM, 44100)]; + + let common = negotiate_formats(&server, &client); + + assert_eq!(common.len(), 1); + assert_eq!(common[0].format().format, WaveFormat::PCM); + assert_eq!(common[0].wformat_no(), 0); +} + +#[test] +fn no_shared_format_yields_empty() { + let server = [fmt(WaveFormat::OPUS, 48000)]; + let client = [fmt(WaveFormat::PCM, 44100)]; + assert!(negotiate_formats(&server, &client).is_empty()); +} + +#[test] +fn equality_ignores_derived_fields_but_not_extra_data() { + let mut a = fmt(WaveFormat::PCM, 44100); + let mut b = fmt(WaveFormat::PCM, 44100); + + // The two derived fields are computable and a client need not echo them — + // differing there is still the same format. + b.n_avg_bytes_per_sec = 0; + b.n_block_align = 99; + assert!(audio_format_eq(&a, &b)); + + // The codec extra-data blob IS significant (e.g. AAC config): a differing + // `data` is a different format, even with identical WAVEFORMATEX fields. + a.data = Some(vec![1, 2, 3]); + b.data = None; + assert!(!audio_format_eq(&a, &b)); + + // A differing identity field (sample rate) is a different format. + let c = fmt(WaveFormat::PCM, 48000); + assert!(!audio_format_eq(&a, &c)); +} + +#[test] +fn extra_data_must_match_for_otherwise_identical_formats() { + // Two AAC formats identical in every WAVEFORMATEX field but carrying + // different HEAACWAVEINFO extra data are genuinely incompatible and must + // not be treated as a match (the MS-RDPEA 2.2.2.1.1 `data` case). + let mut server = fmt(WaveFormat::AAC_MS, 44100); + server.data = Some(vec![0x11, 0x90]); + let mut client = fmt(WaveFormat::AAC_MS, 44100); + client.data = Some(vec![0x12, 0x08]); + + assert!(negotiate_formats(&[server], &[client]).is_empty()); +} + +// ============================================================================ +// `SvcProcessor` negotiation wiring (black-box, public surface only) +// ============================================================================ + +#[derive(Debug, Default)] +struct Recording { + choose_format_calls: usize, + start_calls: usize, + chosen_wformat: Option, +} + +#[derive(Debug)] +struct FakeHandler { + formats: Vec, + rec: Arc>, + start_ok: bool, +} + +impl RdpsndServerHandler for FakeHandler { + fn get_formats(&self) -> &[AudioFormat] { + &self.formats + } + + fn choose_format<'a>(&mut self, common: &'a [NegotiatedFormat]) -> Option<&'a NegotiatedFormat> { + let mut rec = self.rec.lock().expect("poisoned"); + rec.choose_format_calls += 1; + let chosen = common.first(); + rec.chosen_wformat = chosen.map(NegotiatedFormat::wformat_no); + chosen + } + + fn start(&mut self, _format: &NegotiatedFormat) -> Result<(), Box> { + self.rec.lock().expect("poisoned").start_calls += 1; + if self.start_ok { + Ok(()) + } else { + Err(Box::new(std::io::Error::other("simulated init failure"))) + } + } + + fn stop(&mut self) {} +} + +/// Drive a fresh server through the handshake (server announce → client formats +/// → training confirm) so the negotiation (`choose_format` + `start`) runs. +/// Client version is V5 (< V6) to skip the optional Quality Mode step. +fn drive_to_ready(server: &mut RdpsndServer, client_formats: Vec) { + server.start().expect("server announce"); + + let client_af = ClientAudioOutputPdu::AudioFormat(ClientAudioFormatPdu { + version: Version::V5, + flags: AudioFormatFlags::empty(), + formats: client_formats, + volume_left: 0, + volume_right: 0, + pitch: 0, + dgram_port: 0, + }); + server + .process(&encode_vec(&client_af).expect("encode client formats")) + .expect("process client formats"); + + let confirm = ClientAudioOutputPdu::TrainingConfirm(TrainingConfirmPdu { + timestamp: 0, + pack_size: 0, + }); + server + .process(&encode_vec(&confirm).expect("encode training confirm")) + .expect("process training confirm"); +} + +#[test] +fn processor_skips_choose_format_when_nothing_in_common() { + let rec = Arc::new(Mutex::new(Recording::default())); + let mut server = RdpsndServer::new(Box::new(FakeHandler { + formats: vec![fmt(WaveFormat::PCM, 44100)], + rec: Arc::clone(&rec), + start_ok: true, + })); + + // Server offers only PCM; client offers only AAC → no common format. + drive_to_ready(&mut server, vec![fmt(WaveFormat::AAC_MS, 44100)]); + + { + let rec = rec.lock().expect("poisoned"); + assert_eq!( + rec.choose_format_calls, 0, + "choose_format must be skipped when common is empty" + ); + assert_eq!(rec.start_calls, 0); + } + // Nothing negotiated → no format committed. + assert!(server.wave(vec![0; 4], 0).is_err()); +} + +#[test] +fn processor_calls_start_once_and_streams_on_success() { + let rec = Arc::new(Mutex::new(Recording::default())); + let mut server = RdpsndServer::new(Box::new(FakeHandler { + formats: vec![fmt(WaveFormat::PCM, 44100)], + rec: Arc::clone(&rec), + start_ok: true, + })); + + drive_to_ready(&mut server, vec![fmt(WaveFormat::PCM, 44100)]); + + { + let rec = rec.lock().expect("poisoned"); + assert_eq!(rec.choose_format_calls, 1); + assert_eq!(rec.start_calls, 1, "start must be called exactly once"); + assert_eq!(rec.chosen_wformat, Some(0)); // PCM is the client's only entry + } + // Format committed → waves stream. + assert!(server.wave(vec![0; 4], 0).is_ok()); +} + +#[test] +fn processor_declines_when_start_fails() { + let rec = Arc::new(Mutex::new(Recording::default())); + let mut server = RdpsndServer::new(Box::new(FakeHandler { + formats: vec![fmt(WaveFormat::PCM, 44100)], + rec: Arc::clone(&rec), + start_ok: false, // simulate an encoder/init failure + })); + + drive_to_ready(&mut server, vec![fmt(WaveFormat::PCM, 44100)]); + + assert_eq!(rec.lock().expect("poisoned").start_calls, 1); + // `start` returned Err → the crate rolls `format_no` back to None and + // declines, so no audio is streamed — rather than a silent + // "negotiated, no audio" state with a committed format and no producer. + assert!(server.wave(vec![0; 4], 0).is_err()); +} diff --git a/crates/ironrdp-testsuite-core/tests/server/acceptor.rs b/crates/ironrdp-testsuite-core/tests/server/acceptor.rs new file mode 100644 index 0000000000..30702297ec --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/server/acceptor.rs @@ -0,0 +1,189 @@ +use ironrdp_acceptor::Acceptor; +use ironrdp_connector::{DesktopSize, Sequence as _, Written, encode_x224_packet}; +use ironrdp_core::{WriteBuf, decode}; +use ironrdp_pdu::gcc::ClientMessageChannelData; +use ironrdp_pdu::mcs::{self, ConnectInitial}; +use ironrdp_pdu::nego::{self, SecurityProtocol}; +use ironrdp_pdu::x224::{X224, X224Data}; +use ironrdp_testsuite_core::gcc::CLIENT_GCC_WITHOUT_OPTIONAL_FIELDS; + +/// Build a minimal ConnectionRequest with the given protocols and encode it. +fn encode_connection_request(protocol: SecurityProtocol) -> Vec { + let request = nego::ConnectionRequest { + nego_data: None, + flags: nego::RequestFlags::empty(), + protocol, + }; + let mut buf = WriteBuf::new(); + ironrdp_core::encode_buf(&X224(request), &mut buf).unwrap(); + buf.filled().to_vec() +} + +/// When server requires TLS but client only offers HYBRID|HYBRID_EX, +/// the acceptor must write an RDP_NEG_FAILURE PDU and return an error. +#[test] +fn neg_failure_on_protocol_mismatch() { + let mut acceptor = Acceptor::new( + SecurityProtocol::SSL, + DesktopSize { + width: 1920, + height: 1080, + }, + Vec::new(), + None, + ); + + // Step 1: feed the connection request (HYBRID | HYBRID_EX, no SSL) + let request_bytes = encode_connection_request(SecurityProtocol::HYBRID | SecurityProtocol::HYBRID_EX); + let mut output = WriteBuf::new(); + let written = acceptor.step(&request_bytes, &mut output).unwrap(); + assert!(matches!(written, Written::Nothing)); + + // Step 2: acceptor tries to send confirm, finds no common protocol + let mut output = WriteBuf::new(); + let result = acceptor.step(&[], &mut output); + + // Must be an error + assert!(result.is_err(), "expected error on protocol mismatch"); + + // Must have written an RDP_NEG_FAILURE PDU to the output buffer + let response_bytes = output.filled(); + assert!(!response_bytes.is_empty(), "expected RDP_NEG_FAILURE PDU in output"); + + // Decode the response and verify it's a Failure with the right code + let confirm = decode::>(response_bytes).unwrap().0; + match confirm { + nego::ConnectionConfirm::Failure { code } => { + assert_eq!(code, nego::FailureCode::SSL_REQUIRED_BY_SERVER); + } + nego::ConnectionConfirm::Response { .. } => { + panic!("expected Failure, got Response"); + } + } +} + +/// When server and client agree on SSL, negotiation succeeds normally. +#[test] +fn neg_success_when_protocols_match() { + let mut acceptor = Acceptor::new( + SecurityProtocol::SSL, + DesktopSize { + width: 1920, + height: 1080, + }, + Vec::new(), + None, + ); + + let request_bytes = encode_connection_request(SecurityProtocol::SSL | SecurityProtocol::HYBRID); + let mut output = WriteBuf::new(); + acceptor.step(&request_bytes, &mut output).unwrap(); + + let mut output = WriteBuf::new(); + let written = acceptor.step(&[], &mut output).unwrap(); + assert!(!matches!(written, Written::Nothing)); + + let response_bytes = output.filled(); + let confirm = decode::>(response_bytes).unwrap().0; + match confirm { + nego::ConnectionConfirm::Response { protocol, flags } => { + assert_eq!(protocol, SecurityProtocol::SSL); + // The acceptor advertises support for Extended Client Data Blocks so the + // client sends its Client Message Channel Data, enabling the message + // channel to be negotiated. + assert!(flags.contains(nego::ResponseFlags::EXTENDED_CLIENT_DATA_SUPPORTED)); + } + nego::ConnectionConfirm::Failure { .. } => { + panic!("expected Response, got Failure"); + } + } +} + +/// When the client advertises the message channel (Client Message Channel Data), +/// the acceptor allocates an MCS channel ID for it and returns it in Server +/// Message Channel Data. The ID is allocated after the I/O channel and any +/// static virtual channels, so the expected value is derived from the server's +/// network block rather than hard-coded. +#[test] +fn message_channel_advertised_when_client_requests_it() { + let mut acceptor = Acceptor::new( + SecurityProtocol::SSL, + DesktopSize { + width: 1920, + height: 1080, + }, + Vec::new(), + None, + ); + + // Connection request -> confirm -> (TLS upgrade) -> ready for ConnectInitial. + let request_bytes = encode_connection_request(SecurityProtocol::SSL); + acceptor.step(&request_bytes, &mut WriteBuf::new()).unwrap(); + acceptor.step(&[], &mut WriteBuf::new()).unwrap(); + acceptor.mark_security_upgrade_as_done(); + + // Client GCC with the message channel block and no network channels, so the + // allocated ID is deterministic. + let mut blocks = CLIENT_GCC_WITHOUT_OPTIONAL_FIELDS.clone(); + blocks.network = None; + blocks.message_channel = Some(ClientMessageChannelData); + let connect_initial = ConnectInitial::with_gcc_blocks(blocks).unwrap(); + let mut initial_buf = WriteBuf::new(); + encode_x224_packet(&connect_initial, &mut initial_buf).unwrap(); + + acceptor.step(initial_buf.filled(), &mut WriteBuf::new()).unwrap(); + + let mut output = WriteBuf::new(); + acceptor.step(&[], &mut output).unwrap(); + + let payload = decode::>>(output.filled()).unwrap().0; + let response = decode::(payload.data.as_ref()).unwrap(); + let server_blocks = response.conference_create_response.gcc_blocks(); + + let message_channel = server_blocks + .message_channel + .as_ref() + .expect("acceptor must advertise Server Message Channel Data"); + + // The message channel is allocated after the I/O channel and any static + // virtual channels, so derive the expected ID from the server's network + // block instead of coupling the assertion to the I/O channel base. + let network = &server_blocks.network; + let channel_count = u16::try_from(network.channel_ids.len()).expect("channel count fits in u16"); + let expected = network.io_channel + channel_count + 1; + assert_eq!(message_channel.mcs_message_channel_id, expected); +} + +/// When server requires HYBRID but client only offers SSL, the failure code +/// should be HYBRID_REQUIRED_BY_SERVER. +#[test] +fn neg_failure_hybrid_required() { + let mut acceptor = Acceptor::new( + SecurityProtocol::HYBRID | SecurityProtocol::HYBRID_EX, + DesktopSize { + width: 1920, + height: 1080, + }, + Vec::new(), + None, + ); + + let request_bytes = encode_connection_request(SecurityProtocol::SSL); + let mut output = WriteBuf::new(); + acceptor.step(&request_bytes, &mut output).unwrap(); + + let mut output = WriteBuf::new(); + let result = acceptor.step(&[], &mut output); + assert!(result.is_err()); + + let response_bytes = output.filled(); + let confirm = decode::>(response_bytes).unwrap().0; + match confirm { + nego::ConnectionConfirm::Failure { code } => { + assert_eq!(code, nego::FailureCode::HYBRID_REQUIRED_BY_SERVER); + } + nego::ConnectionConfirm::Response { .. } => { + panic!("expected Failure, got Response"); + } + } +} diff --git a/crates/ironrdp-testsuite-core/tests/server/autodetect.rs b/crates/ironrdp-testsuite-core/tests/server/autodetect.rs new file mode 100644 index 0000000000..a104040e82 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/server/autodetect.rs @@ -0,0 +1,130 @@ +use ironrdp_pdu::rdp::autodetect::AutoDetectResponse; +use ironrdp_server::autodetect::AutoDetectManager; + +#[test] +fn rtt_request_increments_sequence() { + let mut mgr = AutoDetectManager::new(); + let req1 = mgr.send_rtt_request(); + let req2 = mgr.send_rtt_request(); + assert_eq!(req1.sequence_number(), 0); + assert_eq!(req2.sequence_number(), 1); + assert_eq!(mgr.pending_count(), 2); +} + +#[test] +fn rtt_response_returns_latency() { + let mut mgr = AutoDetectManager::new(); + let req = mgr.send_rtt_request(); + + let response = AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }; + let rtt = mgr.handle_response(&response); + assert!(rtt.is_some(), "should match the outstanding probe"); + assert_eq!(mgr.pending_count(), 0); +} + +#[test] +fn unknown_sequence_returns_none() { + let mut mgr = AutoDetectManager::new(); + let _ = mgr.send_rtt_request(); + + let response = AutoDetectResponse::RttResponse { sequence_number: 999 }; + assert!(mgr.handle_response(&response).is_none()); + assert_eq!(mgr.pending_count(), 1, "original probe should remain"); +} + +#[test] +fn snapshot_none_without_measurements() { + let mgr = AutoDetectManager::new(); + assert!(mgr.snapshot().is_none()); +} + +#[test] +fn snapshot_reflects_measurements() { + let mut mgr = AutoDetectManager::new(); + + for _ in 0..3 { + let req = mgr.send_rtt_request(); + let response = AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }; + let _ = mgr.handle_response(&response); + } + + let snap = mgr.snapshot().expect("should have data"); + assert_eq!(snap.sample_count, 3); + // RTT should be ~0ms (same-process send/receive) + assert!(snap.avg_ms < 100); +} + +#[test] +fn sequence_number_wraps_at_u16_max() { + let mut mgr = AutoDetectManager::new(); + // Advance sequence counter through all values, resolving each probe immediately + // to avoid growing pending_probes to 65k entries. + for _ in 0..u16::MAX { + let req = mgr.send_rtt_request(); + let response = AutoDetectResponse::RttResponse { + sequence_number: req.sequence_number(), + }; + let _ = mgr.handle_response(&response); + } + let req = mgr.send_rtt_request(); + assert_eq!(req.sequence_number(), u16::MAX); + + let req2 = mgr.send_rtt_request(); + assert_eq!(req2.sequence_number(), 0, "should wrap around"); +} + +#[test] +fn autodetect_rtt_handle_defaults_to_sentinel() { + use core::net::{Ipv4Addr, SocketAddr}; + use core::sync::atomic::Ordering; + + use ironrdp_server::RdpServer; + + let server = RdpServer::builder() + .with_addr(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) + .with_no_security() + .with_no_input() + .with_no_display() + .build(); + + assert_eq!(server.autodetect_rtt_handle().load(Ordering::Relaxed), u32::MAX); +} + +#[test] +fn with_autodetect_rtt_handle_round_trips_the_same_arc() { + use core::net::{Ipv4Addr, SocketAddr}; + use core::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + + use ironrdp_server::RdpServer; + + let handle = Arc::new(AtomicU32::new(42)); + let server = RdpServer::builder() + .with_addr(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) + .with_no_security() + .with_no_input() + .with_no_display() + .with_autodetect_rtt_handle(Arc::clone(&handle)) + .build(); + + assert!(Arc::ptr_eq(&handle, &server.autodetect_rtt_handle())); + // The server resets an injected handle to the sentinel at construction. + assert_eq!(server.autodetect_rtt_handle().load(Ordering::Relaxed), u32::MAX); + // The Arc is shared: mutating the original is visible through the server's handle. + handle.store(42, Ordering::Relaxed); + assert_eq!(server.autodetect_rtt_handle().load(Ordering::Relaxed), 42); +} + +#[test] +fn stale_probe_expiry() { + let mut mgr = AutoDetectManager::new(); + let _ = mgr.send_rtt_request(); + assert_eq!(mgr.pending_count(), 1); + + mgr.expire_stale_probes(core::time::Duration::ZERO); + assert_eq!(mgr.pending_count(), 0); +} diff --git a/crates/ironrdp-testsuite-core/tests/server/credential_validator.rs b/crates/ironrdp-testsuite-core/tests/server/credential_validator.rs new file mode 100644 index 0000000000..4b4dd280a3 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/server/credential_validator.rs @@ -0,0 +1,74 @@ +use core::fmt; +use std::sync::Arc; + +use async_trait::async_trait; +use ironrdp_server::{CredentialDecision, CredentialValidationError, CredentialValidator, Credentials}; + +fn fixed_creds() -> Credentials { + Credentials { + username: "alice".to_owned(), + password: "hunter2".to_owned(), + domain: None, + } +} + +struct AlwaysAccept; +#[async_trait] +impl CredentialValidator for AlwaysAccept { + async fn validate(&self, _: &Credentials) -> Result { + Ok(CredentialDecision::Accept) + } +} + +struct AlwaysReject; +#[async_trait] +impl CredentialValidator for AlwaysReject { + async fn validate(&self, _: &Credentials) -> Result { + Ok(CredentialDecision::Reject) + } +} + +#[derive(Debug)] +struct BackendDown; +impl fmt::Display for BackendDown { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ldap server unreachable") + } +} +impl core::error::Error for BackendDown {} + +struct AlwaysBackendError; +#[async_trait] +impl CredentialValidator for AlwaysBackendError { + async fn validate(&self, _: &Credentials) -> Result { + Err(CredentialValidationError::new(BackendDown)) + } +} + +#[tokio::test] +async fn validator_accept_returns_accept() { + let v = AlwaysAccept; + assert_eq!(v.validate(&fixed_creds()).await.unwrap(), CredentialDecision::Accept); +} + +#[tokio::test] +async fn validator_reject_returns_reject() { + let v = AlwaysReject; + assert_eq!(v.validate(&fixed_creds()).await.unwrap(), CredentialDecision::Reject); +} + +#[tokio::test] +async fn validator_backend_error_propagates_source() { + let v = AlwaysBackendError; + let err = v.validate(&fixed_creds()).await.expect_err("expected backend error"); + assert_eq!(err.to_string(), "credential validator backend failure"); + let inner = core::error::Error::source(&err).expect("source must be Some"); + assert_eq!(inner.to_string(), "ldap server unreachable"); +} + +#[tokio::test] +async fn validator_can_be_held_behind_arc_dyn() { + // Exercises the Send + Sync + 'static bounds the trait promises through Arc. + let v: Arc = Arc::new(AlwaysAccept); + assert_eq!(v.validate(&fixed_creds()).await.unwrap(), CredentialDecision::Accept); +} diff --git a/crates/ironrdp-testsuite-core/tests/server/fast_path.rs b/crates/ironrdp-testsuite-core/tests/server/fast_path.rs deleted file mode 120000 index f7df243457..0000000000 --- a/crates/ironrdp-testsuite-core/tests/server/fast_path.rs +++ /dev/null @@ -1 +0,0 @@ -../../../ironrdp-server/src/encoder/fast_path.rs \ No newline at end of file diff --git a/crates/ironrdp-testsuite-core/tests/server/fast_path.rs b/crates/ironrdp-testsuite-core/tests/server/fast_path.rs new file mode 100644 index 0000000000..9ec5ae9a3d --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/server/fast_path.rs @@ -0,0 +1 @@ +include!("../../../ironrdp-server/src/encoder/fast_path.rs"); diff --git a/crates/ironrdp-testsuite-core/tests/server/mod.rs b/crates/ironrdp-testsuite-core/tests/server/mod.rs index 6e563040a5..7706b1c82b 100644 --- a/crates/ironrdp-testsuite-core/tests/server/mod.rs +++ b/crates/ironrdp-testsuite-core/tests/server/mod.rs @@ -1 +1,4 @@ +mod acceptor; +mod autodetect; +mod credential_validator; mod fast_path; diff --git a/crates/ironrdp-testsuite-core/tests/session/autodetect.rs b/crates/ironrdp-testsuite-core/tests/session/autodetect.rs new file mode 100644 index 0000000000..3bc8eac615 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/session/autodetect.rs @@ -0,0 +1,139 @@ +use std::borrow::Cow; + +use ironrdp_core::encode_vec; +use ironrdp_pdu::mcs::{McsMessage, SendDataIndication}; +use ironrdp_pdu::rdp::autodetect::{AutoDetectReqPdu, AutoDetectRequest, AutoDetectResponse, AutoDetectRspPdu}; +use ironrdp_pdu::x224::X224; +use ironrdp_session::x224::Processor; +use ironrdp_svc::StaticChannelSet; + +const USER_CHANNEL_ID: u16 = 1002; +const IO_CHANNEL_ID: u16 = 1003; +const MESSAGE_CHANNEL_ID: u16 = 1004; +const SHARE_ID: u32 = 0x0001_0000; + +fn make_processor() -> Processor { + Processor::new( + StaticChannelSet::new(), + USER_CHANNEL_ID, + IO_CHANNEL_ID, + Some(MESSAGE_CHANNEL_ID), + SHARE_ID, + ) +} + +/// Encode an Auto-Detect Request as a server-to-client SendDataIndication on the +/// MCS message channel ([MS-RDPBCGR] 2.2.14.3): the auto-detect data is framed by +/// a Basic Security Header (SEC_AUTODETECT_REQ), not a Share Data header. +fn encode_server_autodetect(request: AutoDetectRequest) -> Vec { + let pdu = AutoDetectReqPdu::new(request); + let user_data = encode_vec(&pdu).unwrap(); + + let indication = McsMessage::SendDataIndication(SendDataIndication { + initiator_id: USER_CHANNEL_ID, + channel_id: MESSAGE_CHANNEL_ID, + user_data: Cow::Owned(user_data), + }); + + encode_vec(&X224(indication)).unwrap() +} + +#[test] +fn rtt_request_produces_response_frame() { + let mut processor = make_processor(); + let request = AutoDetectRequest::rtt_continuous(42); + let frame = encode_server_autodetect(request); + + let outputs = processor.process(&frame).unwrap(); + + assert_eq!(outputs.len(), 1); + match &outputs[0] { + ironrdp_session::x224::ProcessorOutput::ResponseFrame(data) => { + assert!(!data.is_empty(), "response frame must not be empty"); + } + other => panic!("expected ResponseFrame, got {other:?}"), + } +} + +#[test] +fn rtt_response_preserves_sequence_number() { + let mut processor = make_processor(); + let sequence_number = 0x1234; + let request = AutoDetectRequest::rtt_connect_time(sequence_number); + let frame = encode_server_autodetect(request); + + let outputs = processor.process(&frame).unwrap(); + + assert_eq!(outputs.len(), 1); + let ironrdp_session::x224::ProcessorOutput::ResponseFrame(response_data) = &outputs[0] else { + panic!("expected ResponseFrame"); + }; + + // The response is a Client Auto-Detect Response PDU on the message channel: + // X224 > MCS SendDataRequest > BasicSecurityHeader(SEC_AUTODETECT_RSP) > data. + let mcs_msg = ironrdp_core::decode::>>(response_data).unwrap(); + let McsMessage::SendDataRequest(send_data) = mcs_msg.0 else { + panic!("expected SendDataRequest in response frame"); + }; + assert_eq!( + send_data.channel_id, MESSAGE_CHANNEL_ID, + "response must be sent on the message channel" + ); + + let response = ironrdp_core::decode::(&send_data.user_data).unwrap(); + match response.response { + AutoDetectResponse::RttResponse { + sequence_number: rsp_seq, + } => { + assert_eq!(rsp_seq, sequence_number, "sequence number must be echoed"); + } + other => panic!("expected RttResponse, got {other:?}"), + } +} + +#[test] +fn network_characteristics_result_surfaces_as_autodetect() { + let mut processor = make_processor(); + let request = AutoDetectRequest::netchar_result(7, 10, 50000, 20); + let frame = encode_server_autodetect(request.clone()); + + let outputs = processor.process(&frame).unwrap(); + + assert_eq!(outputs.len(), 1); + match &outputs[0] { + ironrdp_session::x224::ProcessorOutput::AutoDetect(req) => { + assert_eq!(req, &request, "surfaced request must match the original"); + } + other => panic!("expected AutoDetect output, got {other:?}"), + } +} + +#[test] +fn bandwidth_measure_start_does_not_crash() { + let mut processor = make_processor(); + let request = AutoDetectRequest::bw_start_connect_time(100); + let frame = encode_server_autodetect(request); + + let outputs = processor.process(&frame).unwrap(); + assert!(outputs.is_empty(), "BW start should produce no output"); +} + +#[test] +fn bandwidth_measure_stop_does_not_crash() { + let mut processor = make_processor(); + let request = AutoDetectRequest::bw_stop_continuous(200); + let frame = encode_server_autodetect(request); + + let outputs = processor.process(&frame).unwrap(); + assert!(outputs.is_empty(), "BW stop should produce no output"); +} + +#[test] +fn bandwidth_measure_payload_does_not_crash() { + let mut processor = make_processor(); + let request = AutoDetectRequest::bw_payload(300, vec![0xAA; 64]); + let frame = encode_server_autodetect(request); + + let outputs = processor.process(&frame).unwrap(); + assert!(outputs.is_empty(), "BW payload should produce no output"); +} diff --git a/crates/ironrdp-testsuite-core/tests/session/connection_activation.rs b/crates/ironrdp-testsuite-core/tests/session/connection_activation.rs new file mode 100644 index 0000000000..c7ccbf4f50 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/session/connection_activation.rs @@ -0,0 +1,142 @@ +use std::borrow::Cow; + +use ironrdp_connector::connection_activation::{ConnectionActivationSequence, ConnectionActivationState}; +use ironrdp_connector::{ClientConnector, ClientConnectorState, Credentials, DesktopSize, Sequence as _, Written}; +use ironrdp_core::{WriteBuf, encode_vec}; +use ironrdp_pdu::gcc; +use ironrdp_pdu::mcs::{McsMessage, SendDataIndication}; +use ironrdp_pdu::rdp::capability_sets::MajorPlatformType; +use ironrdp_pdu::rdp::headers::{ServerDeactivateAll, ShareControlHeader, ShareControlPdu}; +use ironrdp_pdu::x224::X224; + +use ironrdp_testsuite_core::capsets::SERVER_DEMAND_ACTIVE; + +const USER_CHANNEL_ID: u16 = 1002; +const IO_CHANNEL_ID: u16 = 1003; +const SHARE_ID: u32 = 0x0001_0000; + +fn test_config() -> ironrdp_connector::Config { + ironrdp_connector::Config { + desktop_size: DesktopSize { + width: 1024, + height: 768, + }, + desktop_scale_factor: 0, + enable_tls: true, + enable_credssp: false, + credentials: Credentials::UsernamePassword { + username: "test".into(), + password: "test".into(), + }, + domain: None, + client_build: 0, + client_name: "test".into(), + keyboard_type: gcc::KeyboardType::IbmEnhanced, + keyboard_subtype: 0, + keyboard_layout: 0, + keyboard_functional_keys_count: 12, + ime_file_name: String::new(), + bitmap: None, + dig_product_id: String::new(), + client_dir: String::new(), + platform: MajorPlatformType::UNIX, + hardware_id: None, + request_data: None, + autologon: false, + enable_audio_playback: false, + license_cache: None, + compression_type: None, + enable_server_pointer: false, + pointer_software_rendering: false, + multitransport_flags: None, + performance_flags: Default::default(), + timezone_info: Default::default(), + alternate_shell: String::new(), + work_dir: String::new(), + } +} + +/// Encode a ShareControlPdu as a server-to-client SendDataIndication frame. +fn encode_server_share_control(pdu: ShareControlPdu) -> Vec { + let share_control_header = ShareControlHeader { + share_control_pdu: pdu, + pdu_source: USER_CHANNEL_ID, + share_id: SHARE_ID, + }; + + let user_data = encode_vec(&share_control_header).unwrap(); + + let indication = McsMessage::SendDataIndication(SendDataIndication { + initiator_id: USER_CHANNEL_ID, + channel_id: IO_CHANNEL_ID, + user_data: Cow::Owned(user_data), + }); + + encode_vec(&X224(indication)).unwrap() +} + +#[test] +fn deactivate_all_during_capabilities_exchange_stays_in_same_state() { + let config = test_config(); + let mut seq = ConnectionActivationSequence::new(config, IO_CHANNEL_ID, USER_CHANNEL_ID); + + let frame = encode_server_share_control(ShareControlPdu::ServerDeactivateAll(ServerDeactivateAll)); + let mut output = WriteBuf::new(); + + let written = seq.step(&frame, &mut output).unwrap(); + + assert_eq!(written, Written::Nothing); + assert!( + matches!( + seq.connection_activation_state(), + ConnectionActivationState::CapabilitiesExchange + ), + "state should remain CapabilitiesExchange after DeactivateAll" + ); +} + +#[test] +fn client_connector_stays_in_capabilities_exchange_on_deactivate_all() { + let config = test_config(); + let mut connector = ClientConnector::new(config.clone(), "127.0.0.1:3389".parse().unwrap()); + connector.state = ClientConnectorState::CapabilitiesExchange { + connection_activation: ConnectionActivationSequence::new(config, IO_CHANNEL_ID, USER_CHANNEL_ID), + }; + + let frame = encode_server_share_control(ShareControlPdu::ServerDeactivateAll(ServerDeactivateAll)); + let mut output = WriteBuf::new(); + + let written = connector.step(&frame, &mut output).unwrap(); + + assert_eq!(written, Written::Nothing); + assert!( + matches!(connector.state, ClientConnectorState::CapabilitiesExchange { .. }), + "outer connector state should remain CapabilitiesExchange after DeactivateAll" + ); +} + +#[test] +fn demand_active_after_deactivate_all_transitions_to_connection_finalization() { + let config = test_config(); + let mut seq = ConnectionActivationSequence::new(config, IO_CHANNEL_ID, USER_CHANNEL_ID); + let mut output = WriteBuf::new(); + + // First: feed DeactivateAll + let deactivate_frame = encode_server_share_control(ShareControlPdu::ServerDeactivateAll(ServerDeactivateAll)); + let written = seq.step(&deactivate_frame, &mut output).unwrap(); + assert_eq!(written, Written::Nothing); + + // Then: feed ServerDemandActive + let demand_active_frame = + encode_server_share_control(ShareControlPdu::ServerDemandActive(SERVER_DEMAND_ACTIVE.clone())); + let written = seq.step(&demand_active_frame, &mut output).unwrap(); + + assert!(written != Written::Nothing, "should have written ClientConfirmActive"); + assert!( + matches!( + seq.connection_activation_state(), + ConnectionActivationState::ConnectionFinalization { .. } + ), + "state should transition to ConnectionFinalization after DemandActive" + ); +} diff --git a/crates/ironrdp-testsuite-core/tests/session/mod.rs b/crates/ironrdp-testsuite-core/tests/session/mod.rs index dda2a99f14..e9126d9102 100644 --- a/crates/ironrdp-testsuite-core/tests/session/mod.rs +++ b/crates/ironrdp-testsuite-core/tests/session/mod.rs @@ -1,8 +1,10 @@ +mod autodetect; +mod connection_activation; mod rfx; #[cfg(test)] mod tests { - use ironrdp_pdu::rdp::capability_sets::{client_codecs_capabilities, CodecProperty}; + use ironrdp_pdu::rdp::capability_sets::{CodecProperty, client_codecs_capabilities}; #[test] fn test_codecs_capabilities() { @@ -14,23 +16,29 @@ mod tests { let config = &["remotefx:on"]; let capabilities = client_codecs_capabilities(config).unwrap(); - assert!(capabilities - .0 - .iter() - .any(|cap| matches!(cap.property, CodecProperty::RemoteFx(_)))); + assert!( + capabilities + .0 + .iter() + .any(|cap| matches!(cap.property, CodecProperty::RemoteFx(_))) + ); let config = &["remotefx:off"]; let capabilities = client_codecs_capabilities(config).unwrap(); - assert!(!capabilities - .0 - .iter() - .any(|cap| matches!(cap.property, CodecProperty::RemoteFx(_)))); + assert!( + !capabilities + .0 + .iter() + .any(|cap| matches!(cap.property, CodecProperty::RemoteFx(_))) + ); let config = &["qoi:on"]; let capabilities = client_codecs_capabilities(config).unwrap(); - assert!(capabilities - .0 - .iter() - .any(|cap| matches!(cap.property, CodecProperty::Qoi))); + assert!( + capabilities + .0 + .iter() + .any(|cap| matches!(cap.property, CodecProperty::Qoi)) + ); } } diff --git a/crates/ironrdp-testsuite-core/tests/session/rfx.rs b/crates/ironrdp-testsuite-core/tests/session/rfx.rs index c2e78032fd..a39fc862bf 100644 --- a/crates/ironrdp-testsuite-core/tests/session/rfx.rs +++ b/crates/ironrdp-testsuite-core/tests/session/rfx.rs @@ -1,6 +1,6 @@ use ironrdp_graphics::image_processing::PixelFormat; -use ironrdp_pdu::geometry::InclusiveRectangle; use ironrdp_pdu::ReadCursor; +use ironrdp_pdu::geometry::InclusiveRectangle; use ironrdp_session::image::DecodedImage; use ironrdp_session::rfx::DecodingContext; diff --git a/crates/ironrdp-testsuite-core/tests/str_types/convert.rs b/crates/ironrdp-testsuite-core/tests/str_types/convert.rs new file mode 100644 index 0000000000..94845e0de1 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/str_types/convert.rs @@ -0,0 +1,41 @@ +use ironrdp_str::{utf16_units_to_le_bytes, utf16le_bytes_to_units}; +use rstest::rstest; + +fn make_utf16le(s: &str) -> Vec { + s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect() +} + +#[rstest] +#[case("")] +#[case("hi")] +#[case("\u{1F600}")] +fn bytes_to_units(#[case] s: &str) { + let expected: Vec = s.encode_utf16().collect(); + assert_eq!(utf16le_bytes_to_units(&make_utf16le(s)).unwrap(), expected); +} + +#[rstest] +#[case("")] +#[case("hi")] +#[case("\u{1F600}")] +fn units_to_bytes(#[case] s: &str) { + let units: Vec = s.encode_utf16().collect(); + assert_eq!(utf16_units_to_le_bytes(&units).as_ref(), make_utf16le(s).as_slice()); +} + +#[test] +fn bytes_to_units_odd_length_returns_none() { + assert!(utf16le_bytes_to_units(&[0x41]).is_none()); +} + +// Property: bytes → units → bytes is a lossless round-trip for any valid UTF-8 string. +proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(50))] + #[test] + fn round_trip_prop(s in "\\PC{0,20}") { + let bytes = make_utf16le(&s); + let units = utf16le_bytes_to_units(&bytes).unwrap(); + let recovered = utf16_units_to_le_bytes(&units); + proptest::prop_assert_eq!(recovered.as_ref(), bytes.as_slice()); + } +} diff --git a/crates/ironrdp-testsuite-core/tests/str_types/fixed.rs b/crates/ironrdp-testsuite-core/tests/str_types/fixed.rs new file mode 100644 index 0000000000..5c853fa244 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/str_types/fixed.rs @@ -0,0 +1,162 @@ +use expect_test::expect; +use ironrdp_core::{DecodeOwned as _, ReadCursor, encode_vec}; +use ironrdp_str::fixed::{FixedString, FixedStringBytesError}; + +// Property: encode + decode is a lossless round-trip for any string fitting in the field. +// "\\PC{0,7}" = printable non-control Unicode char; 7 chars worst-case (all non-BMP) = 14 code units ≤ WCHAR_COUNT-1=15. +proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(50))] + #[test] + fn round_trip_prop(s in "\\PC{0,7}") { + let field = FixedString::<16>::new(s.clone()).unwrap(); + let encoded = encode_vec(&field).unwrap(); + proptest::prop_assert_eq!(encoded.len(), FixedString::<16>::WIRE_SIZE); + let decoded = FixedString::<16>::decode_owned(&mut ReadCursor::new(&encoded)).unwrap(); + let native = decoded.to_native().unwrap(); + proptest::prop_assert_eq!(native.as_ref(), s.as_str()); + } +} + +#[test] +fn round_trip_non_bmp() { + // U+1F600 GRINNING FACE encodes as surrogate pair D83D DE00 = 2 code units. + let original = "\u{1F600}"; + let s = FixedString::<4>::new(original).unwrap(); + let encoded = encode_vec(&s).unwrap(); + assert_eq!(encoded.len(), 8); // 4 * 2 + let decoded = FixedString::<4>::decode_owned(&mut ReadCursor::new(&encoded)).unwrap(); + assert_eq!(decoded.to_native().unwrap().as_ref(), original); +} + +#[test] +fn wire_size_is_constant() { + let empty = FixedString::<16>::new("").unwrap(); + let full = FixedString::<16>::new("a".repeat(15)).unwrap(); + use ironrdp_core::Encode as _; + assert_eq!(empty.size(), 32); + assert_eq!(full.size(), 32); +} + +#[test] +fn rejects_overlong_string() { + // WCHAR_COUNT=4 allows max 3 code units (slot 4 is for null). + let err = FixedString::<4>::new("abcd").unwrap_err(); + expect![[" + StringTooLong { + max_code_units: 3, + actual_code_units: 4, + } + "]] + .assert_debug_eq(&err); +} + +#[test] +fn decode_strips_padding() { + // Wire: [0x41, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + // = 'A' (U+0041) followed by three null code units. + let wire: &[u8] = &[0x41, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + let s = FixedString::<4>::decode_owned(&mut ReadCursor::new(wire)).unwrap(); + assert_eq!(s.to_native().unwrap().as_ref(), "A"); +} + +#[test] +fn decode_accepts_lone_surrogate_to_str_fails() { + // Wire: lone high surrogate D800 followed by padding. + // Decode succeeds (no eager validation); to_native() reports the error. + let wire: &[u8] = &[0x00, 0xD8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + let s = FixedString::<4>::decode_owned(&mut ReadCursor::new(wire)).unwrap(); + let err = s.to_native().unwrap_err(); + expect![[" + InvalidUtf16 + "]] + .assert_debug_eq(&err); + // to_native_lossy() succeeds and replaces lone surrogates with U+FFFD. + assert!(s.to_native_lossy().contains('\u{FFFD}')); +} + +#[test] +fn non_bmp_code_units_counted_correctly() { + // U+1F600 is 2 code units. In a WCHAR_COUNT=3 field, max content = 2 code units. + assert!(FixedString::<3>::new("\u{1F600}").is_ok()); + // Two emoji = 4 code units, exceeds max of 2 for WCHAR_COUNT=3. + let err = FixedString::<3>::new("\u{1F600}\u{1F600}").unwrap_err(); + expect![[" + StringTooLong { + max_code_units: 2, + actual_code_units: 4, + } + "]] + .assert_debug_eq(&err); +} + +// ── from_utf16le_bytes ──────────────────────────────────────────────────────── + +#[test] +fn from_utf16le_bytes_too_long_returns_err() { + // 4 code units for WCHAR_COUNT=4 means 4 content units, but max is 3. + let bytes: Vec = "abcd".encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + let err = FixedString::<4>::from_utf16le_bytes(&bytes).unwrap_err(); + expect![[" + StringTooLong( + StringTooLong { + max_code_units: 3, + actual_code_units: 4, + }, + ) + "]] + .assert_debug_eq(&err); + assert!(matches!(err, FixedStringBytesError::StringTooLong(_))); +} + +#[test] +fn from_utf16le_bytes_odd_length_returns_err() { + let err = FixedString::<4>::from_utf16le_bytes(&[0x41u8]).unwrap_err(); + expect![[" + OddByteCount + "]] + .assert_debug_eq(&err); + assert_eq!(err, FixedStringBytesError::OddByteCount); +} + +#[test] +fn from_utf16le_bytes_content_shorter_than_wire_size() { + // Accepts content bytes shorter than WIRE_SIZE (32 bytes for WCHAR_COUNT=16). + let bytes: Vec = "hi".encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + let s = FixedString::<16>::from_utf16le_bytes(&bytes).unwrap(); + assert_eq!(s.to_native().unwrap().as_ref(), "hi"); +} + +// ── from_wire_units / to_wire_units / into_wire_units ──────────────────────── + +#[test] +fn from_wire_units_round_trip() { + let units: Vec = "hello".encode_utf16().collect(); + let s = FixedString::<16>::from_wire_units(units.clone()).unwrap(); + assert_eq!(s.to_native().unwrap().as_ref(), "hello"); + assert_eq!(s.to_wire_units().as_ref(), units.as_slice()); +} + +#[test] +fn from_wire_units_strips_trailing_nulls() { + let mut units: Vec = "hi".encode_utf16().collect(); + units.push(0); // trailing null + let s = FixedString::<8>::from_wire_units(units).unwrap(); + assert_eq!(s.to_native().unwrap().as_ref(), "hi"); +} + +#[test] +fn into_wire_units_from_decode() { + // Decoded from wire bytes via utf16le_bytes_to_units — into_wire_units is zero-cost. + let wire: &[u8] = &[0x41, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // 'A' + padding + let s = FixedString::<4>::decode_owned(&mut ReadCursor::new(wire)).unwrap(); + let units = s.into_wire_units(); + assert_eq!(units, &[0x0041u16]); +} + +#[test] +fn to_wire_units_from_native() { + let s = FixedString::<8>::new("abc").unwrap(); + let units = s.to_wire_units(); + let expected: Vec = "abc".encode_utf16().collect(); + assert_eq!(units.as_ref(), expected.as_slice()); +} diff --git a/crates/ironrdp-testsuite-core/tests/str_types/mod.rs b/crates/ironrdp-testsuite-core/tests/str_types/mod.rs new file mode 100644 index 0000000000..52fc5a9eb4 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/str_types/mod.rs @@ -0,0 +1,5 @@ +mod convert; +mod fixed; +mod multi_sz; +mod prefixed; +mod unframed; diff --git a/crates/ironrdp-testsuite-core/tests/str_types/multi_sz.rs b/crates/ironrdp-testsuite-core/tests/str_types/multi_sz.rs new file mode 100644 index 0000000000..e71c2378ef --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/str_types/multi_sz.rs @@ -0,0 +1,310 @@ +use expect_test::expect; +use ironrdp_core::{DecodeOwned as _, ReadCursor, encode_vec}; +use ironrdp_str::multi_sz::{MultiSzFlatError, MultiSzSegmentError, MultiSzString}; + +#[test] +fn empty_multi_sz() { + // An empty MULTI_SZ: cch=1, one final null. + let m = MultiSzString::new(core::iter::empty::()).unwrap(); + let encoded = encode_vec(&m).unwrap(); + // 4 bytes (u32 cch=1) + 2 bytes (final null) = 6 bytes + assert_eq!(encoded.len(), 6); + assert_eq!(u32::from_le_bytes([encoded[0], encoded[1], encoded[2], encoded[3]]), 1); +} + +// ── new rejects embedded nulls ──────────────────────────────────────────────── + +#[test] +fn new_rejects_embedded_null() { + assert!(MultiSzString::new(["ab\0c"]).is_err()); + assert!(MultiSzString::new(["ok", "bad\0"]).is_err()); +} + +// Property: new() → encode → decode gives back the original string list. +// Strings with embedded nulls are excluded: U+0000 is a segment delimiter in MULTI_SZ. +proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(50))] + #[test] + fn round_trip_prop(strings in proptest::collection::vec("[^\x00]{0,20}", 0..3usize)) { + let m = MultiSzString::new(strings.clone()).unwrap(); + let encoded = encode_vec(&m).unwrap(); + let decoded = MultiSzString::decode_owned(&mut ReadCursor::new(&encoded)).unwrap(); + let result: Vec = decoded.iter_native().map(|s| s.unwrap().into_owned()).collect(); + proptest::prop_assert_eq!(result, strings); + } +} + +#[test] +fn total_cch_counts_all_nulls() { + // ["ab", "c"] -> total_cch = (2+1) + (1+1) + 1 = 6 + let m = MultiSzString::new(["ab", "c"]).unwrap(); + assert_eq!(m.total_cch(), 6); +} + +// Property: size() == encoded byte length for any list of strings. +proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(50))] + #[test] + fn size_matches_encoded_length_prop(strings in proptest::collection::vec("[^\x00]{0,20}", 0..3usize)) { + use ironrdp_core::Encode as _; + let m = MultiSzString::new(strings).unwrap(); + proptest::prop_assert_eq!(m.size(), encode_vec(&m).unwrap().len()); + } +} + +#[test] +fn rejects_missing_segment_null_terminator() { + // Wire: cch=3, content = [o][f][sentinel] — the segment "of" has no per-string null before + // the sentinel. After stripping the sentinel the stored units are [o, f], which ends with a + // non-null unit. Without the new validation, iter_native / into_native would silently drop "of". + let wire: &[u8] = &[ + 0x03, 0x00, 0x00, 0x00, // u32 cch = 3 + 0x6F, 0x00, // U+006F 'o' + 0x66, 0x00, // U+0066 'f' (no per-string null terminator before the sentinel) + 0x00, 0x00, // final sentinel + ]; + let err = MultiSzString::decode_owned(&mut ReadCursor::new(wire)).unwrap_err(); + expect![[r#" + Error { + context: "::decode_owned", + kind: InvalidField { + field: "content", + reason: "MULTI_SZ last segment is missing its null terminator", + }, + source: None, + } + "#]] + .assert_debug_eq(&err); +} + +#[test] +fn rejects_zero_cch() { + let wire: &[u8] = &[0x00, 0x00, 0x00, 0x00]; // cch=0 + let err = MultiSzString::decode_owned(&mut ReadCursor::new(wire)).unwrap_err(); + expect![[r#" + Error { + context: "::decode_owned", + kind: InvalidField { + field: "cch", + reason: "zero cch for MULTI_SZ is invalid", + }, + source: None, + } + "#]] + .assert_debug_eq(&err); +} + +// ── from_utf16le_byte_strings ───────────────────────────────────────────────── + +#[test] +fn from_utf16le_byte_strings_round_trip() { + let byte_strings: Vec> = ["foo", "bar"] + .iter() + .map(|s| s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect()) + .collect(); + let m = MultiSzString::from_utf16le_byte_strings(byte_strings.iter().map(|v| v.as_slice())).unwrap(); + let strings: Vec = m.iter_native().map(|s| s.unwrap().into_owned()).collect(); + assert_eq!(strings, ["foo", "bar"]); +} + +#[test] +fn from_utf16le_byte_strings_odd_length_returns_err() { + let err = MultiSzString::from_utf16le_byte_strings([&[0x41u8][..]]).unwrap_err(); + expect![[" + OddByteCount + "]] + .assert_debug_eq(&err); + assert_eq!(err, MultiSzSegmentError::OddByteCount); +} + +#[test] +fn from_utf16le_byte_strings_rejects_embedded_null() { + // "a\0b" encoded as UTF-16LE: [0x61, 0x00, 0x00, 0x00, 0x62, 0x00] + let segment: Vec = [0x61u16, 0x0000, 0x62].iter().flat_map(|u| u.to_le_bytes()).collect(); + let err = MultiSzString::from_utf16le_byte_strings([segment.as_slice()]).unwrap_err(); + expect![[" + EmbeddedNul + "]] + .assert_debug_eq(&err); + assert_eq!(err, MultiSzSegmentError::EmbeddedNul); +} + +// ── from_utf16le_flat ───────────────────────────────────────────────────────── + +#[test] +fn from_utf16le_flat_round_trip() { + // Flat content for ["foo", "bar"]: "foo\0bar\0\0" in UTF-16LE. + let flat: Vec = "foo" + .encode_utf16() + .chain([0u16]) // per-string null + .chain("bar".encode_utf16()) + .chain([0u16]) // per-string null + .chain([0u16]) // sentinel + .flat_map(|u| u.to_le_bytes()) + .collect(); + let m = MultiSzString::from_utf16le_flat(&flat).unwrap(); + let strings: Vec = m.iter_native().map(|s| s.unwrap().into_owned()).collect(); + assert_eq!(strings, ["foo", "bar"]); +} + +#[test] +fn from_utf16le_flat_empty_list() { + // Minimal flat content: just the sentinel null. + let flat: &[u8] = &[0x00, 0x00]; + let m = MultiSzString::from_utf16le_flat(flat).unwrap(); + assert_eq!(m.iter_native().count(), 0); +} + +#[test] +fn from_utf16le_flat_odd_length_returns_err() { + let err = MultiSzString::from_utf16le_flat(&[0x00]).unwrap_err(); + expect![[" + OddByteCount + "]] + .assert_debug_eq(&err); + assert_eq!(err, MultiSzFlatError::OddByteCount); +} + +#[test] +fn from_utf16le_flat_missing_sentinel_returns_err() { + // 'A' in UTF-16LE with no trailing null — the buffer does not end with 0x0000. + let err = MultiSzString::from_utf16le_flat(&[0x41, 0x00]).unwrap_err(); + expect![[" + MissingSentinel + "]] + .assert_debug_eq(&err); + assert_eq!(err, MultiSzFlatError::MissingSentinel); +} + +#[test] +fn from_utf16le_flat_unterminated_last_segment_returns_err() { + // [f, o, o, 0x0000]: the 0x0000 is treated as the sentinel; after stripping it, + // the remaining ['f','o','o'] ends with 'o', not a per-string null terminator. + let unterminated: Vec = "foo" + .encode_utf16() + .chain([0u16]) // sentinel (no per-string null precedes it) + .flat_map(|u| u.to_le_bytes()) + .collect(); + let err = MultiSzString::from_utf16le_flat(&unterminated).unwrap_err(); + expect![[" + UnterminatedLastSegment + "]] + .assert_debug_eq(&err); + assert_eq!(err, MultiSzFlatError::UnterminatedLastSegment); +} + +// ── from_wire_units_flat ────────────────────────────────────────────────────── + +#[test] +fn from_wire_units_flat_round_trip() { + let flat: Vec = "foo" + .encode_utf16() + .chain([0u16]) + .chain("bar".encode_utf16()) + .chain([0u16]) + .chain([0u16]) // sentinel + .collect(); + let m = MultiSzString::from_wire_units_flat(flat).unwrap(); + let strings: Vec = m.iter_native().map(|s| s.unwrap().into_owned()).collect(); + assert_eq!(strings, ["foo", "bar"]); +} + +#[test] +fn from_wire_units_flat_empty_list() { + let m = MultiSzString::from_wire_units_flat(vec![0u16]).unwrap(); + assert_eq!(m.iter_native().count(), 0); +} + +#[test] +fn from_wire_units_flat_missing_sentinel_returns_err() { + // Just 'A' with no trailing null — the buffer does not end with 0x0000. + let err = MultiSzString::from_wire_units_flat(vec![0x0041u16]).unwrap_err(); + expect![[" + MissingSentinel + "]] + .assert_debug_eq(&err); + assert_eq!(err, MultiSzFlatError::MissingSentinel); +} + +#[test] +fn from_wire_units_flat_unterminated_last_segment_returns_err() { + // [f, o, o, 0x0000]: the 0x0000 is treated as the sentinel; after stripping it, + // the remaining ['f','o','o'] ends with 'o', not a per-string null terminator. + let unterminated: Vec = "foo".encode_utf16().chain([0u16]).collect(); + let err = MultiSzString::from_wire_units_flat(unterminated).unwrap_err(); + expect![[" + UnterminatedLastSegment + "]] + .assert_debug_eq(&err); + assert_eq!(err, MultiSzFlatError::UnterminatedLastSegment); +} + +// ── from_unit_strings ───────────────────────────────────────────────────────── + +#[test] +fn from_unit_strings_rejects_embedded_null() { + // Interior null (0x0066 'f', 0x0000, 0x006F 'o') — rejected. + let bad: Vec> = vec![vec![0x0066, 0x0000, 0x006F]]; // "f\0o" + assert!(MultiSzString::from_unit_strings(bad).is_err()); +} + +#[test] +fn from_unit_strings_strips_trailing_null() { + // A trailing null terminator is stripped; the string content is preserved. + let with_null: Vec = "hi".encode_utf16().chain([0u16]).collect(); + let m = MultiSzString::from_unit_strings([with_null]).unwrap(); + assert_eq!( + m.iter_native().map(|s| s.unwrap().into_owned()).collect::>(), + ["hi"] + ); +} + +#[test] +fn from_unit_strings_trailing_null_only_not_treated_as_interior() { + // A segment that is solely a null unit — stripped to empty string, not rejected. + let only_null: Vec = vec![0u16]; + let m = MultiSzString::from_unit_strings([only_null]).unwrap(); + assert_eq!( + m.iter_native().map(|s| s.unwrap().into_owned()).collect::>(), + [""] + ); +} + +#[test] +fn from_unit_strings_round_trip() { + let unit_strings: Vec> = ["foo", "bar"].iter().map(|s| s.encode_utf16().collect()).collect(); + let m = MultiSzString::from_unit_strings(unit_strings).unwrap(); + let strings: Vec = m.iter_native().map(|s| s.unwrap().into_owned()).collect(); + assert_eq!(strings, ["foo", "bar"]); +} + +#[test] +fn from_unit_strings_non_bmp() { + let units: Vec = "\u{1F600}".encode_utf16().collect(); + let m = MultiSzString::from_unit_strings([units]).unwrap(); + let strings: Vec = m.iter_native().map(|s| s.unwrap().into_owned()).collect(); + assert_eq!(strings, ["\u{1F600}"]); +} + +#[test] +fn strings_lossy_replaces_lone_surrogates() { + // Manually construct a MULTI_SZ with a lone high surrogate in one segment. + // cch=3: [D800 LE][0000][0000] = lone surrogate + null + sentinel + let wire: &[u8] = &[ + 0x03, 0x00, 0x00, 0x00, // u32 cch = 3 + 0x00, 0xD8, // lone high surrogate D800 (LE) + 0x00, 0x00, // null terminator + 0x00, 0x00, // final sentinel + ]; + let decoded = MultiSzString::decode_owned(&mut ReadCursor::new(wire)).unwrap(); + // iter_native() returns Err for the segment with lone surrogate + let err = decoded.iter_native().find_map(|r| r.err()).unwrap(); + expect![[" + InvalidUtf16 + "]] + .assert_debug_eq(&err); + // strings_lossy() replaces lone surrogate with U+FFFD + let lossy: Vec<_> = decoded.iter_native_lossy().collect(); + assert_eq!(lossy.len(), 1); + assert!(lossy[0].contains('\u{FFFD}')); +} diff --git a/crates/ironrdp-testsuite-core/tests/str_types/prefixed.rs b/crates/ironrdp-testsuite-core/tests/str_types/prefixed.rs new file mode 100644 index 0000000000..72c8fcd901 --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/str_types/prefixed.rs @@ -0,0 +1,284 @@ +use expect_test::expect; +use ironrdp_core::{DecodeOwned as _, ReadCursor, encode_vec}; +use ironrdp_str::prefixed::{ + CbStringNoNull, CbStringNullExcluded, CbStringNullIncluded, Cch32String, CchString, LengthPrefix, + NullTerminatorPolicy, PrefixedString, +}; + +fn encode_decode_roundtrip(s: &str) -> String +where + PrefixedString: ironrdp_core::Encode + ironrdp_core::DecodeOwned, +{ + let field = PrefixedString::::new(s.to_owned()); + let encoded = encode_vec(&field).unwrap(); + let decoded = PrefixedString::::decode_owned(&mut ReadCursor::new(&encoded)).unwrap(); + decoded.to_native().unwrap().into_owned() +} + +// ── Non-BMP correctness ─────────────────────────────────────────────────── + +#[test] +fn non_bmp_cch_null_counted() { + // U+1F600 = 2 code units. cchPCB should be 3 (2 + null). + let s = "\u{1F600}"; + let field = CchString::new(s.to_owned()); + let encoded = encode_vec(&field).unwrap(); + // Prefix = 3 (u16 LE) + 2 code units * 2 bytes + null * 2 bytes = 2 + 4 + 2 = 8 bytes + assert_eq!(encoded.len(), 8); + let prefix = u16::from_le_bytes([encoded[0], encoded[1]]); + assert_eq!(prefix, 3, "cch must include null; non-BMP counts as 2 code units"); + use ironrdp_str::prefixed::{CchU16, NullCounted}; + assert_eq!(encode_decode_roundtrip::(s), s); +} + +#[test] +fn non_bmp_cb_null_excluded() { + // U+1F600 = 2 code units = 4 bytes. cbDomain should be 4 (bytes, null excluded). + let s = "\u{1F600}"; + let field = CbStringNullExcluded::new(s.to_owned()); + let encoded = encode_vec(&field).unwrap(); + // Prefix = 4 (u16 LE) + 4 bytes content + null 2 bytes = 2 + 4 + 2 = 8 bytes + assert_eq!(encoded.len(), 8); + let prefix_bytes = u16::from_le_bytes([encoded[0], encoded[1]]); + assert_eq!(prefix_bytes, 4, "cb must not include null bytes"); + use ironrdp_str::prefixed::{CbU16, NullUncounted}; + assert_eq!(encode_decode_roundtrip::(s), s); +} + +// ── Round-trips for all null policy variants ────────────────────────────── + +#[test] +fn round_trip_cch_null_counted() { + use ironrdp_str::prefixed::{CchU16, NullCounted}; + assert_eq!(encode_decode_roundtrip::("hello"), "hello"); +} + +#[test] +fn round_trip_cch32_null_counted() { + use ironrdp_str::prefixed::{CchU32, NullCounted}; + assert_eq!(encode_decode_roundtrip::("hello"), "hello"); +} + +#[test] +fn round_trip_cb_null_excluded() { + use ironrdp_str::prefixed::{CbU16, NullUncounted}; + assert_eq!(encode_decode_roundtrip::("hello"), "hello"); +} + +#[test] +fn round_trip_cb_null_included() { + use ironrdp_str::prefixed::{CbU16, NullCounted}; + assert_eq!(encode_decode_roundtrip::("hello"), "hello"); +} + +#[test] +fn round_trip_cb_no_null() { + use ironrdp_str::prefixed::{CbU16, NoNull}; + assert_eq!(encode_decode_roundtrip::("hello"), "hello"); +} + +// ── Empty string edge cases ─────────────────────────────────────────────── + +#[test] +fn empty_string_null_uncounted() { + // cbDomain=0 with NullUncounted: prefix=0, then a null terminator on wire. + let field = CbStringNullExcluded::new(String::new()); + let encoded = encode_vec(&field).unwrap(); + assert_eq!(encoded.len(), 4); // 2-byte prefix (0) + 2-byte null + let decoded = CbStringNullExcluded::decode_owned(&mut ReadCursor::new(&encoded)).unwrap(); + assert_eq!(decoded.to_native().unwrap().as_ref(), ""); +} + +#[test] +fn empty_string_no_null() { + let field = CbStringNoNull::new(String::new()); + let encoded = encode_vec(&field).unwrap(); + assert_eq!(encoded.len(), 2); // 2-byte prefix (0), no null + let decoded = CbStringNoNull::decode_owned(&mut ReadCursor::new(&encoded)).unwrap(); + assert_eq!(decoded.to_native().unwrap().as_ref(), ""); +} + +// ── Empty string NullCounted round-trip ────────────────────────────────── + +#[test] +fn empty_string_null_counted() { + // An empty NullCounted string encodes as cch=1 (just the null). + let field = CchString::new(String::new()); + let encoded = encode_vec(&field).unwrap(); + // 2-byte prefix (1) + 2-byte null = 4 bytes + assert_eq!(encoded.len(), 4); + assert_eq!(u16::from_le_bytes([encoded[0], encoded[1]]), 1); + let decoded = CchString::decode_owned(&mut ReadCursor::new(&encoded)).unwrap(); + assert_eq!(decoded.to_native().unwrap().as_ref(), ""); +} + +// ── NullCounted cch=0 is invalid ───────────────────────────────────────── + +#[test] +fn rejects_null_counted_zero_cch() { + // cch=0 is invalid for NullCounted: the null is counted in the prefix, + // so the minimum valid prefix for any string (including empty) is 1. + let wire: &[u8] = &[0x00, 0x00]; // u16 cch=0 + let err = CchString::decode_owned(&mut ReadCursor::new(wire)).unwrap_err(); + expect![[r#" + Error { + context: " as ironrdp_core::decode::DecodeOwned>::decode_owned", + kind: InvalidField { + field: "length prefix", + reason: "NullCounted prefix of 0 is invalid; minimum is 1 (empty string with null)", + }, + source: None, + } + "#]] + .assert_debug_eq(&err); +} + +// ── Non-zero null terminator is rejected ───────────────────────────────── + +#[test] +fn rejects_nonzero_null_terminator_null_counted() { + // cch=2 → content_cch=1 → read 'A', then expect 0x0000 but find 'B'. + let wire: &[u8] = &[ + 0x02, 0x00, // u16 cch=2 (1 content unit + 1 null) + 0x41, 0x00, // U+0041 'A' + 0x42, 0x00, // U+0042 'B' — should be the null terminator + ]; + let err = CchString::decode_owned(&mut ReadCursor::new(wire)).unwrap_err(); + expect![[r#" + Error { + context: " as ironrdp_core::decode::DecodeOwned>::decode_owned", + kind: InvalidField { + field: "null terminator", + reason: "expected 0x0000 null terminator", + }, + source: None, + } + "#]] + .assert_debug_eq(&err); +} + +#[test] +fn rejects_nonzero_null_terminator_null_uncounted() { + // cb=2 (NullUncounted) → content_cch=1 → read 'A', then expect 0x0000 but find 'B'. + let wire: &[u8] = &[ + 0x02, 0x00, // u16 cb=2 (1 content unit, null not counted) + 0x41, 0x00, // U+0041 'A' + 0x42, 0x00, // U+0042 'B' — should be the null terminator + ]; + let err = CbStringNullExcluded::decode_owned(&mut ReadCursor::new(wire)).unwrap_err(); + expect![[r#" + Error { + context: " as ironrdp_core::decode::DecodeOwned>::decode_owned", + kind: InvalidField { + field: "null terminator", + reason: "expected 0x0000 null terminator", + }, + source: None, + } + "#]] + .assert_debug_eq(&err); +} + +// ── Rejection of odd byte counts ───────────────────────────────────────── + +#[test] +fn rejects_odd_byte_count() { + // cb = 3 (odd) is invalid for a UTF-16 string (structural, not UTF-16 validity). + let wire: &[u8] = &[0x03, 0x00, 0x41, 0x00, 0x00, 0x00]; // cb=3, 'A', null + let err = CbStringNullExcluded::decode_owned(&mut ReadCursor::new(wire)).unwrap_err(); + expect![[r#" + Error { + context: " as ironrdp_core::decode::DecodeOwned>::decode_owned", + kind: InvalidField { + field: "length prefix", + reason: "odd byte count for utf-16 string field", + }, + source: None, + } + "#]] + .assert_debug_eq(&err); +} + +// ── Lone surrogates: decode succeeds, to_native() fails ────────────────── + +#[test] +fn lone_surrogate_decode_succeeds_to_native_fails() { + // cb=2, lone high surrogate D800. Decode no longer validates; to_native() reports error. + let wire: &[u8] = &[0x02, 0x00, 0x00, 0xD8]; // cb=2, code unit 0xD800 + let decoded = CbStringNoNull::decode_owned(&mut ReadCursor::new(wire)).unwrap(); + let err = decoded.to_native().unwrap_err(); + expect![[" + InvalidUtf16 + "]] + .assert_debug_eq(&err); + assert!(decoded.to_native_lossy().contains('\u{FFFD}')); +} + +// ── from_wire_units / to_wire_units / into_wire_units ──────────────────────── + +#[test] +fn from_wire_units_round_trip() { + use ironrdp_str::prefixed::{CbU16, NullUncounted}; + let units: Vec = "hello".encode_utf16().collect(); + let field = PrefixedString::::from_wire_units(units.clone()); + assert_eq!(field.to_native().unwrap().as_ref(), "hello"); + assert_eq!(field.to_wire_units().as_ref(), units.as_slice()); +} + +#[test] +fn from_wire_units_strips_trailing_nulls() { + // Callers may pass units that include a trailing null from a wire buffer; + // from_wire_units must strip it so encoding does not emit an extra null. + use ironrdp_str::prefixed::{CbU16, NullUncounted}; + let mut units: Vec = "hi".encode_utf16().collect(); + units.push(0); // trailing null — should be stripped + let field = PrefixedString::::from_wire_units(units); + assert_eq!(field.to_native().unwrap().as_ref(), "hi"); + // to_wire_units must not include the null + assert_eq!( + field.to_wire_units().as_ref(), + "hi".encode_utf16().collect::>().as_slice() + ); +} + +#[test] +fn into_wire_units_from_decode() { + let field = CbStringNoNull::new("abc".to_owned()); + let encoded = encode_vec(&field).unwrap(); + let decoded = CbStringNoNull::decode_owned(&mut ReadCursor::new(&encoded)).unwrap(); + let units = decoded.into_wire_units(); + let expected: Vec = "abc".encode_utf16().collect(); + assert_eq!(units, expected); +} + +#[test] +fn to_wire_units_non_bmp() { + let field = CbStringNoNull::new("\u{1F600}".to_owned()); + let units = field.to_wire_units(); + assert_eq!(units.as_ref(), &[0xD83Du16, 0xDE00u16]); +} + +// ── size() matches encode() output length ──────────────────────────────── + +// Property: size() == encoded byte length for all five type variants, any string. +proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(50))] + #[test] + fn size_matches_encoded_length_prop(s in "\\PC{0,20}") { + use ironrdp_core::Encode as _; + let f1 = CchString::new(s.clone()); + proptest::prop_assert_eq!(f1.size(), encode_vec(&f1).unwrap().len()); + + let f2 = Cch32String::new(s.clone()); + proptest::prop_assert_eq!(f2.size(), encode_vec(&f2).unwrap().len()); + + let f3 = CbStringNullExcluded::new(s.clone()); + proptest::prop_assert_eq!(f3.size(), encode_vec(&f3).unwrap().len()); + + let f4 = CbStringNullIncluded::new(s.clone()); + proptest::prop_assert_eq!(f4.size(), encode_vec(&f4).unwrap().len()); + + let f5 = CbStringNoNull::new(s); + proptest::prop_assert_eq!(f5.size(), encode_vec(&f5).unwrap().len()); + } +} diff --git a/crates/ironrdp-testsuite-core/tests/str_types/unframed.rs b/crates/ironrdp-testsuite-core/tests/str_types/unframed.rs new file mode 100644 index 0000000000..274f4c4fbe --- /dev/null +++ b/crates/ironrdp-testsuite-core/tests/str_types/unframed.rs @@ -0,0 +1,128 @@ +use expect_test::expect; +use ironrdp_core::{ReadCursor, WriteCursor}; +use ironrdp_str::unframed::UnframedString; + +fn make_utf16le(s: &str) -> Vec { + s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect() +} + +#[test] +fn decode_by_wchar_count() { + let wire = make_utf16le("hello"); + let s = UnframedString::decode(&mut ReadCursor::new(&wire), 5).unwrap(); + assert_eq!(s.to_native().unwrap().as_ref(), "hello"); +} + +#[test] +fn decode_from_byte_len() { + let wire = make_utf16le("hi"); + let s = UnframedString::decode_from_byte_len(&mut ReadCursor::new(&wire), 4).unwrap(); + assert_eq!(s.to_native().unwrap().as_ref(), "hi"); +} + +#[test] +fn rejects_odd_byte_len() { + let wire = make_utf16le("hi"); + let err = UnframedString::decode_from_byte_len(&mut ReadCursor::new(&wire), 3).unwrap_err(); + expect![[r#" + Error { + context: "ironrdp_str::unframed::UnframedString::decode_from_byte_len", + kind: InvalidField { + field: "byte_len", + reason: "odd byte count for utf-16 string field", + }, + source: None, + } + "#]] + .assert_debug_eq(&err); +} + +#[test] +fn strips_trailing_null() { + // Wire with null terminator included in the count. + let mut wire = make_utf16le("hello"); + wire.extend_from_slice(&[0x00, 0x00]); // null + let s = UnframedString::decode(&mut ReadCursor::new(&wire), 6).unwrap(); + assert_eq!(s.to_native().unwrap().as_ref(), "hello"); +} + +#[test] +fn non_bmp_round_trip() { + let original = "\u{1F600}"; + let s = UnframedString::new(original.to_owned()); + assert_eq!(s.utf16_len(), 2); + assert_eq!(s.wire_size(), 4); + + let mut buf = vec![0u8; s.wire_size()]; + s.encode_into(&mut WriteCursor::new(&mut buf)).unwrap(); + let decoded = UnframedString::decode(&mut ReadCursor::new(&buf), 2).unwrap(); + assert_eq!(decoded.to_native().unwrap().as_ref(), original); +} + +#[test] +fn lone_surrogate_decode_succeeds_to_native_fails() { + // Lone high surrogate D800 LE. Decode no longer validates; to_native() reports error. + let wire: &[u8] = &[0x00, 0xD8]; + let decoded = UnframedString::decode(&mut ReadCursor::new(wire), 1).unwrap(); + let err = decoded.to_native().unwrap_err(); + expect![[" + InvalidUtf16 + "]] + .assert_debug_eq(&err); + assert!(decoded.to_native_lossy().contains('\u{FFFD}')); +} + +// Property: wire_size() is always the exact number of bytes encode_into() requires. +proptest::proptest! { + #![proptest_config(proptest::test_runner::Config::with_cases(50))] + #[test] + fn wire_size_prop(s in "\\PC{0,20}") { + let f = UnframedString::new(s); + let mut buf = vec![0u8; f.wire_size()]; + proptest::prop_assert!(f.encode_into(&mut WriteCursor::new(&mut buf)).is_ok()); + } +} + +// ── from_utf16le_bytes ──────────────────────────────────────────────────────── + +#[test] +fn from_utf16le_bytes_strips_trailing_null() { + let mut wire = make_utf16le("hi"); + wire.extend_from_slice(&[0x00, 0x00]); + let s = UnframedString::from_utf16le_bytes(&wire).unwrap(); + assert_eq!(s.to_native().unwrap().as_ref(), "hi"); +} + +// ── from_wire_units / to_wire_units / into_wire_units ──────────────────────── + +#[test] +fn from_wire_units_round_trip() { + let units: Vec = "hello".encode_utf16().collect(); + let s = UnframedString::from_wire_units(units.clone()); + assert_eq!(s.to_native().unwrap().as_ref(), "hello"); + assert_eq!(s.to_wire_units().as_ref(), units.as_slice()); +} + +#[test] +fn from_wire_units_strips_trailing_null() { + let mut units: Vec = "hi".encode_utf16().collect(); + units.push(0); + let s = UnframedString::from_wire_units(units); + assert_eq!(s.to_native().unwrap().as_ref(), "hi"); +} + +#[test] +fn into_wire_units_from_decode() { + let wire = make_utf16le("abc"); + let s = UnframedString::decode(&mut ReadCursor::new(&wire), 3).unwrap(); + let units = s.into_wire_units(); + let expected: Vec = "abc".encode_utf16().collect(); + assert_eq!(units, expected); +} + +#[test] +fn to_wire_units_from_native_encodes_correctly() { + let s = UnframedString::new("\u{1F600}"); + let units = s.to_wire_units(); + assert_eq!(units.as_ref(), &[0xD83Du16, 0xDE00u16]); +} diff --git a/crates/ironrdp-testsuite-extra/Cargo.toml b/crates/ironrdp-testsuite-extra/Cargo.toml index 7022d4c347..9e98415bfe 100644 --- a/crates/ironrdp-testsuite-extra/Cargo.toml +++ b/crates/ironrdp-testsuite-extra/Cargo.toml @@ -10,18 +10,37 @@ repository.workspace = true authors.workspace = true keywords.workspace = true categories.workspace = true +autotests = false + +[lib] +doctest = false +test = false + +[[test]] +name = "integration_tests_extra" +path = "tests/main.rs" +harness = true [dev-dependencies] anyhow = "1.0" async-trait = "0.1" -ironrdp = { path = "../ironrdp", features = ["server", "pdu", "connector", "session", "connector"] } +ironrdp = { path = "../ironrdp", features = ["server", "pdu", "connector", "session", "dvc", "echo"] } ironrdp-async.path = "../ironrdp-async" +ironrdp-agent = { path = "../ironrdp-agent", features = ["internal"] } +ironrdp-client.path = "../ironrdp-client" +ironrdp-core.path = "../ironrdp-core" +ironrdp-dvc.path = "../ironrdp-dvc" +ironrdp-dvc-pipe-proxy.path = "../ironrdp-dvc-pipe-proxy" +ironrdp-input.path = "../ironrdp-input" +ironrdp-propertyset.path = "../ironrdp-propertyset" +ironrdp-viewer.path = "../ironrdp-viewer" ironrdp-tokio.path = "../ironrdp-tokio" ironrdp-tls = { path = "../ironrdp-tls", features = ["rustls"] } semver = "1.0" tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } -tokio = { version = "1", features = ["sync", "time"] } +tokio = { version = "1", features = ["sync", "time", "net", "rt", "macros", "io-util"] } +uuid = { version = "1", features = ["v4"] } [lints] workspace = true diff --git a/crates/ironrdp-testsuite-extra/tests/agent.rs b/crates/ironrdp-testsuite-extra/tests/agent.rs new file mode 100644 index 0000000000..77e0391652 --- /dev/null +++ b/crates/ironrdp-testsuite-extra/tests/agent.rs @@ -0,0 +1,176 @@ +//! Codec round-trip tests for the `ironrdp-agent` IPC and wire protocols. +//! +//! These exercise the crate's private wire format through its public (and `internal`-feature) +//! API. They live here, in the shared test suite, rather than inside `ironrdp-agent` itself, per +//! the workspace convention of keeping unit tests for protocol codecs in `ironrdp-testsuite-extra`. + +use core::fmt::Debug; + +use ironrdp_agent::ipc::{ + ConnState, KeyFilter, Payload, PropValue, PropertyDump, PropertyEntry, Request, Response, StatusInfo, +}; +use ironrdp_agent::wire; +use ironrdp_core::{Decode, DecodeOwned, Encode, decode, decode_owned, encode_vec}; +use ironrdp_input::MouseButton; +use ironrdp_propertyset::PropertySet; + +#[track_caller] +fn round_trip(value: &T) +where + T: Encode + DecodeOwned + for<'de> Decode<'de> + PartialEq + Debug, +{ + let bytes = encode_vec(value).expect("encode"); + + let decoded_owned: T = decode_owned(&bytes).expect("decode_owned"); + assert_eq!(value, &decoded_owned, "decode_owned round-trip mismatch"); + + let decoded: T = decode(&bytes).expect("decode"); + assert_eq!(value, &decoded, "decode round-trip mismatch"); +} + +#[test] +fn request_variants_round_trip() { + let mut props = PropertySet::new(); + props.insert("full address", "host.example:3389"); + props.insert("username", "operator"); + + let mut props2 = PropertySet::new(); + props2.insert("full address", "host.example:3389"); + + let requests = [ + Request::Connect { + properties: props, + log_directive: None, + }, + Request::Connect { + properties: props2, + log_directive: Some("ironrdp_connector=trace,debug".to_owned()), + }, + Request::Disconnect, + Request::Status, + Request::QueryProps { filter: None }, + Request::QueryProps { + filter: Some(KeyFilter::Substring("addr".to_owned())), + }, + Request::QueryProps { + filter: Some(KeyFilter::Prefix("Full".to_owned())), + }, + Request::QueryLogs { + substring: Some("error".to_owned()), + last: Some(50), + }, + Request::QueryLogs { + substring: None, + last: None, + }, + Request::Screenshot, + Request::MouseMove { x: 640, y: 480 }, + Request::MouseButton { + button: MouseButton::Right, + pressed: true, + }, + Request::Wheel { + delta: -120, + horizontal: false, + }, + Request::KeyScancode { + scancode: 0x1C, + pressed: false, + }, + Request::KeyUnicode { + ch: '\u{00e9}', + pressed: true, + }, + ]; + + for request in &requests { + round_trip(request); + } +} + +#[test] +fn response_variants_round_trip() { + let responses = [ + Response::ok(), + Response::error("connection refused"), + Response::Ok(Payload::Status(StatusInfo { + state: ConnState::NoSession, + destination: None, + width: None, + height: None, + message: None, + credentials_loaded: true, + })), + Response::Ok(Payload::Status(StatusInfo { + state: ConnState::Connected, + destination: Some("host.example:3389".to_owned()), + width: Some(1920), + height: Some(1080), + message: Some("ok".to_owned()), + credentials_loaded: false, + })), + Response::Ok(Payload::Properties(PropertyDump { + entries: vec![ + PropertyEntry { + key: "full address".to_owned(), + value: PropValue::Str("host.example:3389".to_owned()), + }, + PropertyEntry { + key: "server port".to_owned(), + value: PropValue::Int(3389), + }, + ], + })), + Response::Ok(Payload::Logs(vec!["line one".to_owned(), "line two".to_owned()])), + Response::Ok(Payload::Screenshot { + width: 800, + height: 600, + png: vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A], + }), + Response::Ok(Payload::Empty), + ]; + + for response in &responses { + round_trip(response); + } +} + +#[test] +fn property_set_wire_round_trips() { + let mut original = PropertySet::new(); + original.insert("full address", "host.example:3389"); + original.insert("server port", 3389i64); + original.insert("username", "operator"); + original.insert("screen mode id", 2i64); + + let size = wire::propertyset::size(&original); + let mut buf = vec![0u8; size]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + wire::propertyset::write(&original, &mut cursor).expect("write"); + assert_eq!(cursor.pos(), size, "written length must match computed size"); + + let mut decoded = PropertySet::new(); + let mut read_cursor = ironrdp_core::ReadCursor::new(&buf); + wire::propertyset::read(&mut decoded, &mut read_cursor).expect("read"); + + let mut original_pairs: Vec<_> = original.iter().collect(); + let mut decoded_pairs: Vec<_> = decoded.iter().collect(); + original_pairs.sort_by_key(|(key, _)| *key); + decoded_pairs.sort_by_key(|(key, _)| *key); + assert_eq!(original_pairs, decoded_pairs, "property set wire round-trip mismatch"); +} + +#[test] +fn bytes_wire_round_trips() { + let original = vec![0x89, b'P', b'N', b'G', 0x00, 0xFF, 0x10, 0x20]; + + let size = wire::bytes_size(&original); + let mut buf = vec![0u8; size]; + let mut cursor = ironrdp_core::WriteCursor::new(&mut buf); + wire::write_bytes(&mut cursor, &original).expect("write_bytes"); + assert_eq!(cursor.pos(), size, "written length must match computed size"); + + let mut read_cursor = ironrdp_core::ReadCursor::new(&buf); + let decoded = wire::read_bytes(&mut read_cursor).expect("read_bytes"); + assert_eq!(original, decoded, "bytes wire round-trip mismatch"); +} diff --git a/crates/ironrdp-testsuite-extra/tests/client_config.rs b/crates/ironrdp-testsuite-extra/tests/client_config.rs new file mode 100644 index 0000000000..7614f86e9f --- /dev/null +++ b/crates/ironrdp-testsuite-extra/tests/client_config.rs @@ -0,0 +1,165 @@ +use std::fs; +use std::path::PathBuf; + +use ironrdp_client::config::{ClipboardType, Transport}; +use ironrdp_viewer::cli::parse_config_from; +use uuid::Uuid; + +struct TempRdpFile { + path: PathBuf, +} + +impl TempRdpFile { + fn new(content: &str) -> Self { + let path = std::env::temp_dir().join(format!("ironrdp-client-rdp-{}.rdp", Uuid::new_v4())); + fs::write(&path, content).expect("failed to write temporary .rdp file"); + TempRdpFile { path } + } + + fn path(&self) -> &std::path::Path { + &self.path + } +} + +impl Drop for TempRdpFile { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +fn parse_config_from_rdp(content: &str, extra_args: &[&str]) -> ironrdp_client::config::Config { + let rdp_file = TempRdpFile::new(content); + + let mut args = vec![ + "ironrdp-client".to_owned(), + "--rdp-file".to_owned(), + rdp_file.path().display().to_string(), + ]; + + args.extend(extra_args.iter().map(|arg| (*arg).to_owned())); + + parse_config_from(args).expect("failed to parse client config") +} + +#[test] +fn gateway_is_disabled_when_gateway_usage_method_is_zero() { + let config = parse_config_from_rdp( + "full address:s:rdp.example.com\nusername:s:test-user\nClearTextPassword:s:test-pass\ngatewayhostname:s:gw.example.com:443\ngatewayusagemethod:i:0\n", + &[], + ); + + assert!(!matches!(config.transport(), Transport::Gateway(_))); +} + +#[test] +fn gateway_is_disabled_when_gateway_usage_method_is_four() { + let config = parse_config_from_rdp( + "full address:s:rdp.example.com\nusername:s:test-user\nClearTextPassword:s:test-pass\ngatewayhostname:s:gw.example.com:443\ngatewayusagemethod:i:4\n", + &[], + ); + + assert!(!matches!(config.transport(), Transport::Gateway(_))); +} + +#[test] +fn gateway_is_enabled_with_usage_method_one_and_file_credentials() { + let config = parse_config_from_rdp( + "full address:s:rdp.example.com\nusername:s:test-user\nClearTextPassword:s:test-pass\ngatewayhostname:s:gw.example.com:443\ngatewayusagemethod:i:1\ngatewayusername:s:gw-user\nGatewayPassword:s:gw-pass\n", + &[], + ); + + let Transport::Gateway(gw) = config.transport() else { + panic!("gateway should be configured"); + }; + assert_eq!(gw.endpoint, "gw.example.com:443"); + assert_eq!(gw.username, "gw-user"); + assert_eq!(gw.password, "gw-pass"); +} + +#[test] +fn no_credssp_cli_flag_overrides_rdp_enable_credssp_property() { + let config = parse_config_from_rdp( + "full address:s:rdp.example.com\nusername:s:test-user\nClearTextPassword:s:test-pass\nenablecredsspsupport:i:1\n", + &["--no-credssp"], + ); + + assert!(!config.connector().enable_credssp); +} + +#[test] +fn kdc_proxy_name_is_normalized_to_https_url() { + let config = parse_config_from_rdp( + "full address:s:rdp.example.com\nusername:s:test-user\nClearTextPassword:s:test-pass\nkdcproxyname:s:kdc.example.com\n", + &[], + ); + + let kerberos = config.kerberos_config().expect("kerberos config should be present"); + let kdc_proxy_url = kerberos + .kdc_proxy_url + .as_ref() + .expect("kdc proxy url should be present"); + assert_eq!(kdc_proxy_url.as_str(), "https://kdc.example.com/KdcProxy"); +} + +#[test] +fn redirectclipboard_zero_disables_clipboard_for_default_mode() { + let config = parse_config_from_rdp( + "full address:s:rdp.example.com\nusername:s:test-user\nClearTextPassword:s:test-pass\nredirectclipboard:i:0\n", + &[], + ); + + assert!(matches!(config.channels().clipboard, ClipboardType::Disable)); +} + +#[test] +fn audiomode_two_disables_audio_playback() { + let config = parse_config_from_rdp( + "full address:s:rdp.example.com\nusername:s:test-user\nClearTextPassword:s:test-pass\naudiomode:i:2\n", + &[], + ); + + assert!(!config.connector().enable_audio_playback); +} + +#[test] +fn invalid_audiomode_falls_back_to_audio_playback_enabled() { + let config = parse_config_from_rdp( + "full address:s:rdp.example.com\nusername:s:test-user\nClearTextPassword:s:test-pass\naudiomode:i:99\n", + &[], + ); + + assert!(config.connector().enable_audio_playback); +} + +#[test] +fn desktop_dimensions_are_parsed_from_rdp_file() { + let config = parse_config_from_rdp( + "full address:s:rdp.example.com\nusername:s:test-user\nClearTextPassword:s:test-pass\ndesktopwidth:i:1024\ndesktopheight:i:768\ndesktopscalefactor:i:125\n", + &[], + ); + + assert_eq!(config.connector().desktop_size.width, 1024); + assert_eq!(config.connector().desktop_size.height, 768); + assert_eq!(config.connector().desktop_scale_factor, 125); +} + +#[test] +fn out_of_range_desktop_dimensions_fall_back_to_defaults() { + let default_config = parse_config_from_rdp( + "full address:s:rdp.example.com\nusername:s:test-user\nClearTextPassword:s:test-pass\n", + &[], + ); + let invalid_config = parse_config_from_rdp( + "full address:s:rdp.example.com\nusername:s:test-user\nClearTextPassword:s:test-pass\ndesktopwidth:i:-1\ndesktopheight:i:-1\n", + &[], + ); + + assert_eq!( + invalid_config.connector().desktop_size.width, + default_config.connector().desktop_size.width + ); + assert_eq!( + invalid_config.connector().desktop_size.height, + default_config.connector().desktop_size.height + ); +} diff --git a/crates/ironrdp-testsuite-extra/tests/dvc_pipe_proxy.rs b/crates/ironrdp-testsuite-extra/tests/dvc_pipe_proxy.rs new file mode 100644 index 0000000000..592c176467 --- /dev/null +++ b/crates/ironrdp-testsuite-extra/tests/dvc_pipe_proxy.rs @@ -0,0 +1,44 @@ +#[cfg(windows)] +use core::time::Duration; +#[cfg(windows)] +use std::sync::mpsc; + +#[cfg(windows)] +use ironrdp_dvc::DvcProcessor as _; +#[cfg(windows)] +use ironrdp_dvc_pipe_proxy::DvcNamedPipeProxy; +#[cfg(windows)] +use tokio::io::AsyncWriteExt as _; +#[cfg(windows)] +use tokio::net::windows::named_pipe::ClientOptions; + +#[cfg(windows)] +#[tokio::test] +async fn connects_and_forwards_windows_pipe_data() { + let name = format!("ironrdp-dvc-pipe-proxy-test-{}", std::process::id()); + let (callback_tx, callback_rx) = mpsc::channel(); + let mut proxy = DvcNamedPipeProxy::new("test", &name, move |_, messages| { + callback_tx + .send(messages) + .expect("test callback receiver must remain alive"); + Ok(()) + }); + proxy.start(1).expect("start DVC pipe proxy"); + + let pipe_path = format!(r"\\.\pipe\{name}"); + let mut client = (0..200) + .find_map(|_| match ClientOptions::new().open(&pipe_path) { + Ok(client) => Some(client), + Err(_) => { + std::thread::sleep(Duration::from_millis(10)); + None + } + }) + .expect("DVC pipe proxy must create the pipe within two seconds"); + + client.write_all(b"test data").await.expect("write to DVC pipe"); + let messages = callback_rx + .recv_timeout(Duration::from_secs(1)) + .expect("DVC pipe proxy must forward pipe data to its callback"); + assert!(!messages.is_empty(), "DVC pipe data must produce an SVC message"); +} diff --git a/crates/ironrdp-testsuite-extra/tests/e2e.rs b/crates/ironrdp-testsuite-extra/tests/e2e.rs new file mode 100644 index 0000000000..a0ee690bf5 --- /dev/null +++ b/crates/ironrdp-testsuite-extra/tests/e2e.rs @@ -0,0 +1,430 @@ +// FIXME: tests in this module can probably be rewritten to be much shorter using the ironrdp-client crate. + +use core::time::Duration; +use std::path::Path; +use std::sync::Arc; +use std::time::Instant; + +use anyhow::Result; +use ironrdp::connector; +use ironrdp::dvc::DrdynvcClient; +use ironrdp::echo::client::EchoClient; +use ironrdp::pdu::rdp::capability_sets::MajorPlatformType; +use ironrdp::pdu::{self, gcc}; +use ironrdp::server::{ + self, DesktopSize, DisplayUpdate, KeyboardEvent, MouseEvent, PixelFormat, RdpServer, RdpServerDisplay, + RdpServerDisplayUpdates, RdpServerInputHandler, ServerEvent, TlsIdentityCtx, +}; +use ironrdp::session::image::DecodedImage; +use ironrdp::session::{self, ActiveStage, ActiveStageBuilder, ActiveStageOutput}; +use ironrdp_async::{Framed, FramedWrite as _}; +use ironrdp_testsuite_extra as _; +use ironrdp_tls::TlsStream; +use ironrdp_tokio::TokioStream; +use tokio::net::TcpStream; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use tokio::sync::{Mutex, oneshot}; +use tracing::debug; + +const DESKTOP_WIDTH: u16 = 1024; +const DESKTOP_HEIGHT: u16 = 768; +const USERNAME: &str = ""; +const PASSWORD: &str = ""; + +#[tokio::test] +async fn test_client_server() { + client_server( + default_client_config(), + |stage, _activation_factory, framed, _display_tx| async { (stage, framed) }, + ) + .await +} + +#[tokio::test] +async fn test_deactivation_reactivation() { + let client_config = default_client_config(); + let mut image = DecodedImage::new( + PixelFormat::RgbA32, + client_config.desktop_size.width, + client_config.desktop_size.height, + ); + client_server( + client_config, + |mut stage, activation_factory, mut framed, display_tx| async move { + display_tx + .send(DisplayUpdate::Resize(DesktopSize { + width: 2048, + height: 2048, + })) + .unwrap(); + { + let (action, payload) = framed.read_pdu().await.expect("valid PDU"); + let outputs = stage.process(&mut image, action, &payload).expect("stage process"); + let out = outputs.into_iter().next().unwrap(); + match out { + ActiveStageOutput::DeactivateAll => { + // TODO: factor this out in common client code + // Execute the Deactivation-Reactivation Sequence: + // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 + debug!("Received Server Deactivate All PDU, executing Deactivation-Reactivation Sequence"); + let mut connection_activation = activation_factory.create(); + let mut buf = pdu::WriteBuf::new(); + 'activation_seq: loop { + let written = ironrdp_async::single_sequence_step_read( + &mut framed, + &mut connection_activation, + &mut buf, + ) + .await + .map_err(|e| session::custom_err!("read deactivation-reactivation sequence step", e)) + .unwrap(); + + if written.size().is_some() { + framed + .write_all(buf.filled()) + .await + .map_err(|e| { + session::custom_err!("write deactivation-reactivation sequence step", e) + }) + .unwrap(); + } + + if let connector::connection_activation::ConnectionActivationState::Finalized { + desktop_size, + share_id, + enable_server_pointer, + pointer_software_rendering, + } = connection_activation.connection_activation_state() + { + debug!(?desktop_size, "Deactivation-Reactivation Sequence completed"); + // Update image size with the new desktop size. + // image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height); + // Update the active stage with the new channel IDs and pointer settings. + stage.set_fastpath_processor( + session::fast_path::ProcessorBuilder { + io_channel_id: connection_activation.io_channel_id(), + user_channel_id: connection_activation.user_channel_id(), + share_id, + enable_server_pointer, + pointer_software_rendering, + bulk_decompressor: None, + } + .build(), + ); + stage.set_share_id(share_id); + stage.set_enable_server_pointer(enable_server_pointer); + break 'activation_seq; + } + } + } + _ => unreachable!(), + } + } + (stage, framed) + }, + ) + .await +} + +#[tokio::test] +async fn test_echo_virtual_channel_end_to_end() { + let payload = b"ironrdp echo e2e".to_vec(); + let echo_payload = payload.clone(); + + client_server_with_connector( + default_client_config(), + |connector| connector.with_static_channel(DrdynvcClient::new().with_dynamic_channel(EchoClient::new())), + move |mut stage, _activation_factory, mut framed, display_tx, echo_handle| async move { + let _display_tx = display_tx; + let mut image = DecodedImage::new(PixelFormat::RgbA32, DESKTOP_WIDTH, DESKTOP_HEIGHT); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut matched_measurement = None; + + while Instant::now() < deadline { + echo_handle + .send_request(echo_payload.clone()) + .expect("send echo request"); + + for _ in 0..20 { + let measurements = echo_handle.take_measurements(); + if let Some(measurement) = measurements.into_iter().find(|m| m.payload == echo_payload) { + matched_measurement = Some(measurement); + break; + } + + let read_result = tokio::time::timeout(Duration::from_millis(150), framed.read_pdu()).await; + let Ok(Ok((action, frame))) = read_result else { + continue; + }; + + let outputs = stage.process(&mut image, action, &frame).expect("stage process"); + for output in outputs { + if let ActiveStageOutput::ResponseFrame(frame) = output { + framed.write_all(&frame).await.expect("write response frame"); + } + } + } + + if matched_measurement.is_some() { + break; + } + } + + let measurement = matched_measurement.expect("echo RTT measurement was not produced"); + assert_eq!(measurement.payload, echo_payload); + + (stage, framed) + }, + ) + .await +} + +type DisplayUpdatesRx = Arc>>; + +struct TestDisplayUpdates { + rx: DisplayUpdatesRx, +} + +#[async_trait::async_trait] +impl RdpServerDisplayUpdates for TestDisplayUpdates { + async fn next_update(&mut self) -> Result> { + let mut rx = self.rx.lock().await; + + Ok(rx.recv().await) + } +} + +struct TestDisplay { + rx: DisplayUpdatesRx, +} + +#[async_trait::async_trait] +impl RdpServerDisplay for TestDisplay { + async fn size(&mut self) -> DesktopSize { + DesktopSize { + width: DESKTOP_WIDTH, + height: DESKTOP_HEIGHT, + } + } + + async fn updates(&mut self) -> Result> { + Ok(Box::new(TestDisplayUpdates { + rx: Arc::clone(&self.rx), + })) + } +} + +struct TestInputHandler; +impl RdpServerInputHandler for TestInputHandler { + fn keyboard(&mut self, _: KeyboardEvent) {} + fn mouse(&mut self, _: MouseEvent) {} +} + +async fn client_server(client_config: connector::Config, clientfn: F) +where + F: FnOnce( + ActiveStage, + connector::connection_activation::ConnectionActivationFactory, + Framed>>, + UnboundedSender, + ) -> Fut + + 'static, + Fut: Future>>)>, +{ + client_server_with_connector( + client_config, + |connector| connector, + move |stage, connection_activation, framed, display_tx, _echo_handle| { + clientfn(stage, connection_activation, framed, display_tx) + }, + ) + .await; +} + +async fn client_server_with_connector(client_config: connector::Config, connector_factory: C, clientfn: F) +where + F: FnOnce( + ActiveStage, + connector::connection_activation::ConnectionActivationFactory, + Framed>>, + UnboundedSender, + server::EchoServerHandle, + ) -> Fut + + 'static, + Fut: Future>>)>, + C: FnOnce(connector::ClientConnector) -> connector::ClientConnector + 'static, +{ + // FIXME(@CBenoit): If this is really necessary, we may consider a non-global way of registering the subscriber; otherwise it’s unnecessary to register that. + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .try_init(); + + let cert_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/certs/server-cert.pem"); + let key_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/certs/server-key.pem"); + let identity = TlsIdentityCtx::init_from_paths(&cert_path, &key_path).expect("failed to init TLS identity"); + let acceptor = identity.make_acceptor().expect("failed to build TLS acceptor"); + + let (display_tx, display_rx) = mpsc::unbounded_channel(); + let mut server = RdpServer::builder() + .with_addr(([127, 0, 0, 1], 0)) + .with_tls(acceptor) + .with_input_handler(TestInputHandler) + .with_display_handler(TestDisplay { + rx: Arc::new(Mutex::new(display_rx)), + }) + .build(); + server.set_credentials(Some(server::Credentials { + username: USERNAME.into(), + password: PASSWORD.into(), + domain: None, + })); + let ev = server.event_sender().clone(); + let echo_handle = server.echo_handle().clone(); + + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + let server = tokio::task::spawn_local(async move { + server.run().await.unwrap(); + }); + + let client = tokio::task::spawn_local(async move { + let (tx, rx) = oneshot::channel(); + ev.send(ServerEvent::GetLocalAddr(tx)).unwrap(); + let server_addr = rx.await.unwrap().unwrap(); + let tcp_stream = TcpStream::connect(server_addr).await.expect("TCP connect"); + let client_addr = tcp_stream.local_addr().expect("local_addr"); + let mut framed = ironrdp_tokio::TokioFramed::new(tcp_stream); + let connector = connector::ClientConnector::new(client_config, client_addr); + let mut connector = connector_factory(connector); + let should_upgrade = ironrdp_async::connect_begin(&mut framed, &mut connector) + .await + .expect("begin connection"); + let initial_stream = framed.into_inner_no_leftover(); + let (upgraded_stream, tls_cert) = ironrdp_tls::upgrade(initial_stream, "localhost") + .await + .expect("TLS upgrade"); + let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector); + let mut upgraded_framed = ironrdp_tokio::TokioFramed::new(upgraded_stream); + let server_public_key = + ironrdp_tls::extract_tls_server_public_key(&tls_cert).expect("extract server public key"); + let connection_result = ironrdp_async::connect_finalize( + upgraded, + connector, + &mut upgraded_framed, + &mut ironrdp_tokio::reqwest::ReqwestNetworkClient::new(), + "localhost".into(), + server_public_key.to_owned(), + None, + ) + .await + .expect("finalize connection"); + + // Retain the connection activation factory so the client closure can drive its own + // Deactivation-Reactivation Sequence. + let activation_factory = connection_result.activation_factory; + let active_stage = ActiveStageBuilder { + static_channels: connection_result.static_channels, + user_channel_id: connection_result.user_channel_id, + io_channel_id: connection_result.io_channel_id, + message_channel_id: connection_result.message_channel_id, + share_id: connection_result.share_id, + compression_type: connection_result.compression_type, + enable_server_pointer: connection_result.enable_server_pointer, + pointer_software_rendering: connection_result.pointer_software_rendering, + } + .build(); + let (active_stage, mut upgraded_framed) = clientfn( + active_stage, + activation_factory, + upgraded_framed, + display_tx, + echo_handle, + ) + .await; + let outputs = active_stage.graceful_shutdown().expect("shutdown"); + for out in outputs { + match out { + ActiveStageOutput::ResponseFrame(frame) => { + upgraded_framed.write_all(&frame).await.expect("write frame"); + } + _ => unimplemented!(), + } + } + + // server should probably send TLS close_notify + while let Ok(pdu) = upgraded_framed.read_pdu().await { + debug!(?pdu); + } + ev.send(ServerEvent::Quit("bye".into())).unwrap(); + }); + + tokio::try_join!(server, client).expect("join"); + }) + .await; +} + +fn default_client_config() -> connector::Config { + connector::Config { + desktop_size: DesktopSize { + width: DESKTOP_WIDTH, + height: DESKTOP_HEIGHT, + }, + desktop_scale_factor: 0, // Default to 0 per FreeRDP + enable_tls: true, + enable_credssp: true, + credentials: connector::Credentials::UsernamePassword { + username: USERNAME.into(), + password: PASSWORD.into(), + }, + domain: None, + client_build: semver::Version::parse(env!("CARGO_PKG_VERSION")) + .map(|version| version.major * 100 + version.minor * 10 + version.patch) + .unwrap_or(0) + .try_into() + .unwrap(), + client_name: "ironrdp".into(), + keyboard_type: gcc::KeyboardType::IbmEnhanced, + keyboard_subtype: 0, + keyboard_layout: 0, + keyboard_functional_keys_count: 12, + ime_file_name: "".into(), + bitmap: None, + dig_product_id: "".into(), + // NOTE: hardcode this value like in freerdp + // https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70 + client_dir: "C:\\Windows\\System32\\mstscax.dll".into(), + #[cfg(windows)] + platform: MajorPlatformType::WINDOWS, + #[cfg(target_os = "macos")] + platform: MajorPlatformType::MACINTOSH, + #[cfg(target_os = "ios")] + platform: MajorPlatformType::IOS, + #[cfg(target_os = "linux")] + platform: MajorPlatformType::UNIX, + #[cfg(target_os = "android")] + platform: MajorPlatformType::ANDROID, + #[cfg(target_os = "freebsd")] + platform: MajorPlatformType::UNIX, + #[cfg(target_os = "dragonfly")] + platform: MajorPlatformType::UNIX, + #[cfg(target_os = "openbsd")] + platform: MajorPlatformType::UNIX, + #[cfg(target_os = "netbsd")] + platform: MajorPlatformType::UNIX, + hardware_id: None, + request_data: None, + autologon: false, + enable_audio_playback: true, + license_cache: None, + compression_type: None, + enable_server_pointer: true, + pointer_software_rendering: true, + multitransport_flags: None, + performance_flags: Default::default(), + timezone_info: Default::default(), + alternate_shell: String::new(), + work_dir: String::new(), + } +} diff --git a/crates/ironrdp-testsuite-extra/tests/main.rs b/crates/ironrdp-testsuite-extra/tests/main.rs new file mode 100644 index 0000000000..5811e67748 --- /dev/null +++ b/crates/ironrdp-testsuite-extra/tests/main.rs @@ -0,0 +1,7 @@ +#![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary +#![allow(clippy::unwrap_used, reason = "unwrap is fine in tests")] + +mod agent; +mod client_config; +mod dvc_pipe_proxy; +mod e2e; diff --git a/crates/ironrdp-testsuite-extra/tests/tests.rs b/crates/ironrdp-testsuite-extra/tests/tests.rs deleted file mode 100644 index 29bd2af2f4..0000000000 --- a/crates/ironrdp-testsuite-extra/tests/tests.rs +++ /dev/null @@ -1,307 +0,0 @@ -#![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary - -use core::future::Future; -use std::path::Path; -use std::sync::Arc; - -use anyhow::Result; -use ironrdp::connector; -use ironrdp::pdu::rdp::capability_sets::MajorPlatformType; -use ironrdp::pdu::{self, gcc}; -use ironrdp::server::{ - self, DesktopSize, DisplayUpdate, KeyboardEvent, MouseEvent, PixelFormat, RdpServer, RdpServerDisplay, - RdpServerDisplayUpdates, RdpServerInputHandler, ServerEvent, TlsIdentityCtx, -}; -use ironrdp::session::image::DecodedImage; -use ironrdp::session::{self, ActiveStage, ActiveStageOutput}; -use ironrdp_async::{Framed, FramedWrite as _}; -use ironrdp_testsuite_extra as _; -use ironrdp_tls::TlsStream; -use ironrdp_tokio::TokioStream; -use tokio::net::TcpStream; -use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; -use tokio::sync::{oneshot, Mutex}; -use tracing::debug; - -const DESKTOP_WIDTH: u16 = 1024; -const DESKTOP_HEIGHT: u16 = 768; -const USERNAME: &str = ""; -const PASSWORD: &str = ""; - -#[tokio::test] -async fn test_client_server() { - client_server(default_client_config(), |stage, framed, _display_tx| async { - (stage, framed) - }) - .await -} - -#[tokio::test] -async fn test_deactivation_reactivation() { - let client_config = default_client_config(); - let mut image = DecodedImage::new( - PixelFormat::RgbA32, - client_config.desktop_size.width, - client_config.desktop_size.height, - ); - client_server(client_config, |mut stage, mut framed, display_tx| async move { - display_tx - .send(DisplayUpdate::Resize(DesktopSize { - width: 2048, - height: 2048, - })) - .unwrap(); - { - let (action, payload) = framed.read_pdu().await.expect("valid PDU"); - let outputs = stage.process(&mut image, action, &payload).expect("stage process"); - let out = outputs.into_iter().next().unwrap(); - match out { - ActiveStageOutput::DeactivateAll(mut connection_activation) => { - // TODO: factor this out in common client code - // Execute the Deactivation-Reactivation Sequence: - // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 - debug!("Received Server Deactivate All PDU, executing Deactivation-Reactivation Sequence"); - let mut buf = pdu::WriteBuf::new(); - 'activation_seq: loop { - let written = ironrdp_async::single_sequence_step_read( - &mut framed, - &mut *connection_activation, - &mut buf, - ) - .await - .map_err(|e| session::custom_err!("read deactivation-reactivation sequence step", e)) - .unwrap(); - - if written.size().is_some() { - framed - .write_all(buf.filled()) - .await - .map_err(|e| session::custom_err!("write deactivation-reactivation sequence step", e)) - .unwrap(); - } - - if let connector::connection_activation::ConnectionActivationState::Finalized { - io_channel_id, - user_channel_id, - desktop_size, - enable_server_pointer, - pointer_software_rendering, - } = connection_activation.state - { - debug!(?desktop_size, "Deactivation-Reactivation Sequence completed"); - // Update image size with the new desktop size. - // image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height); - // Update the active stage with the new channel IDs and pointer settings. - stage.set_fastpath_processor( - session::fast_path::ProcessorBuilder { - io_channel_id, - user_channel_id, - enable_server_pointer, - pointer_software_rendering, - } - .build(), - ); - stage.set_enable_server_pointer(enable_server_pointer); - break 'activation_seq; - } - } - } - _ => unreachable!(), - } - } - (stage, framed) - }) - .await -} - -type DisplayUpdatesRx = Arc>>; - -struct TestDisplayUpdates { - rx: DisplayUpdatesRx, -} - -#[async_trait::async_trait] -impl RdpServerDisplayUpdates for TestDisplayUpdates { - async fn next_update(&mut self) -> Option { - let mut rx = self.rx.lock().await; - - rx.recv().await - } -} - -struct TestDisplay { - rx: DisplayUpdatesRx, -} - -#[async_trait::async_trait] -impl RdpServerDisplay for TestDisplay { - async fn size(&mut self) -> DesktopSize { - DesktopSize { - width: DESKTOP_WIDTH, - height: DESKTOP_HEIGHT, - } - } - - async fn updates(&mut self) -> Result> { - Ok(Box::new(TestDisplayUpdates { - rx: Arc::clone(&self.rx), - })) - } -} - -struct TestInputHandler; -impl RdpServerInputHandler for TestInputHandler { - fn keyboard(&mut self, _: KeyboardEvent) {} - fn mouse(&mut self, _: MouseEvent) {} -} - -async fn client_server(client_config: connector::Config, clientfn: F) -where - F: FnOnce(ActiveStage, Framed>>, UnboundedSender) -> Fut + 'static, - Fut: Future>>)>, -{ - let _ = tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .try_init(); - - let cert_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/certs/server-cert.pem"); - let key_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/certs/server-key.pem"); - let identity = TlsIdentityCtx::init_from_paths(&cert_path, &key_path).expect("failed to init TLS identity"); - let acceptor = identity.make_acceptor().expect("failed to build TLS acceptor"); - - let (display_tx, display_rx) = mpsc::unbounded_channel(); - let mut server = RdpServer::builder() - .with_addr(([127, 0, 0, 1], 0)) - .with_tls(acceptor) - .with_input_handler(TestInputHandler) - .with_display_handler(TestDisplay { - rx: Arc::new(Mutex::new(display_rx)), - }) - .build(); - server.set_credentials(Some(server::Credentials { - username: USERNAME.into(), - password: PASSWORD.into(), - domain: None, - })); - let ev = server.event_sender().clone(); - - let local = tokio::task::LocalSet::new(); - local - .run_until(async move { - let server = tokio::task::spawn_local(async move { - server.run().await.unwrap(); - }); - - let client = tokio::task::spawn_local(async move { - let (tx, rx) = oneshot::channel(); - ev.send(ServerEvent::GetLocalAddr(tx)).unwrap(); - let server_addr = rx.await.unwrap().unwrap(); - let tcp_stream = TcpStream::connect(server_addr).await.expect("TCP connect"); - let client_addr = tcp_stream.local_addr().expect("local_addr"); - let mut framed = ironrdp_tokio::TokioFramed::new(tcp_stream); - let mut connector = connector::ClientConnector::new(client_config, client_addr); - let should_upgrade = ironrdp_async::connect_begin(&mut framed, &mut connector) - .await - .expect("begin connection"); - let initial_stream = framed.into_inner_no_leftover(); - let (upgraded_stream, server_public_key) = ironrdp_tls::upgrade(initial_stream, "localhost") - .await - .expect("TLS upgrade"); - let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector); - let mut upgraded_framed = ironrdp_tokio::TokioFramed::new(upgraded_stream); - let connection_result = ironrdp_async::connect_finalize( - upgraded, - &mut upgraded_framed, - connector, - "localhost".into(), - server_public_key, - None, - None, - ) - .await - .expect("finalize connection"); - - let active_stage = ActiveStage::new(connection_result); - let (active_stage, mut upgraded_framed) = clientfn(active_stage, upgraded_framed, display_tx).await; - let outputs = active_stage.graceful_shutdown().expect("shutdown"); - for out in outputs { - match out { - ActiveStageOutput::ResponseFrame(frame) => { - upgraded_framed.write_all(&frame).await.expect("write frame"); - } - _ => unimplemented!(), - } - } - - // server should probably send TLS close_notify - while let Ok(pdu) = upgraded_framed.read_pdu().await { - debug!(?pdu); - } - ev.send(ServerEvent::Quit("bye".into())).unwrap(); - }); - - tokio::try_join!(server, client).expect("join"); - }) - .await; -} - -// Maybe implement Default for Config -fn default_client_config() -> connector::Config { - connector::Config { - desktop_size: DesktopSize { - width: DESKTOP_WIDTH, - height: DESKTOP_HEIGHT, - }, - desktop_scale_factor: 0, // Default to 0 per FreeRDP - enable_tls: true, - enable_credssp: true, - credentials: connector::Credentials::UsernamePassword { - username: USERNAME.into(), - password: PASSWORD.into(), - }, - domain: None, - client_build: semver::Version::parse(env!("CARGO_PKG_VERSION")) - .map(|version| version.major * 100 + version.minor * 10 + version.patch) - .unwrap_or(0) - .try_into() - .unwrap(), - client_name: "ironrdp".into(), - keyboard_type: gcc::KeyboardType::IbmEnhanced, - keyboard_subtype: 0, - keyboard_layout: 0, - keyboard_functional_keys_count: 12, - ime_file_name: "".into(), - bitmap: None, - dig_product_id: "".into(), - // NOTE: hardcode this value like in freerdp - // https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70 - client_dir: "C:\\Windows\\System32\\mstscax.dll".into(), - #[cfg(windows)] - platform: MajorPlatformType::WINDOWS, - #[cfg(target_os = "macos")] - platform: MajorPlatformType::MACINTOSH, - #[cfg(target_os = "ios")] - platform: MajorPlatformType::IOS, - #[cfg(target_os = "linux")] - platform: MajorPlatformType::UNIX, - #[cfg(target_os = "android")] - platform: MajorPlatformType::ANDROID, - #[cfg(target_os = "freebsd")] - platform: MajorPlatformType::UNIX, - #[cfg(target_os = "dragonfly")] - platform: MajorPlatformType::UNIX, - #[cfg(target_os = "openbsd")] - platform: MajorPlatformType::UNIX, - #[cfg(target_os = "netbsd")] - platform: MajorPlatformType::UNIX, - hardware_id: None, - request_data: None, - autologon: false, - enable_audio_playback: true, - license_cache: None, - enable_server_pointer: true, - pointer_software_rendering: true, - performance_flags: Default::default(), - timezone_info: Default::default(), - } -} diff --git a/crates/ironrdp-tls/CHANGELOG.md b/crates/ironrdp-tls/CHANGELOG.md index ec3ebe9e2b..7d50b6b2a8 100644 --- a/crates/ironrdp-tls/CHANGELOG.md +++ b/crates/ironrdp-tls/CHANGELOG.md @@ -6,6 +6,46 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.2.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-tls-v0.2.1...ironrdp-tls-v0.2.2)] - 2026-07-10 + +### Features + +- Expose negotiated TLS version and cipher suite ([#1384](https://github.com/Devolutions/IronRDP/issues/1384)) ([8f76260ea7](https://github.com/Devolutions/IronRDP/commit/8f76260ea753f546a577ad7a1176a5740adc94cf)) + + Adds a backend-neutral way to query the TLS parameters negotiated for an established + ironrdp-tls::TlsStream, enabling downstream diagnostic tooling to report the negotiated + protocol version and cipher suite alongside the existing certificate information. + +- Gate native backends behind Cargo features ([#1338](https://github.com/Devolutions/IronRDP/issues/1338)) ([f7e6106e0f](https://github.com/Devolutions/IronRDP/commit/f7e6106e0f293c1e0f8129be82aa2d86737ba92a)) + + +- Make the rustls crypto provider selectable ([#1387](https://github.com/Devolutions/IronRDP/issues/1387)) ([d767d99032](https://github.com/Devolutions/IronRDP/commit/d767d990325448bf3385974da7ea9b6dcc477673)) + + Makes the ironrdp-tls rustls backend’s crypto provider selectable at compile time by restructuring Cargo features, avoiding forcing a single provider onto downstreams via tokio-rustls default features. + + + +## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-tls-v0.2.0...ironrdp-tls-v0.2.1)] - 2026-05-27 + +### Build + +- Bump tokio from 1.50.0 to 1.52.1 ([#1219](https://github.com/Devolutions/IronRDP/issues/1219)) ([d3e673b455](https://github.com/Devolutions/IronRDP/commit/d3e673b455ec817df7590cef27e598c8517828ae)) ([#1223](https://github.com/Devolutions/IronRDP/issues/1223)) ([8bf140f49d](https://github.com/Devolutions/IronRDP/commit/8bf140f49d3bee952e395ffeb514a27c4725eb15)) + +## [[0.2.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-tls-v0.1.4...ironrdp-tls-v0.2.0)] - 2025-12-18 + +### Features + +- [**breaking**] Return x509_cert::Certificate from upgrade() ([#1054](https://github.com/Devolutions/IronRDP/issues/1054)) ([bd2aed7686](https://github.com/Devolutions/IronRDP/commit/bd2aed76867f4038c32df9a0d24532ee40d2f14c)) + + This allows client applications to verify details of the certificate, + possibly with the user, when connecting to a server using TLS. + +## [[0.1.4](https://github.com/Devolutions/IronRDP/compare/ironrdp-tls-v0.1.3...ironrdp-tls-v0.1.4)] - 2025-08-29 + +### Build + +- Bump tokio from 1.46.1 to 1.47.0 (#893) ([5d513dcf09](https://github.com/Devolutions/IronRDP/commit/5d513dcf099505d4d52fe25884dc019590bc751e)) + ## [[0.1.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-tls-v0.1.1...ironrdp-tls-v0.1.2)] - 2025-01-28 ### Documentation @@ -16,8 +56,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump tokio from 1.42.0 to 1.43.0 (#650) ([ff6c6e875b](https://github.com/Devolutions/IronRDP/commit/ff6c6e875b4c2dce7ec109c3721739f86a808a31)) - - ## [[0.1.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-tls-v0.1.0...ironrdp-tls-v0.1.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-tls/Cargo.toml b/crates/ironrdp-tls/Cargo.toml index ca6cfb5440..ddd1eba020 100644 --- a/crates/ironrdp-tls/Cargo.toml +++ b/crates/ironrdp-tls/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-tls" -version = "0.1.3" +version = "0.2.2" readme = "README.md" description = "TLS boilerplate common with most IronRDP clients" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -17,15 +18,24 @@ test = false [features] default = [] # No default feature, the user must choose a TLS backend by enabling the appropriate feature. -rustls = ["dep:tokio-rustls", "dep:x509-cert", "tokio/io-util"] +# The rustls backend. `rustls` keeps using the aws-lc-rs crypto provider (unchanged +# default); the crypto provider is otherwise selectable so downstream crates are not +# forced onto a specific one. +rustls = ["rustls-aws-lc-rs"] +rustls-aws-lc-rs = ["rustls-no-provider", "tokio-rustls/aws_lc_rs"] +rustls-ring = ["rustls-no-provider", "tokio-rustls/ring"] +# rustls backend without a bundled crypto provider: the downstream must install a +# rustls CryptoProvider as the process default before opening a connection, otherwise +# building the client configuration panics. +rustls-no-provider = ["dep:tokio-rustls", "dep:x509-cert", "tokio/io-util", "tokio-rustls/logging", "tokio-rustls/tls12"] native-tls = ["dep:tokio-native-tls", "dep:x509-cert", "tokio/io-util"] stub = [] [dependencies] -tokio = { version = "1.47" } -x509-cert = { version = "0.2", default-features = false, features = ["std"], optional = true } +tokio = { version = "1.52" } +x509-cert = { version = "0.2", default-features = false, features = ["std"], optional = true } # public tokio-native-tls = { version = "0.3", optional = true } # public -tokio-rustls = { version = "0.26", optional = true } # public +tokio-rustls = { version = "0.26", default-features = false, optional = true } # public [lints] workspace = true diff --git a/crates/ironrdp-tls/README.md b/crates/ironrdp-tls/README.md index f1f0b2bc7c..9ef9b84d2a 100644 --- a/crates/ironrdp-tls/README.md +++ b/crates/ironrdp-tls/README.md @@ -2,16 +2,27 @@ TLS boilerplate common with most IronRDP clients. -This crate exposes three features for selecting the TLS backend: +This crate exposes features for selecting the TLS backend: -- `rustls`: use the rustls crate. +- `rustls`: use the rustls crate (with the default aws-lc-rs crypto provider). - `native-tls`: use the native-tls crate. - `stub`: use a stubbed backend which fail at runtime when used. -These features are mutually exclusive and only one may be enabled at a time. +These backends are mutually exclusive and only one may be enabled at a time. When more than one backend is enabled, a compile-time error is emitted. For this reason, no feature is enabled by default. +When the rustls backend is used, its crypto provider is selectable so downstream +crates are not forced onto a specific one: + +- `rustls` or `rustls-aws-lc-rs`: the aws-lc-rs provider. `rustls` is an alias for + `rustls-aws-lc-rs`, so the default backend is unchanged. +- `rustls-ring`: the ring provider. +- `rustls-no-provider`: no provider is bundled. The downstream must install a rustls + `CryptoProvider` as the process default before opening a connection, otherwise + building the client configuration panics. Use this to plug in a custom or pure-Rust + provider. + The rationale is two-fold: - It makes deliberate the choice of the TLS backend. diff --git a/crates/ironrdp-tls/src/lib.rs b/crates/ironrdp-tls/src/lib.rs index 632618904d..1ab522bbcf 100644 --- a/crates/ironrdp-tls/src/lib.rs +++ b/crates/ironrdp-tls/src/lib.rs @@ -1,7 +1,7 @@ #![cfg_attr(doc, doc = include_str!("../README.md"))] #![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] -#[cfg(feature = "rustls")] +#[cfg(feature = "rustls-no-provider")] #[path = "rustls.rs"] mod impl_; @@ -14,32 +14,36 @@ mod impl_; mod impl_; #[cfg(any( - not(any(feature = "stub", feature = "native-tls", feature = "rustls")), + not(any(feature = "stub", feature = "native-tls", feature = "rustls-no-provider")), all(feature = "stub", feature = "native-tls"), - all(feature = "stub", feature = "rustls"), - all(feature = "rustls", feature = "native-tls"), + all(feature = "stub", feature = "rustls-no-provider"), + all(feature = "rustls-no-provider", feature = "native-tls"), ))] -compile_error!("a TLS backend must be selected by enabling a single feature out of: `rustls`, `native-tls`, `stub`"); +compile_error!( + "a TLS backend must be selected by enabling a single feature out of: `rustls`, `native-tls`, `stub` (the rustls crypto provider is chosen via `rustls`/`rustls-aws-lc-rs`/`rustls-ring`/`rustls-no-provider`)" +); // The whole public API of this crate. -#[cfg(any(feature = "stub", feature = "native-tls", feature = "rustls"))] -pub use impl_::{upgrade, TlsStream}; - -#[cfg(any(feature = "native-tls", feature = "rustls"))] -pub(crate) fn extract_tls_server_public_key(cert: &[u8]) -> std::io::Result> { - use std::io; - - use x509_cert::der::Decode as _; - - let cert = x509_cert::Certificate::from_der(cert).map_err(io::Error::other)?; +#[cfg(any(feature = "stub", feature = "native-tls", feature = "rustls-no-provider"))] +pub use impl_::{TlsStream, negotiated, upgrade}; + +/// TLS parameters negotiated during the handshake, to the extent the active +/// backend exposes them. +/// +/// The `rustls` backend reports both fields. The `native-tls` and `stub` +/// backends cannot introspect the negotiated parameters, so both are `None` +/// there. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct NegotiatedTls { + /// Negotiated protocol version, e.g. `"TLSv1_3"`. + pub version: Option, + /// Negotiated cipher suite, e.g. `"TLS13_AES_256_GCM_SHA384"`. + pub cipher_suite: Option, +} - let server_public_key = cert - .tbs_certificate +pub fn extract_tls_server_public_key(cert: &x509_cert::Certificate) -> Option<&[u8]> { + cert.tbs_certificate .subject_public_key_info .subject_public_key .as_bytes() - .ok_or_else(|| io::Error::other("subject public key BIT STRING is not aligned"))? - .to_owned(); - - Ok(server_public_key) } diff --git a/crates/ironrdp-tls/src/native_tls.rs b/crates/ironrdp-tls/src/native_tls.rs index 0e652e7ee4..2b804e7fa9 100644 --- a/crates/ironrdp-tls/src/native_tls.rs +++ b/crates/ironrdp-tls/src/native_tls.rs @@ -4,7 +4,7 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _}; pub type TlsStream = tokio_native_tls::TlsStream; -pub async fn upgrade(stream: S, server_name: &str) -> io::Result<(TlsStream, Vec)> +pub async fn upgrade(stream: S, server_name: &str) -> io::Result<(TlsStream, x509_cert::Certificate)> where S: Unpin + AsyncRead + AsyncWrite, { @@ -14,25 +14,30 @@ where .use_sni(false) .build() .map(tokio_native_tls::TlsConnector::from) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + .map_err(io::Error::other)?; - connector - .connect(server_name, stream) - .await - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))? + connector.connect(server_name, stream).await.map_err(io::Error::other)? }; tls_stream.flush().await?; - let server_public_key = { + let tls_cert = { + use x509_cert::der::Decode as _; + let cert = tls_stream .get_ref() .peer_certificate() - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))? - .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "peer certificate is missing"))?; - let cert = cert.to_der().map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; - crate::extract_tls_server_public_key(&cert)? + .map_err(io::Error::other)? + .ok_or_else(|| io::Error::other("peer certificate is missing"))?; + let cert = cert.to_der().map_err(io::Error::other)?; + + x509_cert::Certificate::from_der(&cert).map_err(io::Error::other)? }; - Ok((tls_stream, server_public_key)) + Ok((tls_stream, tls_cert)) +} + +/// The `native-tls` backend does not expose the negotiated version or cipher. +pub fn negotiated(_stream: &TlsStream) -> crate::NegotiatedTls { + crate::NegotiatedTls::default() } diff --git a/crates/ironrdp-tls/src/rustls.rs b/crates/ironrdp-tls/src/rustls.rs index 10c8d8ab7f..29ac8643ba 100644 --- a/crates/ironrdp-tls/src/rustls.rs +++ b/crates/ironrdp-tls/src/rustls.rs @@ -1,12 +1,12 @@ use std::io; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _}; +use tokio_rustls::rustls; use tokio_rustls::rustls::pki_types::ServerName; -use tokio_rustls::rustls::{self}; pub type TlsStream = tokio_rustls::client::TlsStream; -pub async fn upgrade(stream: S, server_name: &str) -> io::Result<(TlsStream, Vec)> +pub async fn upgrade(stream: S, server_name: &str) -> io::Result<(TlsStream, x509_cert::Certificate)> where S: Unpin + AsyncRead + AsyncWrite, { @@ -35,22 +35,36 @@ where tls_stream.flush().await?; - let server_public_key = { + let tls_cert = { + use x509_cert::der::Decode as _; + let cert = tls_stream .get_ref() .1 .peer_certificates() .and_then(|certificates| certificates.first()) .ok_or_else(|| io::Error::other("peer certificate is missing"))?; - crate::extract_tls_server_public_key(cert)? + + x509_cert::Certificate::from_der(cert).map_err(io::Error::other)? }; - Ok((tls_stream, server_public_key)) + Ok((tls_stream, tls_cert)) +} + +/// Report the TLS version and cipher suite negotiated for `stream`. +pub fn negotiated(stream: &TlsStream) -> crate::NegotiatedTls { + let (_, connection) = stream.get_ref(); + crate::NegotiatedTls { + version: connection.protocol_version().map(|version| format!("{version:?}")), + cipher_suite: connection + .negotiated_cipher_suite() + .map(|suite| format!("{:?}", suite.suite())), + } } mod danger { use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; - use tokio_rustls::rustls::{pki_types, DigitallySignedStruct, Error, SignatureScheme}; + use tokio_rustls::rustls::{DigitallySignedStruct, Error, SignatureScheme, pki_types}; #[derive(Debug)] pub(super) struct NoCertificateVerification; diff --git a/crates/ironrdp-tls/src/stub.rs b/crates/ironrdp-tls/src/stub.rs index 484979d61d..500066450d 100644 --- a/crates/ironrdp-tls/src/stub.rs +++ b/crates/ironrdp-tls/src/stub.rs @@ -37,3 +37,8 @@ where let _ = (stream, server_name); Err(io::Error::other("no TLS backend enabled for this build")) } + +/// The stub backend performs no handshake and reports nothing. +pub fn negotiated(_stream: &TlsStream) -> crate::NegotiatedTls { + crate::NegotiatedTls::default() +} diff --git a/crates/ironrdp-tokio/CHANGELOG.md b/crates/ironrdp-tokio/CHANGELOG.md index 4265c72b1d..893bfda462 100644 --- a/crates/ironrdp-tokio/CHANGELOG.md +++ b/crates/ironrdp-tokio/CHANGELOG.md @@ -6,6 +6,66 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.10.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-tokio-v0.9.0...ironrdp-tokio-v0.10.0)] - 2026-07-10 + +### Build + +- [**breaking**] Update `ironrdp-async` public dependency to 0.10 + +- [**breaking**] Update `ironrdp-pdu` public dependency to 0.9 + + + +## [[0.9.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-tokio-v0.8.0...ironrdp-tokio-v0.9.0)] - 2026-05-27 + +### Build + +- [**breaking**] Upgrade sspi + + +## [[0.8.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-tokio-v0.7.0...ironrdp-tokio-v0.8.0)] - 2025-12-18 + +### Features + +- Add MovableTokioFramed for Send+!Sync context ([#1033](https://github.com/Devolutions/IronRDP/issues/1033)) ([966ba8a53e](https://github.com/Devolutions/IronRDP/commit/966ba8a53e43a193271f40b9db80e45e495e2f24)) + + The `ironrdp-tokio` crate currently provides the following two + `Framed` implementations using the standard `tokio::io` traits: + - `type TokioFramed = Framed>` where `S: Send + Sync + + Unpin` + - `type LocalTokioFramed = Framed>` where `S: + Unpin` + + The former is meant for multi-threaded runtimes and the latter is meant + for single-threaded runtimes. + + This PR adds a third `Framed` implementation: + + `pub type MovableTokioFramed = Framed>` where + `S: Send + Unpin` + + This is a valid usecase as some implementations of the `tokio::io` + traits are `Send` but `!Sync`. Without this new third type, consumers of + `Framed` who have a `S: Send + !Sync` trait for their streams are + forced to downgrade to `LocalTokioFramed` and do some hacky workaround + with `tokio::task::spawn_blocking` since the defined associated futures, + `ReadFut` and `WriteAllFut`, are neither `Send` nor `Sync`. + +### Bug Fixes + +- [**breaking**] Use static dispatch for NetworkClient trait ([#1043](https://github.com/Devolutions/IronRDP/issues/1043)) ([bca6d190a8](https://github.com/Devolutions/IronRDP/commit/bca6d190a870708468534d224ff225a658767a9a)) + + - Rename `AsyncNetworkClient` to `NetworkClient` + - Replace dynamic dispatch (`Option<&mut dyn ...>`) with static dispatch + using generics (`&mut N where N: NetworkClient`) + - Reorder `connect_finalize` parameters for consistency across crates + +### Build + +- Bump picky and sspi ([#1028](https://github.com/Devolutions/IronRDP/issues/1028)) ([5bd319126d](https://github.com/Devolutions/IronRDP/commit/5bd319126d32fbd8e505508e27ab2b1a18a83d04)) + + This fixes build issues with some dependencies. + ## [[0.6.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-tokio-v0.5.1...ironrdp-tokio-v0.6.0)] - 2025-07-08 ### Build @@ -49,7 +109,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use CDN URLs instead of the blob storage URLs for Devolutions logo (#631) ([dd249909a8](https://github.com/Devolutions/IronRDP/commit/dd249909a894004d4f728d30b3a4aa77a0f8193b)) - ## [[0.2.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-tokio-v0.2.0...ironrdp-tokio-v0.2.1)] - 2024-12-14 ### Other diff --git a/crates/ironrdp-tokio/Cargo.toml b/crates/ironrdp-tokio/Cargo.toml index 4a25ef2d9e..8dc421c475 100644 --- a/crates/ironrdp-tokio/Cargo.toml +++ b/crates/ironrdp-tokio/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp-tokio" -version = "0.6.0" +version = "0.10.0" readme = "README.md" description = "`Framed*` traits implementation above Tokio’s traits" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -17,17 +18,15 @@ test = false [features] default = [] -reqwest = ["dep:reqwest", "dep:sspi", "dep:url", "dep:ironrdp-connector"] +reqwest = ["dep:reqwest", "dep:url", "dep:ironrdp-connector"] reqwest-rustls-ring = ["reqwest", "reqwest?/rustls-tls-webpki-roots"] reqwest-native-tls = ["reqwest", "reqwest?/native-tls"] [dependencies] -bytes = "1" -ironrdp-async = { path = "../ironrdp-async", version = "0.6" } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.6", optional = true } +ironrdp-async = { path = "../ironrdp-async", version = "0.10" } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.10", optional = true } tokio = { version = "1", features = ["io-util"] } reqwest = { version = "0.12", default-features = false, features = ["http2", "system-proxy"], optional = true } -sspi = { version = "0.16", features = ["network_client", "dns_resolver"], optional = true } url = { version = "2.5", optional = true } [lints] diff --git a/crates/ironrdp-tokio/src/lib.rs b/crates/ironrdp-tokio/src/lib.rs index 010b221942..3a3f463732 100644 --- a/crates/ironrdp-tokio/src/lib.rs +++ b/crates/ironrdp-tokio/src/lib.rs @@ -64,7 +64,7 @@ where S: Send + Sync + Unpin + AsyncRead, { type ReadFut<'read> - = Pin> + Send + Sync + 'read>> + = Pin> + Send + Sync + 'read>> where Self: 'read; @@ -80,7 +80,7 @@ where S: Send + Sync + Unpin + AsyncWrite, { type WriteAllFut<'write> - = Pin> + Send + Sync + 'write>> + = Pin> + Send + Sync + 'write>> where Self: 'write; @@ -127,7 +127,7 @@ where S: Unpin + AsyncRead, { type ReadFut<'read> - = Pin> + 'read>> + = Pin> + 'read>> where Self: 'read; @@ -143,7 +143,70 @@ where S: Unpin + AsyncWrite, { type WriteAllFut<'write> - = Pin> + 'write>> + = Pin> + 'write>> + where + Self: 'write; + + fn write_all<'a>(&'a mut self, buf: &'a [u8]) -> Self::WriteAllFut<'a> { + use tokio::io::AsyncWriteExt as _; + + Box::pin(async { + self.inner.write_all(buf).await?; + self.inner.flush().await?; + + Ok(()) + }) + } +} + +pub type MovableTokioFramed = Framed>; + +pub struct MovableTokioStream { + inner: S, +} + +impl StreamWrapper for MovableTokioStream { + type InnerStream = S; + + fn from_inner(stream: Self::InnerStream) -> Self { + Self { inner: stream } + } + + fn into_inner(self) -> Self::InnerStream { + self.inner + } + + fn get_inner(&self) -> &Self::InnerStream { + &self.inner + } + + fn get_inner_mut(&mut self) -> &mut Self::InnerStream { + &mut self.inner + } +} + +impl FramedRead for MovableTokioStream +where + S: Send + Unpin + AsyncRead, +{ + type ReadFut<'read> + = Pin> + Send + 'read>> + where + Self: 'read; + + fn read<'a>(&'a mut self, buf: &'a mut BytesMut) -> Self::ReadFut<'a> { + use tokio::io::AsyncReadExt as _; + + Box::pin(async { self.inner.read_buf(buf).await }) + } +} + +impl FramedWrite for MovableTokioStream +where + S: Send + Unpin + AsyncWrite, +{ + type WriteAllFut<'write> + = Pin> + Send + 'write>> where Self: 'write; diff --git a/crates/ironrdp-tokio/src/reqwest.rs b/crates/ironrdp-tokio/src/reqwest.rs index 9a2fb6e8fc..efda1b6061 100644 --- a/crates/ironrdp-tokio/src/reqwest.rs +++ b/crates/ironrdp-tokio/src/reqwest.rs @@ -1,26 +1,21 @@ -use core::future::Future; use core::net::{IpAddr, Ipv4Addr}; -use core::pin::Pin; -use ironrdp_connector::{custom_err, ConnectorResult}; +use ironrdp_connector::sspi::{self, Error, ErrorKind}; +use ironrdp_connector::{ConnectorResult, custom_err, general_err}; use reqwest::Client; -use sspi::{Error, ErrorKind}; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use tokio::net::{TcpStream, UdpSocket}; use url::Url; -use crate::AsyncNetworkClient; +use crate::NetworkClient; pub struct ReqwestNetworkClient { client: Option, } -impl AsyncNetworkClient for ReqwestNetworkClient { - fn send<'a>( - &'a mut self, - network_request: &'a sspi::generator::NetworkRequest, - ) -> Pin>> + 'a>> { - Box::pin(ReqwestNetworkClient::send_request(self, network_request)) +impl NetworkClient for ReqwestNetworkClient { + async fn send(&mut self, network_request: &sspi::generator::NetworkRequest) -> ConnectorResult> { + ReqwestNetworkClient::send_request(self, network_request).await } } @@ -28,15 +23,7 @@ impl ReqwestNetworkClient { pub fn new() -> Self { Self { client: None } } -} - -impl Default for ReqwestNetworkClient { - fn default() -> Self { - Self::new() - } -} -impl ReqwestNetworkClient { pub async fn send_request<'a>( &'a mut self, request: &'a sspi::generator::NetworkRequest, @@ -70,8 +57,14 @@ impl ReqwestNetworkClient { .map_err(|e| Error::new(ErrorKind::NoAuthenticatingAuthority, format!("{e:?}"))) .map_err(|e| custom_err!("failed to send KDC request over TCP", e))?; - let mut buf = vec![0; len as usize + 4]; - buf[0..4].copy_from_slice(&(len.to_be_bytes())); + let len = usize::try_from(len) + .map_err(|_| general_err!("invalid buffer length: out of range integral type conversion"))?; + + let mut buf = vec![0; len + 4]; + // Write the length prefix back as a big-endian u32 (matching the wire format). + // `len` was originally read as a `u32` so this conversion cannot fail. + let len_u32 = u32::try_from(len).map_err(|_| general_err!("KDC TCP response length overflows u32"))?; + buf[0..4].copy_from_slice(&len_u32.to_be_bytes()); stream .read_exact(&mut buf[4..]) @@ -134,3 +127,9 @@ impl ReqwestNetworkClient { Ok(body) } } + +impl Default for ReqwestNetworkClient { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/ironrdp-viewer/CHANGELOG.md b/crates/ironrdp-viewer/CHANGELOG.md new file mode 100644 index 0000000000..d146d9388d --- /dev/null +++ b/crates/ironrdp-viewer/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [[0.1.0](https://github.com/Devolutions/IronRDP/releases/tag/ironrdp-viewer-v0.1.0)] - 2026-07-10 + +Initial release. diff --git a/crates/ironrdp-viewer/Cargo.toml b/crates/ironrdp-viewer/Cargo.toml new file mode 100644 index 0000000000..96dc099e25 --- /dev/null +++ b/crates/ironrdp-viewer/Cargo.toml @@ -0,0 +1,62 @@ +[package] +name = "ironrdp-viewer" +version = "0.1.0" +readme = "README.md" +description = "Portable RDP viewer (GUI binary) without GPU acceleration" +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +authors.workspace = true +keywords.workspace = true +categories.workspace = true +default-run = "ironrdp-viewer" + +[lib] +doctest = false +test = false + +[[bin]] +name = "ironrdp-viewer" +test = false + +[features] +default = ["rustls"] +rustls = ["ironrdp/rustls"] +native-tls = ["ironrdp/native-tls"] +qoi = ["ironrdp/qoi"] +qoiz = ["ironrdp/qoiz"] + +[dependencies] +ironrdp = { path = "../ironrdp", version = "0.17", features = ["connector", "cliprdr", "input", "pdu", "client", "client-all"] } +ironrdp-cfg = { path = "../ironrdp-cfg", version = "0.1" } +ironrdp-propertyset = { path = "../ironrdp-propertyset", version = "0.1" } +ironrdp-rdpfile = { path = "../ironrdp-rdpfile", version = "0.1" } + +# Windowing and rendering +winit = { version = "0.30", features = ["rwh_06"] } +softbuffer = "0.4" + +# CLI +clap = { version = "4.6", features = ["derive", "cargo"] } +inquire = "0.9" +proc-exit = "2" + +# Logging +tracing = { version = "0.1", features = ["log"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Async, futures +tokio = { version = "1", features = ["full"] } + +# Utils +whoami = "2.1" +anyhow = "1" +smallvec = "1.15" +tap = "1" +semver = "1" +raw-window-handle = "0.6" +url = "2" + +[lints] +workspace = true diff --git a/crates/ironrdp-viewer/README.md b/crates/ironrdp-viewer/README.md new file mode 100644 index 0000000000..2361ec078a --- /dev/null +++ b/crates/ironrdp-viewer/README.md @@ -0,0 +1,90 @@ +# IronRDP Viewer + +Portable RDP client without GPU acceleration. + +This is a a full-fledged RDP client based on IronRDP crates suite, and implemented using +non-blocking, asynchronous I/O. Portability is achieved by using softbuffer for rendering +and winit for windowing. + +## Prebuilt binaries + +Prebuilt, checksummed archives are attached to each GitHub Release under the `ironrdp-viewer-v*` +tags. See the [Releases page](https://github.com/Devolutions/IronRDP/releases) for per-platform +download and verification instructions. + +## Sample usage + +```shell +ironrdp-viewer --username --password +``` + +## `.rdp` file support + +You can load a `.rdp` file with `--rdp-file `. + +Currently supported properties: + +- `full address:s:` +- `alternate full address:s:` +- `server port:i:` +- `username:s:` +- `ClearTextPassword:s:` +- `domain:s:` +- `enablecredsspsupport:i:<0|1>` +- `gatewayhostname:s:` +- `gatewayusagemethod:i:` +- `gatewaycredentialssource:i:` +- `gatewayusername:s:` +- `GatewayPassword:s:` +- `kdcproxyurl:s:` (also `KDCProxyURL:s:`) +- `kdcproxyname:s:` +- `alternate shell:s:` +- `shell working directory:s:` +- `redirectclipboard:i:<0|1>` +- `audiomode:i:<0|1|2>` +- `desktopwidth:i:` +- `desktopheight:i:` +- `desktopscalefactor:i:` +- `compression:i:<0|1>` + +Property precedence is: + +1. CLI options +2. `.rdp` file values +3. Defaults and interactive prompts + +Unknown or unsupported `.rdp` properties are ignored and do not cause parsing failures. Parse +issues are reported to stderr. + + +The `IRONRDP_LOG` environment variable is used to set the log filter directives. + +```shell +IRONRDP_LOG="info,ironrdp_connector=trace" ironrdp-viewer --username --password +``` + +See [`tracing-subscriber`'s documentation][tracing-doc] for more details. + +[tracing-doc]: https://docs.rs/tracing-subscriber/0.3.17/tracing_subscriber/filter/struct.EnvFilter.html#directives + +## Support for `SSLKEYLOGFILE` + +This client supports reading the `SSLKEYLOGFILE` environment variable. +When set, the TLS encryption secrets for the session will be dumped to the file specified +by the environment variable. +This file can be read by Wireshark so that in can decrypt the packets. + +### Example + +```shell +SSLKEYLOGFILE=/tmp/tls-secrets ironrdp-viewer --username --password +``` + +### Usage in Wireshark + +See this [awakecoding's repository][awakecoding-repository] explaining how to use the file in wireshark. + +This crate is part of the [IronRDP] project. + +[IronRDP]: https://github.com/Devolutions/IronRDP +[awakecoding-repository]: https://github.com/awakecoding/wireshark-rdp#sslkeylogfile diff --git a/crates/ironrdp-client/src/app.rs b/crates/ironrdp-viewer/src/app.rs similarity index 87% rename from crates/ironrdp-client/src/app.rs rename to crates/ironrdp-viewer/src/app.rs index 81aca9960c..ad3f47ed5b 100644 --- a/crates/ironrdp-client/src/app.rs +++ b/crates/ironrdp-viewer/src/app.rs @@ -5,9 +5,13 @@ use core::time::Duration; use std::sync::Arc; use std::time::Instant; +use anyhow::Context as _; +use ironrdp::client::rdp::{RdpInputEvent, RdpOutputEvent}; +use ironrdp::pdu::input::fast_path::FastPathInputEvent; use raw_window_handle::{DisplayHandle, HasDisplayHandle as _}; +use smallvec::SmallVec; use tokio::sync::mpsc; -use tracing::{debug, error, trace}; +use tracing::{debug, error, trace, warn}; use winit::application::ApplicationHandler; use winit::dpi::{LogicalPosition, PhysicalSize}; use winit::event::{self, WindowEvent}; @@ -15,13 +19,12 @@ use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; use winit::platform::scancode::PhysicalKeyExtScancode as _; use winit::window::{CursorIcon, CustomCursor, Window, WindowAttributes}; -use crate::rdp::{RdpInputEvent, RdpOutputEvent}; - type WindowSurface = (Arc, softbuffer::Surface, Arc>); pub struct App { input_event_sender: mpsc::UnboundedSender, context: softbuffer::Context>, + initial_window_size: PhysicalSize, window: Option, buffer: Vec, buffer_size: (u16, u16), @@ -34,11 +37,14 @@ impl App { pub fn new( event_loop: &EventLoop, input_event_sender: &mpsc::UnboundedSender, + initial_window_size: PhysicalSize, ) -> anyhow::Result { // SAFETY: We drop the softbuffer context right before the event loop is stopped, thus making this safe. // FIXME: This is not a sufficient proof and the API is actually unsound as-is. let display_handle = unsafe { - core::mem::transmute::, DisplayHandle<'static>>(event_loop.display_handle().unwrap()) + core::mem::transmute::, DisplayHandle<'static>>( + event_loop.display_handle().context("get display handle")?, + ) }; let context = softbuffer::Context::new(display_handle) .map_err(|e| anyhow::anyhow!("unable to initialize softbuffer context: {e}"))?; @@ -47,6 +53,7 @@ impl App { Ok(Self { input_event_sender: input_event_sender.clone(), context, + initial_window_size, window: None, buffer: Vec::new(), buffer_size: (0, 0), @@ -63,11 +70,15 @@ impl App { let Some((window, _)) = self.window.as_mut() else { return; }; + #[expect(clippy::as_conversions, reason = "casting f64 to u32")] let scale_factor = (window.scale_factor() * 100.0) as u32; + let width = u16::try_from(size.width).expect("reasonable width"); + let height = u16::try_from(size.height).expect("reasonable height"); + let _ = self.input_event_sender.send(RdpInputEvent::Resize { - width: u16::try_from(size.width).unwrap(), - height: u16::try_from(size.height).unwrap(), + width, + height, scale_factor, // TODO: it should be possible to get the physical size here, however winit doesn't make it straightforward. // FreeRDP does it based on DPI reading grabbed via [`SDL_GetDisplayDPI`](https://wiki.libsdl.org/SDL2/SDL_GetDisplayDPI): @@ -104,7 +115,9 @@ impl ApplicationHandler for App { } fn resumed(&mut self, event_loop: &ActiveEventLoop) { - let window_attributes = WindowAttributes::default().with_title("IronRDP"); + let window_attributes = WindowAttributes::default() + .with_title("IronRDP") + .with_inner_size(self.initial_window_size); match event_loop.create_window(window_attributes) { Ok(window) => { let window = Arc::new(window); @@ -160,7 +173,14 @@ impl ApplicationHandler for App { // } WindowEvent::KeyboardInput { event, .. } => { if let Some(scancode) = event.physical_key.to_scancode() { - let scancode = ironrdp::input::Scancode::from_u16(u16::try_from(scancode).unwrap()); + let scancode = match u16::try_from(scancode) { + Ok(scancode) => scancode, + Err(_) => { + warn!("Unsupported scancode: `{scancode:#X}`; ignored"); + return; + } + }; + let scancode = ironrdp::input::Scancode::from_u16(scancode); let operation = match event.state { event::ElementState::Pressed => ironrdp::input::Operation::KeyPressed(scancode), @@ -178,7 +198,7 @@ impl ApplicationHandler for App { const ALT_LEFT: ironrdp::input::Scancode = ironrdp::input::Scancode::from_u8(false, 0x38); const LOGO_LEFT: ironrdp::input::Scancode = ironrdp::input::Scancode::from_u8(true, 0x5B); - let mut operations = smallvec::SmallVec::<[ironrdp::input::Operation; 4]>::new(); + let mut operations = SmallVec::<[ironrdp::input::Operation; 4]>::new(); let mut add_operation = |pressed: bool, scancode: ironrdp::input::Scancode| { let operation = if pressed { @@ -209,8 +229,10 @@ impl ApplicationHandler for App { } WindowEvent::CursorMoved { position, .. } => { let win_size = window.inner_size(); - let x = (position.x / win_size.width as f64 * self.buffer_size.0 as f64) as u16; - let y = (position.y / win_size.height as f64 * self.buffer_size.1 as f64) as u16; + #[expect(clippy::as_conversions, reason = "casting f64 to u16")] + let x = (position.x / f64::from(win_size.width) * f64::from(self.buffer_size.0)) as u16; + #[expect(clippy::as_conversions, reason = "casting f64 to u16")] + let y = (position.y / f64::from(win_size.height) * f64::from(self.buffer_size.1)) as u16; let operation = ironrdp::input::Operation::MouseMove(ironrdp::input::MousePosition { x, y }); let input_events = self.input_database.apply(core::iter::once(operation)); @@ -218,7 +240,7 @@ impl ApplicationHandler for App { send_fast_path_events(&self.input_event_sender, input_events); } WindowEvent::MouseWheel { delta, .. } => { - let mut operations = smallvec::SmallVec::<[ironrdp::input::Operation; 2]>::new(); + let mut operations = SmallVec::<[ironrdp::input::Operation; 2]>::new(); match delta { event::MouseScrollDelta::LineDelta(delta_x, delta_y) => { @@ -226,6 +248,7 @@ impl ApplicationHandler for App { operations.push(ironrdp::input::Operation::WheelRotations( ironrdp::input::WheelRotations { is_vertical: false, + #[expect(clippy::as_conversions, reason = "casting f32 to i16")] rotation_units: (delta_x * 100.) as i16, }, )); @@ -235,6 +258,7 @@ impl ApplicationHandler for App { operations.push(ironrdp::input::Operation::WheelRotations( ironrdp::input::WheelRotations { is_vertical: true, + #[expect(clippy::as_conversions, reason = "casting f32 to i16")] rotation_units: (delta_y * 100.) as i16, }, )); @@ -245,6 +269,7 @@ impl ApplicationHandler for App { operations.push(ironrdp::input::Operation::WheelRotations( ironrdp::input::WheelRotations { is_vertical: false, + #[expect(clippy::as_conversions, reason = "casting f64 to i16")] rotation_units: delta.x as i16, }, )); @@ -254,6 +279,7 @@ impl ApplicationHandler for App { operations.push(ironrdp::input::Operation::WheelRotations( ironrdp::input::WheelRotations { is_vertical: true, + #[expect(clippy::as_conversions, reason = "casting f64 to i16")] rotation_units: delta.y as i16, }, )); @@ -325,13 +351,10 @@ impl ApplicationHandler for App { RdpOutputEvent::Image { buffer, width, height } => { trace!(width = ?width, height = ?height, "Received image with size"); trace!(window_physical_size = ?window.inner_size(), "Drawing image to the window with size"); - self.buffer_size = (width, height); + self.buffer_size = (width.get(), height.get()); self.buffer = buffer; surface - .resize( - NonZeroU32::new(u32::from(width)).unwrap(), - NonZeroU32::new(u32::from(height)).unwrap(), - ) + .resize(NonZeroU32::from(width), NonZeroU32::from(height)) .expect("surface resize"); window.request_redraw(); @@ -389,7 +412,7 @@ impl ApplicationHandler for App { fn send_fast_path_events( input_event_sender: &mpsc::UnboundedSender, - input_events: smallvec::SmallVec<[ironrdp::pdu::input::fast_path::FastPathInputEvent; 2]>, + input_events: SmallVec<[FastPathInputEvent; 2]>, ) { if !input_events.is_empty() { let _ = input_event_sender.send(RdpInputEvent::FastPath(input_events)); diff --git a/crates/ironrdp-viewer/src/cli.rs b/crates/ironrdp-viewer/src/cli.rs new file mode 100644 index 0000000000..c03a4a0e39 --- /dev/null +++ b/crates/ironrdp-viewer/src/cli.rs @@ -0,0 +1,506 @@ +#![allow(clippy::print_stdout, clippy::print_stderr)] + +use core::time::Duration; +use std::path::PathBuf; + +use anyhow::Context as _; +use clap::Parser; +use clap::clap_derive::ValueEnum; +use ironrdp::client::config::{ + ClipboardType as ResolvedClipboardType, Config, ConfigBuilder, Destination, DvcProxyInfo, MissingField, + TransportKind, +}; +use ironrdp::pdu::rdp::capability_sets::{MajorPlatformType, client_codecs_capabilities}; +use ironrdp_cfg::PropertySetExt as _; +use tap::prelude::*; +use url::Url; + +/// CLI selection for the clipboard backend. +/// +/// Maps directly into the library's [`ResolvedClipboardType`] when the typed [`Config`] is built. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +pub enum ClipboardType { + /// Enable clipboard redirection (use the best available backend). + Enable, + /// Disable clipboard redirection entirely. + Disable, + /// Use a stub clipboard backend (for testing or headless usage). + Stub, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +pub enum KeyboardType { + IbmPcXt, + OlivettiIco, + IbmPcAt, + IbmEnhanced, + Nokia1050, + Nokia9140, + Japanese, +} + +impl KeyboardType { + fn into_pdu(self) -> ironrdp::pdu::gcc::KeyboardType { + match self { + KeyboardType::IbmEnhanced => ironrdp::pdu::gcc::KeyboardType::IbmEnhanced, + KeyboardType::IbmPcAt => ironrdp::pdu::gcc::KeyboardType::IbmPcAt, + KeyboardType::IbmPcXt => ironrdp::pdu::gcc::KeyboardType::IbmPcXt, + KeyboardType::OlivettiIco => ironrdp::pdu::gcc::KeyboardType::OlivettiIco, + KeyboardType::Nokia1050 => ironrdp::pdu::gcc::KeyboardType::Nokia1050, + KeyboardType::Nokia9140 => ironrdp::pdu::gcc::KeyboardType::Nokia9140, + KeyboardType::Japanese => ironrdp::pdu::gcc::KeyboardType::Japanese, + } + } +} + +/// Devolutions IronRDP viewer +#[derive(Parser, Debug)] +#[clap(author = "Devolutions", about = "Devolutions-IronRDP viewer")] +#[clap(version, long_about = None)] +struct Args { + /// A file with IronRDP viewer logs + #[clap(short, long, value_parser)] + log_file: Option, + + #[clap(long, value_parser)] + gw_endpoint: Option, + #[clap(long, value_parser)] + gw_user: Option, + #[clap(long, value_parser)] + gw_pass: Option, + + /// An address on which the client will connect. + destination: Option, + + /// Path to a .rdp file to read the configuration from. + #[clap(long)] + rdp_file: Option, + + /// A target RDP server user name + #[clap(short, long)] + username: Option, + + /// An optional target RDP server domain name + #[clap(short, long)] + domain: Option, + + /// A target RDP server user password + #[clap(short, long)] + password: Option, + + /// Proxy URL to connect to for the RDCleanPath + /// + /// The accompanying token may be supplied via `--rdcleanpath-token` or entered interactively. + #[clap(long)] + rdcleanpath_url: Option, + + /// Authentication token to insert in the RDCleanPath packet + #[clap(long, requires("rdcleanpath_url"))] + rdcleanpath_token: Option, + + /// The keyboard type + #[clap(long, value_enum, default_value_t = KeyboardType::IbmEnhanced)] + keyboard_type: KeyboardType, + + /// The keyboard subtype (an original equipment manufacturer-dependent value) + #[clap(long, default_value_t = 0)] + keyboard_subtype: u32, + + /// The number of function keys on the keyboard + #[clap(long, default_value_t = 12)] + keyboard_functional_keys_count: u32, + + /// The input method editor (IME) file name associated with the active input locale + #[clap(long, default_value_t = String::from(""))] + ime_file_name: String, + + /// Contains a value that uniquely identifies the client + #[clap(long, default_value_t = String::from(""))] + dig_product_id: String, + + /// Scaling factor for desktop applications, percentage (value between 100 and 500) + #[clap(long, value_parser = clap::value_parser!(u32).range(100..=500))] + scale_desktop: Option, + + /// Desired desktop width for the RDP session + #[clap(long, value_parser = clap::value_parser!(u16).range(1..=8192))] + desktop_width: Option, + + /// Desired desktop height for the RDP session + #[clap(long, value_parser = clap::value_parser!(u16).range(1..=8192))] + desktop_height: Option, + + /// Set required color depth. Currently only 32 and 16 bit color depths are supported + #[clap(long)] + color_depth: Option, + + /// Ignore mouse pointer messages sent by the server. Increases performance when enabled, as the + /// client could skip costly software rendering of the pointer with alpha blending + #[clap(long)] + no_server_pointer: bool, + + /// Automatically logon to the server by passing the INFO_AUTOLOGON flag + /// + /// This flag is ignored if CredSSP authentication is used. + /// You can use `--no-credssp` to ensure it's not. + #[clap(long)] + autologon: bool, + + /// Disable TLS + Graphical login (legacy authentication method) + /// + /// Disabling this in order to enforce usage of CredSSP (NLA) is recommended. + #[clap(long)] + no_tls: bool, + + /// Disable TLS + Network Level Authentication (NLA) using CredSSP + /// + /// NLA is used to authenticates RDP clients and servers before sending credentials over the network. + /// It's not recommended to disable this. + #[clap(long, alias = "no-nla")] + no_credssp: bool, + + /// The clipboard type + #[clap(long, value_enum, default_value_t = ClipboardType::Enable)] + clipboard_type: ClipboardType, + + /// The bitmap codecs to use (remotefx:on, ...) + #[clap(long, num_args = 1.., value_delimiter = ',')] + codecs: Vec, + + /// Disable bulk compression support. + /// + /// By default the client advertises support for bulk compression and the + /// server may send compressed PDUs. Pass `--no-compression` to disable it. + /// When not specified, the value from the `.rdp` file is used (if present), + /// otherwise compression is enabled by default. + #[clap(long)] + no_compression: bool, + + /// Bulk compression level to negotiate with the server. + /// + /// Valid values: + /// 0 — MPPC with 8 KB history (RDP 4.0) + /// 1 — MPPC with 64 KB history (RDP 5.0) + /// 2 — NCRUSH (RDP 6.0) + /// 3 — XCRUSH (RDP 6.1) + #[clap(long, value_parser = clap::value_parser!(u32).range(0..=3))] + compression_level: Option, + + /// Prevents session locking by injecting fake mouse movement events when + /// the connection is idle (interval in minutes) + #[clap(long)] + prevent_session_lock: Option, + + /// Add DVC channel named pipe proxy + /// + /// The format is `=`, e.g., `ChannelName=PipeName` where `ChannelName` is the name of the channel, + /// and `PipeName` is the name of the named pipe to connect to (without OS-specific prefix). + /// `` will automatically be prefixed with `\\.\pipe\` on Windows. + #[clap(long)] + dvc_proxy: Vec, + /// Load a DVC client plugin DLL (Windows only). + /// + /// Path to a DVC plugin DLL that exports VirtualChannelGetInstance. + /// Example: C:\Windows\System32\webauthn.dll + #[cfg(windows)] + #[clap(long)] + dvc_plugin: Vec, + + /// Write the effective PropertySet (merged .rdp file and CLI overrides) to the given path and exit. + /// + /// The output is a standard `.rdp` file that can be used as a starting point for customisation + /// or passed back via `--rdp-file` on the next invocation. + #[clap(long)] + dump_rdp: Option, +} + +/// Result of parsing CLI args + loading the `.rdp` file: a configured [`ConfigBuilder`] plus the +/// CLI-only settings that cannot live on the builder. +/// +/// Call [`ViewerConfig::into_config`] to resolve the remaining required fields (interactive prompts +/// + frontend-derived client identity) and build the strongly-typed [`Config`]. +pub struct ViewerConfig { + builder: ConfigBuilder, + + // CLI-only settings that are not representable as `.rdp` file properties. + log_file: Option, + dump_rdp: Option, +} + +impl ViewerConfig { + pub fn parse_args() -> anyhow::Result { + Self::parse_from(std::env::args_os()) + } + + pub fn parse_from(args: I) -> anyhow::Result + where + I: IntoIterator, + T: Into + Clone, + { + let args = Args::parse_from(args); + + let mut properties = ironrdp_propertyset::PropertySet::new(); + + if let Some(rdp_file) = &args.rdp_file { + let input = + std::fs::read_to_string(rdp_file).with_context(|| format!("failed to read {}", rdp_file.display()))?; + + if let Err(errors) = ironrdp_rdpfile::load(&mut properties, &input) { + for error in &errors { + eprintln!("Warning: skipped entry in {}: {error}", rdp_file.display()); + } + } + } + + let log_file = args.log_file.clone(); + let dump_rdp = args.dump_rdp.clone(); + + // The library overlays everything expressible as a `.rdp` property: destination, credentials, + // transport, channels, desktop size, audio, DVC proxies, etc. + let builder = ConfigBuilder::from_property_set(&properties)?; + + // Whether the `.rdp` file requested clipboard redirection; the CLI `--clipboard-type` is + // resolved against this when applied below. + let redirect_clipboard = properties.redirect_clipboard().unwrap_or(true); + + // CLI arguments take precedence: apply them on top of the `.rdp`-derived builder. + let builder = apply_cli_to_builder(builder, args, redirect_clipboard); + + Ok(Self { + builder, + log_file, + dump_rdp, + }) + } + + pub fn into_config(self) -> anyhow::Result { + // When dumping, the built config is only used to observe the effective, secret-stripped + // PropertySet; we never start a session. Secrets are stripped on `build()` anyway, so there + // is no point prompting for them: fill a placeholder instead. + let dump = self.dump_rdp.is_some(); + prompt_missing(self.builder, dump) + } + + /// Path to the log file requested on the CLI, if any. + pub fn log_file(&self) -> Option<&str> { + self.log_file.as_deref() + } + + /// Path to dump the effective `.rdp` PropertySet to, if `--dump-rdp` was given. + pub fn dump_rdp(&self) -> Option<&std::path::Path> { + self.dump_rdp.as_deref() + } +} + +/// Apply CLI overrides on top of a builder that already reflects the `.rdp` file. Every flag that is +/// present overwrites the corresponding builder (and mirrored property) value. +fn apply_cli_to_builder(mut builder: ConfigBuilder, args: Args, redirect_clipboard: bool) -> ConfigBuilder { + // Validate the codecs early to surface help text before connecting. + { + let codecs: Vec<_> = args.codecs.iter().map(String::as_str).collect(); + if let Err(help) = client_codecs_capabilities(&codecs) { + print!("{help}"); + std::process::exit(0); + } + } + + if let Some(destination) = args.destination { + builder = builder.with_destination(destination); + } + if let Some(username) = args.username { + builder = builder.with_username(username); + } + if let Some(password) = args.password { + builder = builder.with_password(password); + } + if let Some(domain) = args.domain { + builder = builder.with_domain(domain); + } + if let Some(scale) = args.scale_desktop { + builder = builder.with_desktop_scale_factor(scale); + } + if let Some(width) = args.desktop_width { + builder = builder.with_desktop_width(width); + } + if let Some(height) = args.desktop_height { + builder = builder.with_desktop_height(height); + } + if let Some(color_depth) = args.color_depth { + builder = builder.with_color_depth(color_depth); + } + if args.no_credssp { + builder = builder.with_credssp(false); + } + if args.no_tls { + builder = builder.with_tls(false); + } + if args.no_server_pointer { + builder = builder.with_server_pointer(false); + } + if args.autologon { + builder = builder.with_autologon(true); + } + if args.no_compression { + builder = builder.with_compression(false); + } + if let Some(level) = args.compression_level { + builder = builder.with_compression_level(level); + } + if let Some(minutes) = args.prevent_session_lock { + builder = builder.with_fake_events_interval(Duration::from_secs(u64::from(minutes) * 60)); + } + + // Transport overrides: RDCleanPath takes precedence over Gateway. + if let Some(url) = args.rdcleanpath_url { + builder = builder.with_transport(TransportKind::RDCleanPath { url }); + + if let Some(token) = args.rdcleanpath_token { + builder = builder.with_rdcleanpath_token(token); + } + } else if let Some(endpoint) = args.gw_endpoint { + builder = builder.with_transport(TransportKind::Gateway { endpoint }); + + if let Some(username) = args.gw_user { + builder = builder.with_gateway_username(username); + } + if let Some(password) = args.gw_pass { + builder = builder.with_gateway_password(password); + } + } + + builder = builder.with_clipboard(resolve_clipboard_type(args.clipboard_type, redirect_clipboard)); + + // CLI-only knobs that are not representable as `.rdp` properties. + // TODO/FIXME: Some of these, we may want to add support for storing in .rdp files (e.g.: IME file name can be reasonably seen as a connection option) + builder = builder + .with_keyboard_type(args.keyboard_type.into_pdu()) + .with_keyboard_subtype(args.keyboard_subtype) + .with_keyboard_functional_keys_count(args.keyboard_functional_keys_count) + .with_ime_file_name(args.ime_file_name) + .with_dig_product_id(args.dig_product_id) + .with_codecs(args.codecs); + + for proxy in args.dvc_proxy { + builder = builder.with_dvc_pipe_proxy(proxy); + } + + #[cfg(windows)] + for plugin in args.dvc_plugin { + builder = builder.with_dvc_plugin(plugin); + } + + builder +} + +/// Resolve the remaining [`MissingField`]s by prompting for credentials/addresses and deriving the +/// frontend-specific client identity, then build the [`Config`]. +/// +/// When `dump` is set, the resulting config is only used to observe the effective, secret-stripped +/// PropertySet (no session is started). Secret fields are stripped on `build()` regardless, so they +/// are filled with a placeholder instead of being prompted for. +fn prompt_missing(mut builder: ConfigBuilder, dump: bool) -> anyhow::Result { + // Stripped on `build()`, so any value works when only dumping the PropertySet. + const DUMP_SECRET_PLACEHOLDER: &str = ""; + + for field in builder.missing() { + builder = match field { + MissingField::ServerAddress => { + let dest = inquire::Text::new("Server address:") + .prompt() + .context("Address prompt")? + .pipe(Destination::new)?; + builder.with_destination(dest) + } + MissingField::Username => { + let username = inquire::Text::new("Username:").prompt().context("Username prompt")?; + builder.with_username(username) + } + MissingField::Password if dump => builder.with_password(DUMP_SECRET_PLACEHOLDER), + MissingField::Password => { + let password = inquire::Password::new("Password:") + .without_confirmation() + .prompt() + .context("Password prompt")?; + builder.with_password(password) + } + MissingField::GatewayUsername => { + let username = inquire::Text::new("Gateway username:") + .prompt() + .context("Gateway username prompt")?; + builder.with_gateway_username(username) + } + MissingField::GatewayPassword if dump => builder.with_gateway_password(DUMP_SECRET_PLACEHOLDER), + MissingField::GatewayPassword => { + let password = inquire::Password::new("Gateway password:") + .without_confirmation() + .prompt() + .context("Gateway password prompt")?; + builder.with_gateway_password(password) + } + MissingField::RDCleanPathToken if dump => builder.with_rdcleanpath_token(DUMP_SECRET_PLACEHOLDER), + MissingField::RDCleanPathToken => { + let token = inquire::Text::new("RDCleanPath token:") + .prompt() + .context("RDCleanPath token prompt")?; + builder.with_rdcleanpath_token(token) + } + // Frontend-derived identity: never prompted. + MissingField::ClientBuild => builder.with_client_build(client_build()), + MissingField::ClientDir => { + // NOTE: hardcode this value like in freerdp + // https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70 + builder.with_client_dir("C:\\Windows\\System32\\mstscax.dll") + } + MissingField::Platform => builder.with_platform(current_platform()), + MissingField::ClientName => builder.with_client_name(client_name()), + }; + } + + builder.build() +} + +fn client_build() -> u32 { + semver::Version::parse(env!("CARGO_PKG_VERSION")) + .map_or(0, |v| v.major * 100 + v.minor * 10 + v.patch) + .try_into() + .unwrap_or(0) +} + +fn client_name() -> String { + whoami::hostname().unwrap_or_else(|_| "ironrdp".to_owned()) +} + +fn current_platform() -> MajorPlatformType { + match whoami::platform() { + whoami::Platform::Windows => MajorPlatformType::WINDOWS, + whoami::Platform::Linux => MajorPlatformType::UNIX, + whoami::Platform::Mac => MajorPlatformType::MACINTOSH, + whoami::Platform::Ios => MajorPlatformType::IOS, + whoami::Platform::Android => MajorPlatformType::ANDROID, + _ => MajorPlatformType::UNSPECIFIED, + } +} + +pub fn parse_config() -> anyhow::Result { + ViewerConfig::parse_args()?.into_config() +} + +pub fn parse_config_from(args: I) -> anyhow::Result +where + I: IntoIterator, + T: Into + Clone, +{ + ViewerConfig::parse_from(args)?.into_config() +} + +fn resolve_clipboard_type(cli: ClipboardType, redirect_clipboard: bool) -> ResolvedClipboardType { + if !redirect_clipboard { + return ResolvedClipboardType::Disable; + } + + match cli { + ClipboardType::Enable => ResolvedClipboardType::Enable, + ClipboardType::Disable => ResolvedClipboardType::Disable, + ClipboardType::Stub => ResolvedClipboardType::Stub, + } +} diff --git a/crates/ironrdp-viewer/src/clipboard.rs b/crates/ironrdp-viewer/src/clipboard.rs new file mode 100644 index 0000000000..cc304c0bc2 --- /dev/null +++ b/crates/ironrdp-viewer/src/clipboard.rs @@ -0,0 +1,24 @@ +use ironrdp::cliprdr::backend::{ClipboardMessage, ClipboardMessageProxy}; +use ironrdp::client::rdp::RdpInputEvent; +use tokio::sync::mpsc; +use tracing::error; + +/// Shim for sending and receiving CLIPRDR events as `RdpInputEvent` +#[derive(Clone, Debug)] +pub struct ClientClipboardMessageProxy { + tx: mpsc::UnboundedSender, +} + +impl ClientClipboardMessageProxy { + pub fn new(tx: mpsc::UnboundedSender) -> Self { + Self { tx } + } +} + +impl ClipboardMessageProxy for ClientClipboardMessageProxy { + fn send_clipboard_message(&self, message: ClipboardMessage) { + if self.tx.send(RdpInputEvent::Clipboard(message)).is_err() { + error!("Failed to send os clipboard message, receiver is closed"); + } + } +} diff --git a/crates/ironrdp-viewer/src/lib.rs b/crates/ironrdp-viewer/src/lib.rs new file mode 100644 index 0000000000..2939e5d7f4 --- /dev/null +++ b/crates/ironrdp-viewer/src/lib.rs @@ -0,0 +1,13 @@ +#![cfg_attr(doc, doc = include_str!("../README.md"))] +#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")] +#![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary + +// No need to be as strict as in production libraries +#![allow(clippy::arithmetic_side_effects)] +#![allow(clippy::cast_lossless)] +#![allow(clippy::cast_possible_truncation)] +#![allow(clippy::cast_possible_wrap)] +#![allow(clippy::cast_sign_loss)] + +pub mod app; +pub mod cli; diff --git a/crates/ironrdp-client/src/main.rs b/crates/ironrdp-viewer/src/main.rs similarity index 51% rename from crates/ironrdp-client/src/main.rs rename to crates/ironrdp-viewer/src/main.rs index 15caaf99a1..797d327a12 100644 --- a/crates/ironrdp-client/src/main.rs +++ b/crates/ironrdp-viewer/src/main.rs @@ -1,71 +1,62 @@ #![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary use anyhow::Context as _; -use ironrdp_client::app::App; -use ironrdp_client::config::{ClipboardType, Config}; -use ironrdp_client::rdp::{DvcPipeProxyFactory, RdpClient, RdpInputEvent, RdpOutputEvent}; +use ironrdp::client::rdp::{RdpClient, RdpOutputEvent}; +use ironrdp_viewer::app::App; +use ironrdp_viewer::cli::ViewerConfig; use tokio::runtime; +use tokio::sync::mpsc; use tracing::debug; +use winit::dpi::PhysicalSize; use winit::event_loop::EventLoop; fn main() -> anyhow::Result<()> { - let mut config = Config::parse_args().context("CLI arguments parsing")?; + let cli = ViewerConfig::parse_args().context("CLI arguments parsing")?; - setup_logging(config.log_file.as_deref()).context("unable to initialize logging")?; + setup_logging(cli.log_file()).context("unable to initialize logging")?; + + let dump_rdp = cli.dump_rdp().map(ToOwned::to_owned); + let config = cli.into_config().context("configuration")?; + + if let Some(dump_path) = dump_rdp { + // Dump the effective, secret-stripped PropertySet observed from the built configuration. + let content = ironrdp_rdpfile::write(config.properties()); + std::fs::write(&dump_path, &content).with_context(|| format!("failed to write {}", dump_path.display()))?; + return Ok(()); + } debug!("Initialize App"); let event_loop = EventLoop::::with_user_event().build()?; let event_loop_proxy = event_loop.create_proxy(); - let (input_event_sender, input_event_receiver) = RdpInputEvent::create_channel(); - let mut app = App::new(&event_loop, &input_event_sender).context("unable to initialize App")?; + let (output_event_sender, mut output_event_receiver) = mpsc::channel::(64); + let initial_window_size = PhysicalSize::new( + u32::from(config.connector().desktop_size.width), + u32::from(config.connector().desktop_size.height), + ); - // TODO: get window size & scale factor from GUI/App - let window_size = (1024, 768); - config.connector.desktop_scale_factor = 0; - config.connector.desktop_size.width = u16::try_from(window_size.0).unwrap(); - config.connector.desktop_size.height = u16::try_from(window_size.1).unwrap(); + let client = RdpClient::new(config, output_event_sender); + let input_event_sender = client.input_sender(); + + let mut app = + App::new(&event_loop, &input_event_sender, initial_window_size).context("unable to initialize App")?; let rt = runtime::Builder::new_multi_thread() .enable_all() .build() .context("unable to create tokio runtime")?; - // NOTE: we need to keep `win_clipboard` alive, otherwise it will be dropped before IronRDP - // starts and clipboard functionality will not be available. - #[cfg(windows)] - let _win_clipboard; - - let cliprdr_factory = match config.clipboard_type { - ClipboardType::Stub => { - use ironrdp_cliprdr_native::StubClipboard; - - let cliprdr = StubClipboard::new(); - let factory = cliprdr.backend_factory(); - Some(factory) - } - #[cfg(windows)] - ClipboardType::Windows => { - use ironrdp_client::clipboard::ClientClipboardMessageProxy; - use ironrdp_cliprdr_native::WinClipboard; - - let cliprdr = WinClipboard::new(ClientClipboardMessageProxy::new(input_event_sender.clone()))?; - - let factory = cliprdr.backend_factory(); - _win_clipboard = cliprdr; - Some(factory) + // Forward output events from the library's mpsc channel to winit's `EventLoopProxy`. + // + // The library is winit-agnostic: it just emits `RdpOutputEvent`s on a plain + // `tokio::sync::mpsc` channel. Bridging onto the GUI event loop is the binary's job. + rt.spawn(async move { + while let Some(event) = output_event_receiver.recv().await { + if event_loop_proxy.send_event(event).is_err() { + // The event loop is gone; nothing left to forward. + break; + } } - _ => None, - }; - - let dvc_pipe_proxy_factory = DvcPipeProxyFactory::new(input_event_sender); - - let client = RdpClient { - config, - event_loop_proxy, - input_event_receiver, - cliprdr_factory, - dvc_pipe_proxy_factory, - }; + }); debug!("Start RDP thread"); std::thread::spawn(move || { @@ -74,6 +65,7 @@ fn main() -> anyhow::Result<()> { debug!("Run App"); event_loop.run_app(&mut app)?; + Ok(()) } @@ -81,8 +73,8 @@ fn setup_logging(log_file: Option<&str>) -> anyhow::Result<()> { use std::fs::OpenOptions; use tracing::metadata::LevelFilter; - use tracing_subscriber::prelude::*; use tracing_subscriber::EnvFilter; + use tracing_subscriber::prelude::*; let env_filter = EnvFilter::builder() .with_default_directive(LevelFilter::WARN.into()) diff --git a/crates/ironrdp-web/Cargo.toml b/crates/ironrdp-web/Cargo.toml index 7f1a44e850..716181f3ee 100644 --- a/crates/ironrdp-web/Cargo.toml +++ b/crates/ironrdp-web/Cargo.toml @@ -32,6 +32,8 @@ ironrdp = { path = "../ironrdp", features = [ "graphics", "dvc", "cliprdr", + "rdpdr", + "rdpsnd", "svc", "displaycontrol", "pdu", @@ -39,28 +41,37 @@ ironrdp = { path = "../ironrdp", features = [ ironrdp-core.path = "../ironrdp-core" ironrdp-cliprdr-format.path = "../ironrdp-cliprdr-format" ironrdp-futures.path = "../ironrdp-futures" +ironrdp-pdu.path = "../ironrdp-pdu" ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath" ironrdp-propertyset.path = "../ironrdp-propertyset" ironrdp-rdpfile.path = "../ironrdp-rdpfile" +ironrdp-svc.path = "../ironrdp-svc" iron-remote-desktop.path = "../iron-remote-desktop" # WASM wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["HtmlCanvasElement"] } +web-sys = { version = "0.3", features = [ + "CanvasRenderingContext2d", + "HtmlCanvasElement", + "ImageData", + "Navigator", + "Performance", + "Window", +] } js-sys = "0.3" -gloo-net = { version = "0.6", default-features = false, features = ["websocket", "http", "io-util"] } -gloo-timers = { version = "0.3", default-features = false, features = ["futures"] } +gloo-net = { version = "0.7", default-features = false, features = ["websocket", "http", "io-util"] } +gloo-timers = { version = "0.4", default-features = false, features = ["futures"] } # Rendering -softbuffer = { version = "0.4", default-features = false } -png = "0.17" +png = "0.18" resize = { version = "0.8", features = ["std"], default-features = false } rgb = "0.8" # Enable WebAssembly support for a few crates getrandom2 = { package = "getrandom", version = "0.2", features = ["js"] } getrandom = { version = "0.3", features = ["wasm_js"] } +getrandom4 = { package = "getrandom", version = "0.4", features = ["wasm_js"] } # sspi/picky transitive dep chrono = { version = "0.4", features = ["wasmbind"] } time = { version = "0.3", features = ["wasm-bindgen"] } diff --git a/crates/ironrdp-web/src/canvas.rs b/crates/ironrdp-web/src/canvas.rs index 322c050682..30ba5be78f 100644 --- a/crates/ironrdp-web/src/canvas.rs +++ b/crates/ironrdp-web/src/canvas.rs @@ -1,92 +1,82 @@ use core::num::NonZeroU32; -use ironrdp::pdu::geometry::{InclusiveRectangle, Rectangle as _}; -use softbuffer::{NoDisplayHandle, NoWindowHandle}; -use web_sys::HtmlCanvasElement; - +#[cfg(target_arch = "wasm32")] +use anyhow::anyhow; +use ironrdp::pdu::geometry::InclusiveRectangle; +#[cfg(target_arch = "wasm32")] +use ironrdp::pdu::geometry::Rectangle as _; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::{Clamped, JsCast as _}; +#[cfg(target_arch = "wasm32")] +use web_sys::ImageData; +use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement}; + +/// Web render surface: blits each dirty region to the canvas with `put_image_data`. pub(crate) struct Canvas { - width: u32, - surface: softbuffer::Surface, + canvas: HtmlCanvasElement, + ctx: CanvasRenderingContext2d, } impl Canvas { - pub(crate) fn new(render_canvas: HtmlCanvasElement, width: u32, height: u32) -> anyhow::Result { - render_canvas.set_width(width); - render_canvas.set_height(height); - - #[cfg(target_arch = "wasm32")] - let mut surface = { - use softbuffer::SurfaceExtWeb as _; - softbuffer::Surface::from_canvas(render_canvas).expect("surface") - }; - - #[cfg(not(target_arch = "wasm32"))] - let mut surface = { - fn stub(_: HtmlCanvasElement) -> softbuffer::Surface { - unimplemented!() - } - - stub(render_canvas) - }; - - surface - .resize(NonZeroU32::new(width).unwrap(), NonZeroU32::new(height).unwrap()) - .expect("surface resize"); - - Ok(Self { width, surface }) + pub(crate) fn new(render_canvas: HtmlCanvasElement, width: NonZeroU32, height: NonZeroU32) -> anyhow::Result { + render_canvas.set_width(width.get()); + render_canvas.set_height(height.get()); + let ctx = context_2d(&render_canvas)?; + + Ok(Self { + canvas: render_canvas, + ctx, + }) } + /// Resizes the backing store. Note: this also clears the canvas and resets 2D context state; + /// the cached `ctx` stays valid. pub(crate) fn resize(&mut self, width: NonZeroU32, height: NonZeroU32) { - self.surface.resize(width, height).expect("surface resize"); - self.width = width.get(); + self.canvas.set_width(width.get()); + self.canvas.set_height(height.get()); } - pub(crate) fn draw(&mut self, buffer: &[u8], region: InclusiveRectangle) -> anyhow::Result<()> { - let region_width = region.width(); - let region_height = region.height(); - - let mut src = buffer.chunks_exact(4).map(|pixel| { - let r = pixel[0]; - let g = pixel[1]; - let b = pixel[2]; - u32::from_be_bytes([0, r, g, b]) - }); - - let mut dst = self.surface.buffer_mut().expect("surface buffer"); + /// Blits a dirty region with `put_image_data`. Forces alpha opaque first: the framebuffer isn't + /// guaranteed opaque (zero-init columns, QOI-RGBA) and `put_image_data` stores alpha verbatim. + pub(crate) fn draw(&self, buffer: &mut [u8], region: InclusiveRectangle) -> anyhow::Result<()> { + for pixel in buffer.chunks_exact_mut(4) { + pixel[3] = 0xFF; + } + #[cfg(target_arch = "wasm32")] { - // Copy src into dst - - let region_top_usize = usize::from(region.top); - let region_height_usize = usize::from(region_height); - let region_left_usize = usize::from(region.left); - let region_width_usize = usize::from(region_width); - - for dst_row in dst - .chunks_exact_mut(self.width as usize) - .skip(region_top_usize) - .take(region_height_usize) - { - let src_row = src.by_ref().take(region_width_usize); - - dst_row - .iter_mut() - .skip(region_left_usize) - .take(region_width_usize) - .zip(src_row) - .for_each(|(dst, src)| *dst = src); - } + let image = ImageData::new_with_u8_clamped_array_and_sh( + Clamped(&*buffer), + u32::from(region.width()), + u32::from(region.height()), + ) + .map_err(|err| anyhow!("ImageData::new failed: {err:?}"))?; + self.ctx + .put_image_data(&image, f64::from(region.left), f64::from(region.top)) + .map_err(|err| anyhow!("put_image_data failed: {err:?}")) } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (&self.ctx, buffer, region); + unimplemented!("web canvas is only available on wasm32") + } + } +} - let damage_rect = softbuffer::Rect { - x: u32::from(region.left), - y: u32::from(region.top), - width: NonZeroU32::new(u32::from(region_width)).unwrap(), - height: NonZeroU32::new(u32::from(region_height)).unwrap(), - }; - - dst.present_with_damage(&[damage_rect]).expect("buffer present"); - - Ok(()) +/// Acquires the canvas 2D context (wasm only; panics on other targets). +fn context_2d(canvas: &HtmlCanvasElement) -> anyhow::Result { + #[cfg(target_arch = "wasm32")] + { + canvas + .get_context("2d") + .map_err(|err| anyhow!("get_context(\"2d\") failed: {err:?}"))? + .ok_or_else(|| anyhow!("canvas has no 2d context"))? + .dyn_into::() + .map_err(|_| anyhow!("2d context is not a CanvasRenderingContext2d")) + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = canvas; + unimplemented!("web canvas is only available on wasm32") } } diff --git a/crates/ironrdp-web/src/clipboard.rs b/crates/ironrdp-web/src/clipboard.rs index eb5d1c5ea7..8bd9c62ca8 100644 --- a/crates/ironrdp-web/src/clipboard.rs +++ b/crates/ironrdp-web/src/clipboard.rs @@ -17,13 +17,13 @@ use futures_channel::mpsc; use iron_remote_desktop::ClipboardData as _; use ironrdp::cliprdr::backend::{ClipboardMessage, CliprdrBackend}; use ironrdp::cliprdr::pdu::{ - ClipboardFormat, ClipboardFormatId, ClipboardFormatName, ClipboardGeneralCapabilityFlags, FileContentsRequest, - FileContentsResponse, FormatDataRequest, FormatDataResponse, LockDataId, + ClipboardFormat, ClipboardFormatId, ClipboardFormatName, ClipboardGeneralCapabilityFlags, FileContentsFlags, + FileContentsRequest, FileContentsResponse, FormatDataRequest, FormatDataResponse, LockDataId, }; use ironrdp_cliprdr_format::bitmap::{dib_to_png, dibv5_to_png, png_to_cf_dibv5}; use ironrdp_cliprdr_format::html::{cf_html_to_plain_html, plain_html_to_cf_html}; -use ironrdp_core::{impl_as_any, IntoOwned as _}; -use tracing::{error, trace, warn}; +use ironrdp_core::{IntoOwned as _, impl_as_any}; +use tracing::{error, info, trace, warn}; use wasm_bindgen::prelude::*; use crate::session::RdpInputEvent; @@ -105,8 +105,113 @@ pub(crate) enum WasmClipboardBackendMessage { RemoteClipboardChanged(Vec), RemoteDataResponse(FormatDataResponse<'static>), - FormatListReceived, ForceClipboardUpdate, + + // File transfer messages + /// [MS-RDPECLIP] 2.2.5.2 File list available from remote for download. + /// + /// Sent when remote copies files - contains file metadata (names, sizes, timestamps). + /// The lock ID is included so JS can use it in FileContentsRequest calls. + FileListAdvertise { + files: Vec, + clip_data_id: Option, + }, + /// [MS-RDPECLIP] 2.2.5.3.1 Remote requests file contents from client (upload). + /// + /// Forwarded from remote when user pastes files on remote. JS should read the + /// requested file chunk and respond via submit_file_contents(). + /// + /// flags indicates SIZE (return 8-byte u64) or RANGE (return byte range). + /// position/size define the byte range for RANGE requests. + /// data_id associates request with locked clipboard (from LockClipData PDU). + FileContentsRequest { + stream_id: u32, + /// Per MS-RDPECLIP 2.2.5.3, lindex is a signed i32. + /// Must be non-negative (validated by protocol layer). + index: i32, + flags: FileContentsFlags, + position: u64, + size: u32, + /// Optional clipboard data ID from LockClipData PDU + data_id: Option, + }, + /// [MS-RDPECLIP] 2.2.5.3.2 Remote sends file contents to client (download). + /// + /// Forwarded from remote in response to client's FileContentsRequest. + /// If is_error is true, the request failed (data unavailable/access denied). + /// For SIZE requests, data contains 8-byte little-endian u64. + /// For DATA requests, data contains the requested byte range. + FileContentsResponse { + stream_id: u32, + /// If true, the request failed and data should be ignored + is_error: bool, + data: Vec, + }, + /// [MS-RDPECLIP] 2.2.4.1 Remote locked their clipboard for file transfer. + /// + /// Sent when remote locks their clipboard before we request file contents. + /// The data_id associates subsequent FileContentsRequest/Response cycles. + /// Clipboard remains locked until UnlockClipData PDU received. + Lock { + data_id: LockDataId, + }, + /// [MS-RDPECLIP] 2.2.4.2 Remote unlocked their clipboard. + /// + /// Sent when remote unlocks clipboard after file transfer completes or + /// when new clipboard content is copied (auto-unlock per spec). + Unlock { + data_id: LockDataId, + }, + /// Client-side locks expired due to inactivity timeout. + /// + /// Sent when automatic cleanup removes locks that have been inactive + /// for too long or exceeded maximum lifetime. The locks have already + /// been unlocked by the time this notification is sent. + /// + /// JS should clear any references to these lock IDs and abort any + /// associated file transfers. + LocksExpired { + clip_data_ids: Vec, + }, + /// [MS-RDPECLIP] 2.2.3.2 Remote's response to one of our outbound Format Lists. + /// + /// `ok` is `true` when the remote accepted the advertised formats + /// (`CB_RESPONSE_OK`) and `false` when it rejected them (`CB_RESPONSE_FAIL`). + /// A rejected advertise is silently discarded by the remote, so a file paste + /// cannot proceed; backends use this to detect and recover from a refused + /// paste instead of stalling until a lock timeout. + FormatListResponse { + ok: bool, + }, + + // JS-initiated file transfer operations + /// JS requests file contents from remote (download). + /// + /// Sends FileContentsRequest PDU to remote to request file size or data. + FileContentsRequestSend { + stream_id: u32, + /// Per MS-RDPECLIP 2.2.5.3, lindex is a signed i32. + /// Must be non-negative (validated by protocol layer). + index: i32, + flags: FileContentsFlags, + position: u64, + size: u32, + clip_data_id: Option, + }, + /// JS sends file contents to remote (upload response). + /// + /// Sends FileContentsResponse PDU to remote with requested file data. + FileContentsResponseSend { + stream_id: u32, + is_error: bool, + data: Vec, + }, + /// JS advertises local files for copy (upload). + /// + /// Sends FormatList with FileGroupDescriptorW containing file metadata. + InitiateFileCopy { + files: Vec, + }, } /// Clipboard backend implementation for web. This object should be created once per session and @@ -118,6 +223,13 @@ pub(crate) struct WasmClipboard { remote_mapping: HashMap, remote_formats_to_read: Vec, + /// Deferred file list paste: when the remote FormatList includes FileGroupDescriptorW, + /// we store its format ID here and trigger `SendInitiatePaste` only after all text/image + /// formats have been fetched (or immediately if no text/image formats are present). + /// This sequences the file list request after other format requests so that the cliprdr + /// layer can correctly correlate each FormatDataResponse with its FormatDataRequest. + pending_file_list_paste: Option, + proxy: WasmClipboardMessageProxy, js_callbacks: JsClipboardCallbacks, } @@ -125,8 +237,15 @@ pub(crate) struct WasmClipboard { /// Callbacks, required to interact with JS code from within the backend. pub(crate) struct JsClipboardCallbacks { pub(crate) on_remote_clipboard_changed: js_sys::Function, - pub(crate) on_remote_received_format_list: Option, pub(crate) on_force_clipboard_update: Option, + // File transfer callbacks + pub(crate) on_files_available: Option, + pub(crate) on_file_contents_request: Option, + pub(crate) on_file_contents_response: Option, + pub(crate) on_lock: Option, + pub(crate) on_unlock: Option, + pub(crate) on_locks_expired: Option, + pub(crate) on_format_list_response: Option, } impl WasmClipboard { @@ -139,6 +258,7 @@ impl WasmClipboard { remote_mapping: HashMap::new(), remote_formats_to_read: Vec::new(), + pending_file_list_paste: None, } } @@ -181,7 +301,7 @@ impl WasmClipboard { self.local_clipboard = Some(clipboard_data); - trace!("Sending clipboard formats: {:?}", formats); + trace!(?formats, "Sending clipboard formats"); Ok(formats) } @@ -264,18 +384,28 @@ impl WasmClipboard { formats: Vec, ) -> anyhow::Result> { self.remote_clipboard.clear(); + self.remote_mapping.clear(); + self.pending_file_list_paste = None; // We accumulate all formats in the `remote_formats_to_read` attribute. // Later, we loop over and fetch all of these (see `process_remote_data_response`). + // + // SAFETY (stale response concern): clearing both `remote_mapping` and + // `remote_formats_to_read` here is safe because the WASM runtime is + // single-threaded. Any in-flight `FormatDataResponse` for the previous + // format list will be processed after this function returns. At that + // point `remote_formats_to_read` is either empty (response dropped by + // the guard in `process_remote_data_response`) or repopulated with the + // new format list, so stale data cannot be misattributed to a new format. self.remote_formats_to_read.clear(); // In this loop, we ignore some formats. There are two reasons for that: // // 1) Some formats require an extra conversion into the appropriate MIME format // prior to being written to the system clipboard. - // E.g.: "image/png" format is preferred over "CF_DIB" because we’ll convert the + // E.g.: "image/png" format is preferred over "CF_DIB" because we'll convert the // uncompressed BMP into "image/png". "text/html" is preferred over Windows - // "CF_HTML" because we’ll convert it into "text/html". + // "CF_HTML" because we'll convert it into "text/html". // // 2) A direct consequence of 1) is that some formats will end up being mapped // into the same MIME type. Fetching only one of these is enough, especially given @@ -283,6 +413,17 @@ impl WasmClipboard { for format in &formats { if format.id().is_registered() { if let Some(name) = format.name() { + // [MS-RDPECLIP] 2.2.5.2 FileGroupDescriptorW: file transfer format. + // Handled separately from text/image because the cliprdr layer's + // `initiate_paste()` + `handle_format_data_response()` intercept path + // parses the file descriptors and calls `on_remote_file_list()`. + // We defer this paste until after all text/image formats are fetched + // to avoid conflicts with the FormatDataRequest/Response chain. + if name.value() == ClipboardFormatName::FILE_LIST.value() { + self.pending_file_list_paste = Some(format.id()); + continue; + } + const SUPPORTED_FORMATS: &[&str] = &[ FORMAT_WIN_HTML.name, FORMAT_MIME_HTML.name, @@ -369,21 +510,21 @@ impl WasmClipboard { ClipboardFormatId::CF_UNICODETEXT => match response.to_unicode_string() { Ok(text) => Some(ClipboardItem::new_text(MIME_TEXT, text)), Err(err) => { - error!("CF_UNICODETEXT decode error: {}", err); + error!(error = %err, "CF_UNICODETEXT decode error"); None } }, ClipboardFormatId::CF_DIB => match dib_to_png(response.data()) { Ok(png) => Some(ClipboardItem::new_binary(MIME_PNG, png)), Err(err) => { - warn!("DIB decode error: {}", err); + warn!(error = %err, "DIB decode error"); None } }, ClipboardFormatId::CF_DIBV5 => match dibv5_to_png(response.data()) { Ok(png) => Some(ClipboardItem::new_binary(MIME_PNG, png)), Err(err) => { - warn!("DIBv5 decode error: {}", err); + warn!(error = %err, "DIBv5 decode error"); None } }, @@ -394,14 +535,14 @@ impl WasmClipboard { Some(FORMAT_WIN_HTML_NAME) => match cf_html_to_plain_html(response.data()) { Ok(text) => Some(ClipboardItem::new_text(MIME_HTML, text.to_owned())), Err(err) => { - warn!("CF_HTML decode error: {}", err); + warn!(error = %err, "CF_HTML decode error"); None } }, Some(FORMAT_MIME_HTML_NAME) => match response.to_string() { Ok(text) => Some(ClipboardItem::new_text(MIME_HTML, text)), Err(err) => { - warn!("text/html decode error: {}", err); + warn!(error = %err, "text/html decode error"); None } }, @@ -425,21 +566,25 @@ impl WasmClipboard { self.proxy .send_cliprdr_message(ClipboardMessage::SendInitiatePaste(*format)); } else { - // All formats were read, send clipboard to JS. + // All text/image formats were read, send clipboard data to JS. let clipboard_data = core::mem::take(&mut self.remote_clipboard); - if clipboard_data.is_empty() { - return Ok(()); - } - - // Set clipboard when all formats were read. - self.js_callbacks - .on_remote_clipboard_changed - .call1( + if !clipboard_data.is_empty() { + if let Err(e) = self.js_callbacks.on_remote_clipboard_changed.call1( &JsValue::NULL, &JsValue::from(crate::wasm_bridge::ClipboardData::from(clipboard_data)), - ) - .expect("failed to call JS callback"); + ) { + error!(error = ?e, "Failed to call remote clipboard changed callback"); + } + } + + // Now trigger the deferred file list fetch if the FormatList included + // FileGroupDescriptorW. The cliprdr layer handles parsing the file + // descriptors and calling `on_remote_file_list()` automatically. + if let Some(file_format) = self.pending_file_list_paste.take() { + self.proxy + .send_cliprdr_message(ClipboardMessage::SendInitiatePaste(file_format)); + } } Ok(()) @@ -475,13 +620,21 @@ impl WasmClipboard { WasmClipboardBackendMessage::RemoteClipboardChanged(formats) => { match self.process_remote_clipboard_changed(formats) { Ok(Some(format)) => { - // We start querying formats right away. This is due absence of - // delay-rendering in web client. + // We start querying text/image formats right away. This is due to + // absence of delay-rendering in web client. + // If a file list format is also pending, it will be triggered after + // all text/image formats are fetched (see process_remote_data_response). self.proxy .send_cliprdr_message(ClipboardMessage::SendInitiatePaste(format)); } Ok(None) => { - // No formats to query + // No text/image formats to query. If a file list format was detected, + // trigger it immediately since there's no text/image fetch chain to + // wait for. + if let Some(file_format) = self.pending_file_list_paste.take() { + self.proxy + .send_cliprdr_message(ClipboardMessage::SendInitiatePaste(file_format)); + } } Err(e) => { error!(error = format!("{e:#}"), "Failed to process remote clipboard change"); @@ -496,20 +649,226 @@ impl WasmClipboard { } } } - WasmClipboardBackendMessage::FormatListReceived => { - if let Some(callback) = self.js_callbacks.on_remote_received_format_list.as_mut() { - callback.call0(&JsValue::NULL).expect("failed to call JS callback"); - } - } WasmClipboardBackendMessage::ForceClipboardUpdate => { if let Some(callback) = self.js_callbacks.on_force_clipboard_update.as_mut() { - callback.call0(&JsValue::NULL).expect("failed to call JS callback"); + if let Err(e) = callback.call0(&JsValue::NULL) { + error!(error = ?e, "Failed to call JS force clipboard update callback"); + return Ok(()); + } } else { // If no initial clipboard callback was set, send empty format list instead return self .process_event(WasmClipboardBackendMessage::LocalClipboardChanged(ClipboardData::new())); } } + WasmClipboardBackendMessage::FileListAdvertise { files, clip_data_id } => { + if let Some(callback) = self.js_callbacks.on_files_available.as_ref() { + // Convert FileMetadata vector to JS array. + // Reflect::set on a fresh Object practically never fails, but we + // log and skip rather than panicking the entire WASM module. + let js_array = js_sys::Array::new(); + for file in &files { + let js_file = js_sys::Object::new(); + if let Err(e) = js_sys::Reflect::set(&js_file, &"name".into(), &JsValue::from_str(&file.name)) { + error!(error = ?e, field = "name", file_name = %file.name, "Failed to set JS file metadata property"); + } + // Set path if present (relative directory within the copied collection) + if let Some(path) = &file.path { + if let Err(e) = js_sys::Reflect::set(&js_file, &"path".into(), &JsValue::from_str(path)) { + error!(error = ?e, field = "path", file_name = %file.name, "Failed to set JS file metadata property"); + } + } + #[expect(clippy::as_conversions, clippy::cast_precision_loss)] + let size_f64 = file.size as f64; + if let Err(e) = js_sys::Reflect::set(&js_file, &"size".into(), &JsValue::from_f64(size_f64)) { + error!(error = ?e, field = "size", file_name = %file.name, "Failed to set JS file metadata property"); + } + #[expect(clippy::as_conversions, clippy::cast_precision_loss)] + let last_modified_f64 = file.last_modified as f64; + if let Err(e) = js_sys::Reflect::set( + &js_file, + &"lastModified".into(), + &JsValue::from_f64(last_modified_f64), + ) { + error!(error = ?e, field = "lastModified", file_name = %file.name, "Failed to set JS file metadata property"); + } + if let Err(e) = js_sys::Reflect::set( + &js_file, + &"isDirectory".into(), + &JsValue::from_bool(file.is_directory), + ) { + error!(error = ?e, field = "isDirectory", file_name = %file.name, "Failed to set JS file metadata property"); + } + js_array.push(&js_file); + } + let clip_data_id_js = match clip_data_id { + Some(id) => JsValue::from_f64(f64::from(id)), + None => JsValue::UNDEFINED, + }; + if let Err(e) = callback.call2(&JsValue::NULL, &js_array, &clip_data_id_js) { + error!(error = ?e, file_count = files.len(), "Failed to call JS files available callback"); + return Ok(()); + } + } else { + warn!( + file_count = files.len(), + "File list available but no JS callback registered" + ); + } + } + WasmClipboardBackendMessage::FileContentsRequest { + stream_id, + index, + flags, + position, + size, + data_id, + } => { + if let Some(callback) = self.js_callbacks.on_file_contents_request.as_ref() { + let js_request = js_sys::Object::new(); + if let Err(e) = js_sys::Reflect::set( + &js_request, + &"streamId".into(), + &JsValue::from_f64(f64::from(stream_id)), + ) { + error!(error = ?e, field = "streamId", stream_id, "Failed to set JS file contents request property"); + } + if let Err(e) = + js_sys::Reflect::set(&js_request, &"index".into(), &JsValue::from_f64(f64::from(index))) + { + error!(error = ?e, field = "index", stream_id, "Failed to set JS file contents request property"); + } + if let Err(e) = js_sys::Reflect::set( + &js_request, + &"flags".into(), + &JsValue::from_f64(f64::from(flags.bits())), + ) { + error!(error = ?e, field = "flags", stream_id, "Failed to set JS file contents request property"); + } + #[expect(clippy::as_conversions, clippy::cast_precision_loss)] + let position_f64 = position as f64; + if let Err(e) = + js_sys::Reflect::set(&js_request, &"position".into(), &JsValue::from_f64(position_f64)) + { + error!(error = ?e, field = "position", stream_id, "Failed to set JS file contents request property"); + } + if let Err(e) = + js_sys::Reflect::set(&js_request, &"size".into(), &JsValue::from_f64(f64::from(size))) + { + error!(error = ?e, field = "size", stream_id, "Failed to set JS file contents request property"); + } + // data_id is optional - only set if present + if let Some(id) = data_id { + if let Err(e) = + js_sys::Reflect::set(&js_request, &"dataId".into(), &JsValue::from_f64(f64::from(id))) + { + error!(error = ?e, field = "dataId", stream_id, "Failed to set JS file contents request property"); + } + } + if let Err(e) = callback.call1(&JsValue::NULL, &js_request) { + error!(error = ?e, stream_id, "Failed to call JS file contents request callback"); + return Ok(()); + } + } else { + warn!( + stream_id, + index, "File contents request from remote but no JS callback registered" + ); + } + } + WasmClipboardBackendMessage::FileContentsResponse { + stream_id, + is_error, + data, + } => { + if let Some(callback) = self.js_callbacks.on_file_contents_response.as_ref() { + let js_response = js_sys::Object::new(); + if let Err(e) = js_sys::Reflect::set( + &js_response, + &"streamId".into(), + &JsValue::from_f64(f64::from(stream_id)), + ) { + error!(error = ?e, field = "streamId", stream_id, "Failed to set JS file contents response property"); + } + if let Err(e) = js_sys::Reflect::set(&js_response, &"isError".into(), &JsValue::from_bool(is_error)) + { + error!(error = ?e, field = "isError", stream_id, "Failed to set JS file contents response property"); + } + if let Err(e) = + js_sys::Reflect::set(&js_response, &"data".into(), &js_sys::Uint8Array::from(data.as_slice())) + { + error!(error = ?e, field = "data", stream_id, data_len = data.len(), "Failed to set JS file contents response property"); + } + if let Err(e) = callback.call1(&JsValue::NULL, &js_response) { + error!(error = ?e, stream_id, "Failed to call JS file contents response callback"); + return Ok(()); + } + } else { + warn!( + stream_id, + is_error, + data_len = data.len(), + "File contents response from remote but no JS callback registered" + ); + } + } + WasmClipboardBackendMessage::Lock { data_id } => { + if let Some(callback) = self.js_callbacks.on_lock.as_ref() { + if let Err(e) = callback.call1(&JsValue::NULL, &JsValue::from_f64(f64::from(data_id.0))) { + error!(error = ?e, data_id = data_id.0, "Failed to call JS lock callback"); + return Ok(()); + } + } else { + warn!( + data_id = data_id.0, + "Clipboard lock received but no JS callback registered" + ); + } + } + WasmClipboardBackendMessage::Unlock { data_id } => { + if let Some(callback) = self.js_callbacks.on_unlock.as_ref() { + if let Err(e) = callback.call1(&JsValue::NULL, &JsValue::from_f64(f64::from(data_id.0))) { + error!(error = ?e, data_id = data_id.0, "Failed to call JS unlock callback"); + return Ok(()); + } + } else { + warn!( + data_id = data_id.0, + "Clipboard unlock received but no JS callback registered" + ); + } + } + WasmClipboardBackendMessage::LocksExpired { clip_data_ids } => { + if let Some(callback) = self.js_callbacks.on_locks_expired.as_ref() { + let js_array = js_sys::Uint32Array::from(clip_data_ids.as_slice()); + if let Err(e) = callback.call1(&JsValue::NULL, &js_array) { + error!(error = ?e, count = clip_data_ids.len(), "Failed to call JS locks expired callback"); + return Ok(()); + } + } else { + warn!( + count = clip_data_ids.len(), + "Clipboard locks expired but no JS callback registered" + ); + } + } + WasmClipboardBackendMessage::FormatListResponse { ok } => { + if let Some(callback) = self.js_callbacks.on_format_list_response.as_ref() { + if let Err(e) = callback.call1(&JsValue::NULL, &JsValue::from_bool(ok)) { + error!(error = ?e, ok, "Failed to call JS format list response callback"); + return Ok(()); + } + } else { + trace!(ok, "Format list response received but no JS callback registered"); + } + } + // The following variants are handled directly in the event loop and should never reach here + WasmClipboardBackendMessage::FileContentsRequestSend { .. } + | WasmClipboardBackendMessage::FileContentsResponseSend { .. } + | WasmClipboardBackendMessage::InitiateFileCopy { .. } => { + error!("Outbound file transfer message should not reach WasmClipboard::process_event"); + anyhow::bail!("Unexpected outbound file transfer message in clipboard backend"); + } }; Ok(()) @@ -537,8 +896,16 @@ impl CliprdrBackend for WasmClipboardBackend { } fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags { - // No additional capabilities yet - ClipboardGeneralCapabilityFlags::empty() + // [MS-RDPECLIP] 2.2.2.1 General Capability Set (CLIPRDR_GENERAL_CAPABILITY) + // Advertise file transfer support via CLIPRDR virtual channel: + // - STREAM_FILECLIP_ENABLED: support stream-based file copy/paste via FileContentsRequest/Response PDUs + // - FILECLIP_NO_FILE_PATHS: file descriptors must not include source paths (security) + // - CAN_LOCK_CLIPDATA: support clipboard locking during file transfer via LockData/UnlockData PDUs + // - HUGE_FILE_SUPPORT_ENABLED: support files >4GB (positions/sizes use 64-bit values) + ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED + | ClipboardGeneralCapabilityFlags::FILECLIP_NO_FILE_PATHS + | ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA + | ClipboardGeneralCapabilityFlags::HUGE_FILE_SUPPORT_ENABLED } fn on_ready(&mut self) {} @@ -548,12 +915,15 @@ impl CliprdrBackend for WasmClipboardBackend { self.send_event(WasmClipboardBackendMessage::ForceClipboardUpdate); } - fn on_format_list_received(&mut self) { - self.send_event(WasmClipboardBackendMessage::FormatListReceived); - } + fn on_process_negotiated_capabilities(&mut self, capabilities: ClipboardGeneralCapabilityFlags) { + info!(?capabilities, "CLIPRDR negotiated capabilities"); - fn on_process_negotiated_capabilities(&mut self, _: ClipboardGeneralCapabilityFlags) { - // No additional capabilities yet + if !capabilities.contains(ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED) { + warn!("CB_STREAM_FILECLIP_ENABLED not negotiated - file transfers will not work"); + } + if !capabilities.contains(ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA) { + warn!("CB_CAN_LOCK_CLIPDATA not negotiated - file transfer reliability may be reduced"); + } } fn on_remote_copy(&mut self, available_formats: &[ClipboardFormat]) { @@ -570,20 +940,72 @@ impl CliprdrBackend for WasmClipboardBackend { self.send_event(WasmClipboardBackendMessage::RemoteDataResponse(response.into_owned())); } - fn on_file_contents_request(&mut self, _request: FileContentsRequest) { - // File transfer not implemented yet + fn on_file_contents_request(&mut self, request: FileContentsRequest) { + // Forward file contents request to JS to retrieve file data + self.send_event(WasmClipboardBackendMessage::FileContentsRequest { + stream_id: request.stream_id, + index: request.index, + flags: request.flags, + position: request.position, + size: request.requested_size, + data_id: request.data_id, + }); + } + + fn on_file_contents_response(&mut self, response: FileContentsResponse<'_>) { + // Forward file contents response to JS (for downloads from remote) + self.send_event(WasmClipboardBackendMessage::FileContentsResponse { + stream_id: response.stream_id(), + is_error: response.is_error(), + data: response.data().to_owned(), + }); + } + + fn on_lock(&mut self, data_id: LockDataId) { + self.send_event(WasmClipboardBackendMessage::Lock { data_id }); + } + + fn on_unlock(&mut self, data_id: LockDataId) { + self.send_event(WasmClipboardBackendMessage::Unlock { data_id }); + } + + fn on_format_list_response(&mut self, ok: bool) { + self.send_event(WasmClipboardBackendMessage::FormatListResponse { ok }); + } + + fn on_remote_file_list(&mut self, files: &[ironrdp::cliprdr::pdu::FileDescriptor], clip_data_id: Option) { + let file_metadata: Vec = files.iter().map(FileMetadata::from_file_descriptor).collect(); + + self.send_event(WasmClipboardBackendMessage::FileListAdvertise { + files: file_metadata, + clip_data_id, + }); } - fn on_file_contents_response(&mut self, _response: FileContentsResponse<'_>) { - // File transfer not implemented yet + fn on_outgoing_locks_cleared(&mut self, clip_data_ids: &[LockDataId]) { + // Notify JS that locks expired due to inactivity timeout + // JS should clear references and abort associated transfers + if !clip_data_ids.is_empty() { + self.send_event(WasmClipboardBackendMessage::LocksExpired { + clip_data_ids: clip_data_ids.iter().map(|id| id.0).collect(), + }); + } } - fn on_lock(&mut self, _data_id: LockDataId) { - // File transfer not implemented yet + fn now_ms(&self) -> u64 { + // Prefer Performance.now() for a monotonic clock that won't jump + // backwards on NTP adjustments. Fall back to Date.now() if the + // Performance API is unavailable (e.g. in non-browser WASM runtimes). + #[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation, clippy::as_conversions)] + { + web_sys::window() + .and_then(|w| w.performance()) + .map_or_else(|| js_sys::Date::now() as u64, |p| p.now() as u64) + } } - fn on_unlock(&mut self, _data_id: LockDataId) { - // File transfer not implemented yet + fn elapsed_ms(&self, since: u64) -> u64 { + self.now_ms().saturating_sub(since) } } @@ -683,3 +1105,685 @@ impl iron_remote_desktop::ClipboardItem for ClipboardItem { } } } + +/// File metadata for JS interop. +/// +/// Simplified representation of [FileDescriptor] for WASM/JS boundary. +/// When passed to JavaScript, u64 values are converted to f64 which may lose +/// precision for files larger than 2^53 bytes (~9 PB). In practice, this is +/// acceptable because: +/// - Files >9 PB are extremely rare +/// - JavaScript Number has ~15-16 decimal digits of precision +/// - File systems typically don't support files that large +/// +/// ## [MS-RDPECLIP] Spec Notes +/// Per 2.2.5.2.3.1, file names must be ≤259 characters (leaving room for null +/// terminator in 260-character field). Names must not be empty. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FileMetadata { + /// File name (basename including extension, without directory path). + /// Per [MS-RDPECLIP] 2.2.5.2.3.1: max 259 chars, non-empty, no path separators. + pub(crate) name: String, + /// Relative directory path within the copied collection, using `\` as separator. + /// `None` for root-level files. + /// Per [MS-RDPECLIP] 3.1.1.2, file lists use relative paths (e.g., `temp\file1.txt`). + pub(crate) path: Option, + /// File size in bytes. + /// + /// When constructed via [`FileMetadata::from_file_descriptor`], `None` + /// (unknown) is mapped to `0`. In [`FileMetadata::to_file_descriptor`], + /// the size is always reported as known (`Some(self.size)`), which is + /// correct for the JS-to-remote upload path where JS `File.size` always + /// provides a concrete value. + /// + /// **Note**: When converted to JavaScript f64, precision loss occurs for sizes >2^53. + pub(crate) size: u64, + /// Last write time as a JavaScript timestamp (milliseconds since Unix epoch 1970-01-01). + /// 0 indicates unknown or not applicable (valid for files without timestamps). + /// Converted from/to Windows FILETIME at the WASM boundary so JS consumers can use + /// the value directly (e.g., `new Date(lastModified)`). + pub(crate) last_modified: u64, + /// Whether this entry represents a directory rather than a file. + pub(crate) is_directory: bool, +} + +impl FileMetadata { + pub(crate) fn from_file_descriptor(desc: &ironrdp::cliprdr::pdu::FileDescriptor) -> Self { + use ironrdp::cliprdr::pdu::ClipboardFileAttributes; + + let is_directory = desc + .attributes + .map(|a| a.contains(ClipboardFileAttributes::DIRECTORY)) + .unwrap_or(false); + + // Convert Windows FILETIME (100-ns intervals since 1601-01-01) to + // JavaScript timestamp (ms since Unix epoch 1970-01-01). This mirrors + // the reverse conversion in session.rs for the upload path. + const WINDOWS_EPOCH_DIFF: u64 = 116_444_736_000_000_000; + const TICKS_PER_MS: u64 = 10_000; + let last_modified = match desc.last_write_time { + Some(ft) if ft >= WINDOWS_EPOCH_DIFF => (ft - WINDOWS_EPOCH_DIFF) / TICKS_PER_MS, + _ => 0, + }; + + Self { + name: desc.name.clone(), + path: desc.relative_path.clone(), + size: desc.file_size.unwrap_or(0), + last_modified, + is_directory, + } + } + + pub(crate) fn to_file_descriptor(&self) -> anyhow::Result { + use ironrdp::cliprdr::pdu::{ClipboardFileAttributes, FileDescriptor}; + + // [MS-RDPECLIP] 2.2.5.2.3.1: File names must be <=259 characters (leaving room for null terminator). + // Check the full wire name (relative_path + \ + name) since that's what goes on the wire. + if self.name.is_empty() { + anyhow::bail!("File name cannot be empty per MS-RDPECLIP 2.2.5.2.3.1"); + } + let wire_len = match &self.path { + Some(p) if !p.is_empty() => p.chars().count() + 1 + self.name.chars().count(), + _ => self.name.chars().count(), + }; + if wire_len > 259 { + anyhow::bail!( + "Wire file name exceeds 259 character limit per MS-RDPECLIP 2.2.5.2.3.1 (has {wire_len} chars)", + ); + } + + let attributes = if self.is_directory { + Some(ClipboardFileAttributes::DIRECTORY) + } else { + Some(ClipboardFileAttributes::NORMAL) + }; + + // Convert JavaScript timestamp (ms since Unix epoch) back to Windows + // FILETIME (100-ns intervals since 1601-01-01) for the wire format. + // This mirrors the reverse conversion in from_file_descriptor(). + const WINDOWS_EPOCH_DIFF: u64 = 116_444_736_000_000_000; + const TICKS_PER_MS: u64 = 10_000; + let last_write_time = if self.last_modified > 0 { + Some( + self.last_modified + .saturating_mul(TICKS_PER_MS) + .saturating_add(WINDOWS_EPOCH_DIFF), + ) + } else { + None + }; + + let mut desc = FileDescriptor::new(self.name.clone()).with_file_size(self.size); + if let Some(attrs) = attributes { + desc = desc.with_attributes(attrs); + } + if let Some(time) = last_write_time { + desc = desc.with_last_write_time(time); + } + if let Some(path) = self.path.clone() { + desc = desc.with_relative_path(path); + } + Ok(desc) + } +} + +#[cfg(test)] +mod tests { + use ironrdp::cliprdr::pdu::FileDescriptor; + use ironrdp_core::AsAny as _; + + use super::*; + + // Helper to create a test message proxy + fn create_test_proxy() -> (WasmClipboardMessageProxy, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded(); + (WasmClipboardMessageProxy::new(tx), rx) + } + + // Mock JS callbacks that don't panic + fn create_test_callbacks() -> JsClipboardCallbacks { + JsClipboardCallbacks { + on_remote_clipboard_changed: js_sys::Function::new_no_args(""), + on_force_clipboard_update: Some(js_sys::Function::new_no_args("")), + on_files_available: Some(js_sys::Function::new_no_args("")), + on_file_contents_request: Some(js_sys::Function::new_no_args("")), + on_file_contents_response: Some(js_sys::Function::new_no_args("")), + on_lock: Some(js_sys::Function::new_no_args("")), + on_unlock: Some(js_sys::Function::new_no_args("")), + on_locks_expired: Some(js_sys::Function::new_no_args("")), + on_format_list_response: Some(js_sys::Function::new_no_args("")), + } + } + + #[test] + fn test_wasm_clipboard_backend_new() { + let (proxy, _rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let clipboard = WasmClipboard::new(proxy, callbacks); + + assert!(clipboard.local_clipboard.is_none()); + assert!(clipboard.remote_mapping.is_empty()); + } + + #[test] + fn test_local_clipboard_text_formats() { + let (proxy, _rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + let mut data = ClipboardData::new(); + data.items + .push(ClipboardItem::new_text("text/plain", "Hello World".to_owned())); + + let formats = clipboard.handle_local_clipboard_changed(data).unwrap(); + + // Should contain CF_UNICODETEXT + assert!(formats.iter().any(|f| f.id() == ClipboardFormatId::CF_UNICODETEXT)); + } + + #[test] + fn test_local_clipboard_html_formats() { + let (proxy, _rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + let mut data = ClipboardData::new(); + data.items + .push(ClipboardItem::new_text("text/html", "

Hello

".to_owned())); + + let formats = clipboard.handle_local_clipboard_changed(data).unwrap(); + + // Should contain CF_UNICODETEXT, HTML Format, and text/html + assert!(formats.iter().any(|f| f.id() == ClipboardFormatId::CF_UNICODETEXT)); + assert!(formats.iter().any(|f| f.id() == FORMAT_WIN_HTML_ID)); + assert!(formats.iter().any(|f| f.id() == FORMAT_MIME_HTML_ID)); + } + + #[test] + fn test_local_clipboard_image_formats() { + let (proxy, _rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + let mut data = ClipboardData::new(); + let png_data = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; // PNG magic + data.items.push(ClipboardItem::new_binary("image/png", png_data)); + + let formats = clipboard.handle_local_clipboard_changed(data).unwrap(); + + // Should contain CF_DIBV5, PNG, and image/png + assert!(formats.iter().any(|f| f.id() == ClipboardFormatId::CF_DIBV5)); + assert!(formats.iter().any(|f| f.id() == FORMAT_PNG_ID)); + assert!(formats.iter().any(|f| f.id() == FORMAT_MIME_PNG_ID)); + } + + #[test] + fn test_process_remote_data_request_no_local_clipboard() { + let (proxy, _rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + // Request format when no local clipboard is set + let result = clipboard.process_remote_data_request(ClipboardFormatId::CF_UNICODETEXT); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Local clipboard is empty")); + } + + #[test] + fn test_process_remote_data_request_text() { + let (proxy, _rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + let mut data = ClipboardData::new(); + data.items + .push(ClipboardItem::new_text("text/plain", "Hello World".to_owned())); + clipboard.handle_local_clipboard_changed(data).unwrap(); + + let response = clipboard + .process_remote_data_request(ClipboardFormatId::CF_UNICODETEXT) + .unwrap(); + + assert!(!response.is_error()); + let data = response.into_owned().into_data(); + // Should be UTF-16LE encoded with null terminator + assert!(!data.is_empty()); + } + + #[test] + fn test_message_proxy_send_cliprdr_message() { + let (proxy, mut rx) = create_test_proxy(); + + proxy.send_cliprdr_message(ClipboardMessage::SendInitiateCopy(vec![])); + + // Check message was sent + match rx.try_recv() { + Ok(RdpInputEvent::Cliprdr(_)) => {} + _ => panic!("Expected Cliprdr message"), + } + } + + #[test] + fn test_message_proxy_send_backend_message() { + let (proxy, mut rx) = create_test_proxy(); + + proxy.send_backend_message(WasmClipboardBackendMessage::ForceClipboardUpdate); + + // Check message was sent + match rx.try_recv() { + Ok(RdpInputEvent::ClipboardBackend(_)) => {} + _ => panic!("Expected ClipboardBackend message"), + } + } + + #[test] + fn test_file_metadata_conversions() { + let file_desc = FileDescriptor::new("test.txt") + .with_last_write_time(132_000_000_000_000_000) + .with_file_size(1024); + + let metadata = FileMetadata::from_file_descriptor(&file_desc); + + assert_eq!(metadata.name, "test.txt"); + assert_eq!(metadata.size, 1024); + assert!(metadata.last_modified > 0); // Should be converted to Unix timestamp + } + + #[test] + fn test_file_metadata_from_js_timestamp() { + let now = 1_700_000_000_000u64; // JavaScript timestamp (ms) + + let metadata = FileMetadata { + name: "test.txt".to_owned(), + path: None, + size: 1024, + last_modified: now, + is_directory: false, + }; + + let file_desc = metadata.to_file_descriptor().unwrap(); + + assert_eq!(file_desc.name, "test.txt"); + assert_eq!(file_desc.file_size, Some(1024)); + assert!(file_desc.last_write_time.is_some()); + } + + #[test] + fn test_process_event_local_clipboard_changed() { + let (proxy, mut rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + let mut data = ClipboardData::new(); + data.items + .push(ClipboardItem::new_text("text/plain", "test".to_owned())); + + let result = clipboard.process_event(WasmClipboardBackendMessage::LocalClipboardChanged(data)); + + assert!(result.is_ok()); + + // Should have sent SendInitiateCopy message + match rx.try_recv() { + Ok(RdpInputEvent::Cliprdr(ClipboardMessage::SendInitiateCopy(_))) => {} + _ => panic!("Expected SendInitiateCopy message"), + } + } + + #[test] + fn test_process_event_remote_data_request() { + let (proxy, mut rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + // Set up local clipboard first + let mut data = ClipboardData::new(); + data.items + .push(ClipboardItem::new_text("text/plain", "test".to_owned())); + clipboard.handle_local_clipboard_changed(data).unwrap(); + + // Clear the SendInitiateCopy message + let _ = rx.try_recv(); + + let result = clipboard.process_event(WasmClipboardBackendMessage::RemoteDataRequest( + ClipboardFormatId::CF_UNICODETEXT, + )); + + assert!(result.is_ok()); + + // Should have sent SendFormatData message + match rx.try_recv() { + Ok(RdpInputEvent::Cliprdr(ClipboardMessage::SendFormatData(_))) => {} + _ => panic!("Expected SendFormatData message"), + } + } + + #[test] + fn test_process_event_remote_data_request_error() { + let (proxy, mut rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + // Don't set up local clipboard - should result in error response + let result = clipboard.process_event(WasmClipboardBackendMessage::RemoteDataRequest( + ClipboardFormatId::CF_UNICODETEXT, + )); + + assert!(result.is_ok()); // Event processing succeeds, but sends error response + + // Should have sent SendFormatData with error + match rx.try_recv() { + Ok(RdpInputEvent::Cliprdr(ClipboardMessage::SendFormatData(response))) => { + assert!(response.is_error()); + } + _ => panic!("Expected SendFormatData error message"), + } + } + + #[test] + fn test_process_event_force_clipboard_update() { + let (proxy, _rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + let result = clipboard.process_event(WasmClipboardBackendMessage::ForceClipboardUpdate); + + // With callback present, should succeed without panicking + assert!(result.is_ok()); + } + + #[test] + fn test_process_event_force_clipboard_update_fallback() { + let (proxy, mut rx) = create_test_proxy(); + let mut callbacks = create_test_callbacks(); + callbacks.on_force_clipboard_update = None; // No callback + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + let result = clipboard.process_event(WasmClipboardBackendMessage::ForceClipboardUpdate); + + assert!(result.is_ok()); + + // Should fall back to sending empty LocalClipboardChanged + match rx.try_recv() { + Ok(RdpInputEvent::Cliprdr(ClipboardMessage::SendInitiateCopy(formats))) => { + assert!(formats.is_empty()); // Empty clipboard + } + _ => panic!("Expected empty SendInitiateCopy message"), + } + } + + #[test] + fn test_clipboard_backend_capabilities() { + let (proxy, _rx) = create_test_proxy(); + let backend = WasmClipboardBackend { proxy }; + + let caps = backend.client_capabilities(); + + // Should include file transfer capabilities + assert!(caps.contains(ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED)); + assert!(caps.contains(ClipboardGeneralCapabilityFlags::FILECLIP_NO_FILE_PATHS)); + assert!(caps.contains(ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA)); + } + + #[test] + fn test_clipboard_backend_as_any() { + let (proxy, _rx) = create_test_proxy(); + let mut backend = WasmClipboardBackend { proxy }; + + // Test AsAny trait implementation + let any_ref = backend.as_any(); + assert!(any_ref.is::()); + + let any_mut = backend.as_any_mut(); + assert!(any_mut.is::()); + } + + #[test] + fn test_multiple_clipboard_items() { + let (proxy, _rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + let mut data = ClipboardData::new(); + data.items + .push(ClipboardItem::new_text("text/plain", "plain text".to_owned())); + data.items + .push(ClipboardItem::new_text("text/html", "

html

".to_owned())); + + let formats = clipboard.handle_local_clipboard_changed(data).unwrap(); + + // Should contain formats for both text and HTML + assert!(formats.len() >= 3); // CF_UNICODETEXT, HTML Format, text/html + } + + #[test] + fn test_empty_clipboard() { + let (proxy, _rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + let data = ClipboardData::new(); // Empty + + let formats = clipboard.handle_local_clipboard_changed(data).unwrap(); + + assert!(formats.is_empty()); + } + + #[test] + fn test_unsupported_mime_type() { + let (proxy, _rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + let mut data = ClipboardData::new(); + data.items + .push(ClipboardItem::new_binary("application/octet-stream", vec![1, 2, 3])); + + let formats = clipboard.handle_local_clipboard_changed(data).unwrap(); + + // Unsupported MIME type should not generate any formats + assert!(formats.is_empty()); + } + + #[test] + fn test_backend_temporary_directory() { + let (proxy, _rx) = create_test_proxy(); + let backend = WasmClipboardBackend { proxy }; + + let temp_dir = backend.temporary_directory(); + + // Should return a valid path + assert!(!temp_dir.is_empty()); + } + + #[test] + fn test_file_descriptor_with_minimal_fields() { + let file_desc = FileDescriptor::new("minimal.txt"); + + let metadata = FileMetadata::from_file_descriptor(&file_desc); + + assert_eq!(metadata.name, "minimal.txt"); + assert_eq!(metadata.size, 0); // None maps to 0 (unknown size) + assert_eq!(metadata.last_modified, 0); // None maps to 0 (unknown timestamp) + } + + #[test] + fn test_file_descriptor_round_trip() { + let original = FileMetadata { + name: "roundtrip.txt".to_owned(), + path: None, + size: 4096, + last_modified: 1_700_000_000_000, + is_directory: false, + }; + + let file_desc = original.to_file_descriptor().unwrap(); + let converted = FileMetadata::from_file_descriptor(&file_desc); + + assert_eq!(converted.name, original.name); + assert_eq!(converted.path, original.path); + assert_eq!(converted.size, original.size); + assert_eq!(converted.is_directory, original.is_directory); + // Note: timestamp conversion may lose some precision, so we check within tolerance + let diff = converted.last_modified.abs_diff(original.last_modified); + assert!(diff < 1000); + } + + #[test] + fn test_zero_size_file_reports_known_size() { + // Zero-byte files (e.g. .gitkeep) should produce file_size: Some(0), + // not None (which means "unknown" per MS-RDPECLIP 2.2.5.2.3.1). + let metadata = FileMetadata { + name: ".gitkeep".to_owned(), + path: None, + size: 0, + last_modified: 0, + is_directory: false, + }; + + let file_desc = metadata.to_file_descriptor().unwrap(); + assert_eq!(file_desc.file_size, Some(0)); + } + + // Helper: create a ClipboardFormat for FileGroupDescriptorW with a given registered ID. + fn file_list_format(id: u32) -> ClipboardFormat { + ClipboardFormat::new(ClipboardFormatId::new(id)).with_name(ClipboardFormatName::FILE_LIST) + } + + #[test] + fn test_remote_clipboard_changed_detects_file_format_in_mixed_list() { + // A mixed FormatList with text and FileGroupDescriptorW should: + // - Add text format to remote_formats_to_read + // - Store file format in pending_file_list_paste (NOT in remote_formats_to_read) + let (proxy, _rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + let formats = vec![ + ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT), + file_list_format(0xC080), + ]; + + let result = clipboard.process_remote_clipboard_changed(formats).unwrap(); + + // Should return CF_UNICODETEXT as the format to fetch + assert_eq!(result, Some(ClipboardFormatId::CF_UNICODETEXT)); + // File format should NOT be in remote_formats_to_read + assert!( + !clipboard + .remote_formats_to_read + .contains(&ClipboardFormatId::new(0xC080)), + "FileGroupDescriptorW should not be in remote_formats_to_read" + ); + // File format should be stored for deferred paste + assert_eq!(clipboard.pending_file_list_paste, Some(ClipboardFormatId::new(0xC080))); + } + + #[test] + fn test_remote_clipboard_changed_files_only() { + // A FormatList with only FileGroupDescriptorW should return None + // (no text/image formats to fetch) and store the file format for deferred paste. + let (proxy, _rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + let formats = vec![file_list_format(0xC080)]; + + let result = clipboard.process_remote_clipboard_changed(formats).unwrap(); + + assert_eq!(result, None, "No text/image formats should be returned"); + assert_eq!(clipboard.pending_file_list_paste, Some(ClipboardFormatId::new(0xC080))); + } + + #[test] + fn test_process_event_files_only_triggers_immediate_paste() { + // When FormatList contains only FileGroupDescriptorW, process_event should + // immediately send SendInitiatePaste for the file format. + let (proxy, mut rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + let formats = vec![file_list_format(0xC080)]; + clipboard + .process_event(WasmClipboardBackendMessage::RemoteClipboardChanged(formats)) + .unwrap(); + + match rx.try_recv() { + Ok(RdpInputEvent::Cliprdr(ClipboardMessage::SendInitiatePaste(format))) => { + assert_eq!(format, ClipboardFormatId::new(0xC080)); + } + other => panic!("Expected SendInitiatePaste for file format, got: {other:?}"), + } + } + + #[test] + fn test_deferred_file_paste_after_text_formats() { + // When FormatList has text + files, the file format paste should be deferred + // until all text formats are fetched. Simulate the full chain: + // 1. RemoteClipboardChanged with text + file + // 2. First SendInitiatePaste for text + // 3. RemoteDataResponse for text + // 4. After text is done, SendInitiatePaste for file format should be sent + let (proxy, mut rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + // Step 1: Remote clipboard changed with text + file + let formats = vec![ + ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT), + file_list_format(0xC080), + ]; + clipboard + .process_event(WasmClipboardBackendMessage::RemoteClipboardChanged(formats)) + .unwrap(); + + // Step 2: Should get SendInitiatePaste for text first + match rx.try_recv() { + Ok(RdpInputEvent::Cliprdr(ClipboardMessage::SendInitiatePaste(format))) => { + assert_eq!(format, ClipboardFormatId::CF_UNICODETEXT); + } + other => panic!("Expected SendInitiatePaste for CF_UNICODETEXT, got: {other:?}"), + } + + // Step 3: Simulate FormatDataResponse for text (null-terminated UTF-16LE "hi") + let text_data: Vec = vec![0x68, 0x00, 0x69, 0x00, 0x00, 0x00]; // "hi\0" in UTF-16LE + let response = FormatDataResponse::new_data(text_data); + clipboard + .process_event(WasmClipboardBackendMessage::RemoteDataResponse(response.into_owned())) + .unwrap(); + + // Step 4: After text is done, should get SendInitiatePaste for file format + match rx.try_recv() { + Ok(RdpInputEvent::Cliprdr(ClipboardMessage::SendInitiatePaste(format))) => { + assert_eq!( + format, + ClipboardFormatId::new(0xC080), + "Deferred file list paste should fire after text formats are done" + ); + } + other => panic!("Expected deferred SendInitiatePaste for file format, got: {other:?}"), + } + } + + #[test] + fn test_new_format_list_clears_pending_file_paste() { + // A new FormatList should clear any pending file list paste from a previous one. + let (proxy, _rx) = create_test_proxy(); + let callbacks = create_test_callbacks(); + let mut clipboard = WasmClipboard::new(proxy, callbacks); + + // First FormatList with files + let formats = vec![file_list_format(0xC080)]; + clipboard.process_remote_clipboard_changed(formats).unwrap(); + assert!(clipboard.pending_file_list_paste.is_some()); + + // Second FormatList without files replaces the first + let formats = vec![ClipboardFormat::new(ClipboardFormatId::CF_UNICODETEXT)]; + clipboard.process_remote_clipboard_changed(formats).unwrap(); + assert_eq!( + clipboard.pending_file_list_paste, None, + "New FormatList without files should clear pending file paste" + ); + } +} diff --git a/crates/ironrdp-web/src/error.rs b/crates/ironrdp-web/src/error.rs index 4e159ef94c..1498451088 100644 --- a/crates/ironrdp-web/src/error.rs +++ b/crates/ironrdp-web/src/error.rs @@ -1,9 +1,10 @@ -use iron_remote_desktop::IronErrorKind; -use ironrdp::connector::{self, sspi, ConnectorErrorKind}; +use iron_remote_desktop::{IronErrorKind, RDCleanPathDetails}; +use ironrdp::connector::{self, ConnectorErrorKind, sspi}; pub(crate) struct IronError { kind: IronErrorKind, source: anyhow::Error, + rdcleanpath_details: Option, } impl IronError { @@ -11,23 +12,36 @@ impl IronError { self.kind = kind; self } + + pub(crate) fn with_rdcleanpath_details(mut self, details: RDCleanPathDetails) -> Self { + debug_assert!( + matches!(self.kind, IronErrorKind::RDCleanPath), + "rdcleanpath_details should only be set for RDCleanPath errors" + ); + self.rdcleanpath_details = Some(details); + self + } } impl iron_remote_desktop::IronError for IronError { fn backtrace(&self) -> String { - format!("{:?}", self.source) + format!("{:#}", self.source) } fn kind(&self) -> IronErrorKind { self.kind } + + fn rdcleanpath_details(&self) -> Option { + self.rdcleanpath_details + } } impl From for IronError { fn from(e: connector::ConnectorError) -> Self { use sspi::credssp::NStatusCode; - let kind = match e.kind { + let kind = match e.kind() { ConnectorErrorKind::Credssp(sspi::Error { nstatus: Some(NStatusCode::WRONG_PASSWORD), .. @@ -44,6 +58,7 @@ impl From for IronError { Self { kind, source: anyhow::Error::new(e), + rdcleanpath_details: None, } } } @@ -53,6 +68,7 @@ impl From for IronError { Self { kind: IronErrorKind::General, source: anyhow::Error::new(e), + rdcleanpath_details: None, } } } @@ -62,6 +78,7 @@ impl From for IronError { Self { kind: IronErrorKind::General, source: e, + rdcleanpath_details: None, } } } diff --git a/crates/ironrdp-web/src/image.rs b/crates/ironrdp-web/src/image.rs index 13ac3fedbf..2df63efcf4 100644 --- a/crates/ironrdp-web/src/image.rs +++ b/crates/ironrdp-web/src/image.rs @@ -2,18 +2,29 @@ use ironrdp::pdu::geometry::{InclusiveRectangle, Rectangle as _}; use ironrdp::session::image::DecodedImage; - -pub(crate) fn extract_partial_image(image: &DecodedImage, region: InclusiveRectangle) -> (InclusiveRectangle, Vec) { +use ironrdp_core::WriteBuf; + +/// Copies the dirty `region` into `buffer` from its current cursor (clear it between regions). +/// The returned rect may be wider than `region`: the whole-rows path widens to full image width. +pub(crate) fn extract_partial_image( + image: &DecodedImage, + region: InclusiveRectangle, + buffer: &mut WriteBuf, +) -> InclusiveRectangle { // PERF: needs actual benchmark to find a better heuristic if region.height() > 64 || region.width() > 512 { - extract_whole_rows(image, region) + extract_whole_rows(image, region, buffer) } else { - extract_smallest_rectangle(image, region) + extract_smallest_rectangle(image, region, buffer) } } // Faster for low-height and smaller images -fn extract_smallest_rectangle(image: &DecodedImage, region: InclusiveRectangle) -> (InclusiveRectangle, Vec) { +fn extract_smallest_rectangle( + image: &DecodedImage, + region: InclusiveRectangle, + buffer: &mut WriteBuf, +) -> InclusiveRectangle { let pixel_size = usize::from(image.pixel_format().bytes_per_pixel()); let image_width = usize::from(image.width()); @@ -26,7 +37,7 @@ fn extract_smallest_rectangle(image: &DecodedImage, region: InclusiveRectangle) let region_stride = region_width * pixel_size; let dst_buf_size = region_width * region_height * pixel_size; - let mut dst = vec![0; dst_buf_size]; + let dst = buffer.unfilled_to(dst_buf_size); let src = image.data(); @@ -42,11 +53,13 @@ fn extract_smallest_rectangle(image: &DecodedImage, region: InclusiveRectangle) target_slice.copy_from_slice(src_slice); } - (region, dst) + buffer.advance(dst_buf_size); + + region } // Faster for high-height and bigger images -fn extract_whole_rows(image: &DecodedImage, region: InclusiveRectangle) -> (InclusiveRectangle, Vec) { +fn extract_whole_rows(image: &DecodedImage, region: InclusiveRectangle, buffer: &mut WriteBuf) -> InclusiveRectangle { let pixel_size = usize::from(image.pixel_format().bytes_per_pixel()); let image_width = usize::from(image.width()); @@ -59,15 +72,15 @@ fn extract_whole_rows(image: &DecodedImage, region: InclusiveRectangle) -> (Incl let src_begin = region_top * image_stride; let src_end = (region_bottom + 1) * image_stride; + let len = src_end - src_begin; - let dst = src[src_begin..src_end].to_vec(); + buffer.unfilled_to(len).copy_from_slice(&src[src_begin..src_end]); + buffer.advance(len); - let wider_region = InclusiveRectangle { + InclusiveRectangle { left: 0, top: region.top, right: image.width() - 1, bottom: region.bottom, - }; - - (wider_region, dst) + } } diff --git a/crates/ironrdp-web/src/input.rs b/crates/ironrdp-web/src/input.rs index f22b5fec52..c6f1ea7ecc 100644 --- a/crates/ironrdp-web/src/input.rs +++ b/crates/ironrdp-web/src/input.rs @@ -1,3 +1,4 @@ +use iron_remote_desktop::RotationUnit; use ironrdp::input::{MouseButton, MousePosition, Operation, Scancode, WheelRotations}; use smallvec::SmallVec; use tracing::warn; @@ -30,10 +31,23 @@ impl iron_remote_desktop::DeviceEvent for DeviceEvent { Self(Operation::MouseMove(MousePosition { x, y })) } - fn wheel_rotations(vertical: bool, rotation_units: i16) -> Self { + fn wheel_rotations(vertical: bool, rotation_amount: i16, rotation_unit: RotationUnit) -> Self { + const LINES_TO_PIXELS_SCALE: i16 = 50; + const PAGES_TO_LINES_SCALE: i16 = 38; + + let lines_to_pixels = |lines: i16| lines * LINES_TO_PIXELS_SCALE; + + let pages_to_pixels = |pages: i16| pages * PAGES_TO_LINES_SCALE * LINES_TO_PIXELS_SCALE; + + let rotation_amount = match rotation_unit { + RotationUnit::Pixel => rotation_amount, + RotationUnit::Line => lines_to_pixels(rotation_amount), + RotationUnit::Page => pages_to_pixels(rotation_amount), + }; + Self(Operation::WheelRotations(WheelRotations { is_vertical: vertical, - rotation_units, + rotation_units: rotation_amount, })) } diff --git a/crates/ironrdp-web/src/lib.rs b/crates/ironrdp-web/src/lib.rs index b98ebb14bd..664e42e698 100644 --- a/crates/ironrdp-web/src/lib.rs +++ b/crates/ironrdp-web/src/lib.rs @@ -12,6 +12,7 @@ extern crate chrono as _; extern crate getrandom as _; extern crate getrandom2 as _; +extern crate getrandom4 as _; extern crate time as _; mod canvas; @@ -20,6 +21,7 @@ mod error; mod image; mod input; mod network_client; +mod printer; mod rdp_file; mod session; diff --git a/crates/ironrdp-web/src/network_client.rs b/crates/ironrdp-web/src/network_client.rs index c779ca6e28..71bf8c1af4 100644 --- a/crates/ironrdp-web/src/network_client.rs +++ b/crates/ironrdp-web/src/network_client.rs @@ -1,53 +1,45 @@ -use core::pin::Pin; - -use futures_util::Future; use ironrdp::connector::sspi::generator::NetworkRequest; use ironrdp::connector::sspi::network_client::NetworkProtocol; -use ironrdp::connector::{custom_err, reason_err, ConnectorResult}; -use ironrdp_futures::AsyncNetworkClient; +use ironrdp::connector::{ConnectorResult, custom_err, reason_err}; +use ironrdp_futures::NetworkClient; use tracing::debug; #[derive(Debug)] pub(crate) struct WasmNetworkClient; -impl AsyncNetworkClient for WasmNetworkClient { - fn send<'a>( - &'a mut self, - network_request: &'a NetworkRequest, - ) -> Pin>> + 'a>> { - Box::pin(async move { - debug!(?network_request.protocol, ?network_request.url); - - match &network_request.protocol { - NetworkProtocol::Http | NetworkProtocol::Https => { - let body = js_sys::Uint8Array::from(&network_request.data[..]); - - let response = gloo_net::http::Request::post(network_request.url.as_str()) - .header("keep-alive", "true") - .body(body) - .map_err(|e| custom_err!("failed to send KDC request", e))? - .send() - .await - .map_err(|e| custom_err!("failed to send KDC request", e))?; - - if !response.ok() { - return Err(reason_err!( - "KdcProxy", - "HTTP status error ({} {})", - response.status(), - response.status_text(), - )); - } +impl NetworkClient for WasmNetworkClient { + async fn send(&mut self, network_request: &NetworkRequest) -> ConnectorResult> { + debug!(?network_request.protocol, ?network_request.url); + + match &network_request.protocol { + NetworkProtocol::Http | NetworkProtocol::Https => { + let body = js_sys::Uint8Array::from(network_request.data.as_slice()); + + let response = gloo_net::http::Request::post(network_request.url.as_str()) + .header("keep-alive", "true") + .body(body) + .map_err(|e| custom_err!("failed to send KDC request", e))? + .send() + .await + .map_err(|e| custom_err!("failed to send KDC request", e))?; + + if !response.ok() { + return Err(reason_err!( + "KdcProxy", + "HTTP status error ({} {})", + response.status(), + response.status_text(), + )); + } - let body = response - .binary() - .await - .map_err(|e| custom_err!("failed to retrieve HTTP response", e))?; + let body = response + .binary() + .await + .map_err(|e| custom_err!("failed to retrieve HTTP response", e))?; - Ok(body) - } - unsupported => Err(reason_err!("CredSSP", "unsupported protocol: {unsupported:?}")), + Ok(body) } - }) + unsupported => Err(reason_err!("CredSSP", "unsupported protocol: {unsupported:?}")), + } } } diff --git a/crates/ironrdp-web/src/printer.rs b/crates/ironrdp-web/src/printer.rs new file mode 100644 index 0000000000..918dd5cb1f --- /dev/null +++ b/crates/ironrdp-web/src/printer.rs @@ -0,0 +1,692 @@ +//! Browser-side virtual printer backend for RDPDR. +//! +//! Architecture mirrors the clipboard backend ([`crate::clipboard`]): +//! +//! * [`WasmPrinterBackend`] lives on the SVC processor side and implements +//! [`ironrdp::rdpdr::backend::RdpdrBackend`]. It is `Send` (required by the +//! trait) and holds only per-handle byte counts plus an mpsc proxy — no +//! JS callbacks. +//! * [`WasmPrinter`] lives in the session event loop and owns the +//! `js_sys::Function` callbacks. Per-job stream messages flow from the +//! backend to the event loop via [`PrinterBackendMessage`]. +//! +//! The IRP completion responses (DR_CREATE_RSP / DR_WRITE_RSP / DR_CLOSE_RSP) +//! are synthesised synchronously inside the backend — the RDP peer tracks +//! outstanding IRPs by `completion_id`, so completions just need to get +//! queued onto the SVC out-stream; they don't need JS roundtrips. Print data +//! is streamed to the event loop as writes arrive, so completed jobs are not +//! buffered inside the RDPDR backend. Each +//! response is wrapped in its matching [`RdpdrPdu`] variant so the +//! [`SvcMessage`] layer prepends the correct RDPDR `SharedHeader` +//! (`RDPDR_CTYP_CORE` + `PAKID_CORE_DEVICE_IOCOMPLETION`) automatically. + +use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; + +use core::sync::atomic::{AtomicUsize, Ordering}; + +use futures_channel::mpsc; +use ironrdp::rdpdr::backend::RdpdrBackend; +use ironrdp::rdpdr::pdu::RdpdrPdu; +use ironrdp::rdpdr::pdu::efs::{ + DeviceCloseResponse, DeviceControlRequest, DeviceCreateResponse, DeviceIoResponse, DeviceWriteResponse, + Information, NtStatus, PrinterIoRequest, ServerDeviceAnnounceResponse, ServerDriveIoRequest, +}; +use ironrdp::rdpdr::pdu::esc::{ScardCall, ScardIoCtlCode}; +use ironrdp_core::impl_as_any; +use ironrdp_pdu::PduResult; +use ironrdp_svc::SvcMessage; +use tracing::{debug, error, trace, warn}; +use wasm_bindgen::prelude::*; + +use crate::session::RdpInputEvent; + +/// Maximum in-memory print job accepted by the browser backend. +const MAX_PRINT_JOB_BYTES: usize = 128 * 1024 * 1024; // 128 MiB +/// Maximum pending print data bytes allowed to wait in the event queue. +const MAX_QUEUED_PRINT_DATA_BYTES: usize = MAX_PRINT_JOB_BYTES; + +/// Messages sent from the printer backend to the session event loop. +#[derive(Debug)] +pub(crate) enum PrinterBackendMessage { + /// A server-created printer file handle started a new print job. + Created { file_id: u32 }, + /// A print job data chunk produced by the announced server-side driver. + Data { + file_id: u32, + document_bytes: Vec, + _queued_bytes: QueuedPrintDataBytes, + }, + /// The server closed the print job file handle. + Completed { file_id: u32 }, + /// The backend rejected or dropped the job before completion. + Aborted { file_id: u32 }, +} + +pub(crate) struct QueuedPrintDataBytes { + len: usize, + queued_bytes: Arc, +} + +impl Drop for QueuedPrintDataBytes { + fn drop(&mut self) { + self.queued_bytes.fetch_sub(self.len, Ordering::AcqRel); + } +} + +impl fmt::Debug for QueuedPrintDataBytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("QueuedPrintDataBytes") + .field("len", &self.len) + .finish_non_exhaustive() + } +} + +/// mpsc proxy used by the backend to stream print jobs to the event loop. +#[derive(Debug, Clone)] +pub(crate) struct WasmPrinterMessageProxy { + tx: mpsc::UnboundedSender, + queued_data_bytes: Arc, + queued_data_bytes_limit: usize, +} + +impl WasmPrinterMessageProxy { + pub(crate) fn new(tx: mpsc::UnboundedSender) -> Self { + Self::new_with_limit(tx, MAX_QUEUED_PRINT_DATA_BYTES) + } + + fn send_job_created(&self, file_id: u32) -> bool { + self.send_message(PrinterBackendMessage::Created { file_id }) + } + + fn send_job_data(&self, file_id: u32, document_bytes: Vec) -> bool { + let Some(queued_bytes) = self.reserve_queue_capacity(document_bytes.len()) else { + warn!( + file_id, + bytes = document_bytes.len(), + limit = self.queued_data_bytes_limit, + "Print job data exceeds queued print data byte budget" + ); + return false; + }; + + self.send_message(PrinterBackendMessage::Data { + file_id, + document_bytes, + _queued_bytes: queued_bytes, + }) + } + + fn send_job_completed(&self, file_id: u32) -> bool { + self.send_message(PrinterBackendMessage::Completed { file_id }) + } + + fn send_job_aborted(&self, file_id: u32) { + let _ = self.send_message(PrinterBackendMessage::Aborted { file_id }); + } + + fn send_message(&self, message: PrinterBackendMessage) -> bool { + if self.tx.unbounded_send(RdpInputEvent::Printer(message)).is_err() { + error!("Failed to queue printer backend message, event loop receiver is closed"); + return false; + } + + true + } + + fn reserve_queue_capacity(&self, len: usize) -> Option { + let mut queued = self.queued_data_bytes.load(Ordering::Acquire); + loop { + let next = queued.checked_add(len)?; + if next > self.queued_data_bytes_limit { + return None; + } + + match self + .queued_data_bytes + .compare_exchange_weak(queued, next, Ordering::AcqRel, Ordering::Acquire) + { + Ok(_) => { + return Some(QueuedPrintDataBytes { + len, + queued_bytes: Arc::clone(&self.queued_data_bytes), + }); + } + Err(actual) => queued = actual, + } + } + } + + fn new_with_limit(tx: mpsc::UnboundedSender, queued_data_bytes_limit: usize) -> Self { + Self { + tx, + queued_data_bytes: Arc::new(AtomicUsize::new(0)), + queued_data_bytes_limit, + } + } +} + +#[derive(Debug)] +struct OpenPrintJob { + bytes_written: usize, +} + +/// RDPDR backend that streams a server-initiated print job to the session +/// event loop as write IRPs arrive. +#[derive(Debug)] +pub(crate) struct WasmPrinterBackend { + /// Per-file-handle document byte counts. Populated on `IRP_MJ_CREATE`, + /// updated by `IRP_MJ_WRITE`, drained on `IRP_MJ_CLOSE`. + open_files: HashMap, + /// Monotonic file id counter. The server doesn't care what we stamp + /// into `DR_CREATE_RSP::FileId` as long as it's unique per open handle + /// and we echo it back on subsequent Write/Close IRPs. + next_file_id: u32, + max_print_job_bytes: usize, + proxy: WasmPrinterMessageProxy, +} + +impl_as_any!(WasmPrinterBackend); + +impl WasmPrinterBackend { + pub(crate) fn new(proxy: WasmPrinterMessageProxy) -> Self { + Self::new_with_limit(proxy, MAX_PRINT_JOB_BYTES) + } + + fn new_with_limit(proxy: WasmPrinterMessageProxy, max_print_job_bytes: usize) -> Self { + Self { + open_files: HashMap::new(), + next_file_id: 1, + max_print_job_bytes, + proxy, + } + } + + fn allocate_file_id(&mut self) -> u32 { + let id = self.next_file_id; + self.next_file_id = self.next_file_id.wrapping_add(1); + if self.next_file_id == 0 { + self.next_file_id = 1; + } + id + } +} + +impl RdpdrBackend for WasmPrinterBackend { + fn handle_server_device_announce_response(&mut self, pdu: ServerDeviceAnnounceResponse) -> PduResult<()> { + // Surface server-side rejection at `warn!` so silent failures + // (where a redirected device never appears in the session) are + // visible at the default tracing level. + if pdu.result_code == NtStatus::SUCCESS { + debug!(device_id = pdu.device_id, "RDPDR device announce accepted by server"); + } else { + warn!( + device_id = pdu.device_id, + result_code = ?pdu.result_code, + "RDPDR device announce rejected by server; redirected device will not appear in session" + ); + } + Ok(()) + } + + fn handle_scard_call(&mut self, _req: DeviceControlRequest, _call: ScardCall) -> PduResult<()> { + warn!("Smartcard IOCTL reached printer-only backend; ignoring"); + Ok(()) + } + + fn handle_drive_io_request(&mut self, _req: ServerDriveIoRequest) -> PduResult> { + warn!("Drive IRP reached printer-only backend; ignoring"); + Ok(Vec::new()) + } + + fn handle_printer_io_request(&mut self, req: PrinterIoRequest) -> PduResult> { + match req { + PrinterIoRequest::Create(create) => { + let file_id = self.allocate_file_id(); + let io_status = if self.proxy.send_job_created(file_id) { + self.open_files.insert(file_id, OpenPrintJob { bytes_written: 0 }); + trace!(file_id, path = %create.path, "IRP_MJ_CREATE: opened print handle"); + NtStatus::SUCCESS + } else { + NtStatus::UNSUCCESSFUL + }; + + let response = DeviceCreateResponse { + device_io_reply: DeviceIoResponse::new(create.device_io_request, io_status), + file_id, + // A virtual printer is conceptually opened fresh every time; + // the bridge's former implementation used FILE_OPENED and + // Windows' own redirector accepts either value. + information: if io_status == NtStatus::SUCCESS { + Information::FILE_OPENED + } else { + Information::empty() + }, + }; + Ok(vec![SvcMessage::from(RdpdrPdu::DeviceCreateResponse(response))]) + } + PrinterIoRequest::Write(write) => { + let file_id = write.device_io_request.file_id; + let device_io_request = write.device_io_request; + let write_data = write.write_data; + // INVARIANT: write.write_data was decoded via a u32-length-prefixed + // wire field (MS-RDPEFS 2.2.1.4.4 DR_WRITE_REQ Length), so its + // in-memory Vec length always round-trips back to a u32. + let data_len = u32::try_from(write_data.len()).expect("write length round-trips from u32 wire decode"); + + let mut drop_partial_job = false; + let io_status = match self.open_files.get_mut(&file_id) { + Some(job) => { + if let Some(projected_len) = job + .bytes_written + .checked_add(write_data.len()) + .filter(|len| *len <= self.max_print_job_bytes) + { + if self.proxy.send_job_data(file_id, write_data) { + job.bytes_written = projected_len; + trace!( + file_id, + chunk = data_len, + total = job.bytes_written, + "IRP_MJ_WRITE: streamed" + ); + NtStatus::SUCCESS + } else { + warn!( + file_id, + chunk = data_len, + current = job.bytes_written, + limit = self.max_print_job_bytes, + "IRP_MJ_WRITE could not be queued; rejecting and dropping partial job" + ); + drop_partial_job = true; + NtStatus::UNSUCCESSFUL + } + } else { + warn!( + file_id, + chunk = data_len, + current = job.bytes_written, + limit = self.max_print_job_bytes, + "IRP_MJ_WRITE exceeds print job size limit; rejecting and dropping partial job" + ); + drop_partial_job = true; + NtStatus::UNSUCCESSFUL + } + } + None => { + warn!(file_id, "IRP_MJ_WRITE for unknown file_id; rejecting"); + NtStatus::UNSUCCESSFUL + } + }; + if drop_partial_job { + self.open_files.remove(&file_id); + self.proxy.send_job_aborted(file_id); + } + + let response = DeviceWriteResponse { + device_io_reply: DeviceIoResponse::new(device_io_request, io_status), + length: if io_status == NtStatus::SUCCESS { data_len } else { 0 }, + }; + Ok(vec![SvcMessage::from(RdpdrPdu::DeviceWriteResponse(response))]) + } + PrinterIoRequest::Close(close) => { + let file_id = close.device_io_request.file_id; + let io_status = if let Some(job) = self.open_files.remove(&file_id) { + debug!( + file_id, + bytes = job.bytes_written, + "IRP_MJ_CLOSE: completing streamed print job" + ); + if self.proxy.send_job_completed(file_id) { + NtStatus::SUCCESS + } else { + NtStatus::UNSUCCESSFUL + } + } else { + warn!(file_id, "IRP_MJ_CLOSE for unknown file_id; no job to deliver"); + NtStatus::UNSUCCESSFUL + }; + + let response = DeviceCloseResponse { + device_io_response: DeviceIoResponse::new(close.device_io_request, io_status), + }; + Ok(vec![SvcMessage::from(RdpdrPdu::DeviceCloseResponse(response))]) + } + } + } +} + +/// Event-loop-side companion to [`WasmPrinterBackend`]. Owns the +/// `js_sys::Function` callbacks (`!Send`, so they live here, not in the +/// backend). The session event loop forwards every +/// [`PrinterBackendMessage`] into [`WasmPrinter::process_message`]. +#[derive(Debug)] +pub(crate) struct WasmPrinter { + callbacks: JsPrinterStreamCallbacks, +} + +#[derive(Debug, Clone)] +pub(crate) struct JsPrinterStreamCallbacks { + /// Optional `function(fileId: number): void`. + pub(crate) on_job_start: Option, + /// Required `function(fileId: number, chunk: Uint8Array): void`. + pub(crate) on_job_data: js_sys::Function, + /// Required `function(fileId: number): void`. + pub(crate) on_job_complete: js_sys::Function, + /// Optional `function(fileId: number): void`. + pub(crate) on_job_error: Option, +} + +impl WasmPrinter { + pub(crate) fn new(callbacks: JsPrinterStreamCallbacks) -> Self { + Self { callbacks } + } + + pub(crate) fn process_message(&self, message: PrinterBackendMessage) { + let this = JsValue::NULL; + match message { + PrinterBackendMessage::Created { file_id } => { + if let Some(on_job_start) = &self.callbacks.on_job_start { + let file_id = JsValue::from(file_id); + if let Err(err) = on_job_start.call1(&this, &file_id) { + error!(?err, "on_job_start JS callback threw"); + } + } + } + PrinterBackendMessage::Data { + file_id, + document_bytes, + _queued_bytes: _, + } => { + trace!( + file_id, + bytes = document_bytes.len(), + "Delivering print data chunk to JS callback" + ); + let file_id = JsValue::from(file_id); + let array = js_sys::Uint8Array::from(document_bytes.as_slice()); + if let Err(err) = self.callbacks.on_job_data.call2(&this, &file_id, &array) { + error!(?err, "on_job_data JS callback threw"); + } + } + PrinterBackendMessage::Completed { file_id } => { + let file_id = JsValue::from(file_id); + if let Err(err) = self.callbacks.on_job_complete.call1(&this, &file_id) { + error!(?err, "on_job_complete JS callback threw"); + } + } + PrinterBackendMessage::Aborted { file_id } => { + if let Some(on_job_error) = &self.callbacks.on_job_error { + let file_id = JsValue::from(file_id); + if let Err(err) = on_job_error.call1(&this, &file_id) { + error!(?err, "on_job_error JS callback threw"); + } + } + } + } + } +} + +/// Factory used by [`crate::session::SessionBuilder::connect`] to build a +/// matched (backend, event-loop) pair from a single mpsc channel. +pub(crate) fn wasm_printer_pair( + input_events_tx: mpsc::UnboundedSender, + callbacks: JsPrinterStreamCallbacks, +) -> (WasmPrinterBackend, WasmPrinter) { + let proxy = WasmPrinterMessageProxy::new(input_events_tx); + let backend = WasmPrinterBackend::new(proxy); + let printer = WasmPrinter::new(callbacks); + (backend, printer) +} + +#[cfg(test)] +mod tests { + use super::*; + use ironrdp::rdpdr::pdu::efs::{ + CreateDisposition, CreateOptions, DesiredAccess, DeviceCloseRequest, DeviceCreateRequest, DeviceIoRequest, + DeviceWriteRequest, FileAttributes, MajorFunction, MinorFunction, SharedAccess, + }; + + const DEVICE_ID: u32 = 42; + + fn printer_backend() -> (WasmPrinterBackend, mpsc::UnboundedReceiver) { + printer_backend_with_limits(MAX_PRINT_JOB_BYTES, MAX_QUEUED_PRINT_DATA_BYTES) + } + + fn printer_backend_with_limits( + max_print_job_bytes: usize, + max_queued_print_job_bytes: usize, + ) -> (WasmPrinterBackend, mpsc::UnboundedReceiver) { + let (tx, rx) = mpsc::unbounded(); + let proxy = WasmPrinterMessageProxy::new_with_limit(tx, max_queued_print_job_bytes); + (WasmPrinterBackend::new_with_limit(proxy, max_print_job_bytes), rx) + } + + fn device_io_request(file_id: u32, completion_id: u32, major_function: MajorFunction) -> DeviceIoRequest { + DeviceIoRequest { + device_id: DEVICE_ID, + file_id, + completion_id, + major_function, + minor_function: MinorFunction::from(0), + } + } + + fn create_request(completion_id: u32) -> DeviceCreateRequest { + DeviceCreateRequest { + device_io_request: device_io_request(0, completion_id, MajorFunction::Create), + desired_access: DesiredAccess::empty(), + allocation_size: 0, + file_attributes: FileAttributes::empty(), + shared_access: SharedAccess::empty(), + create_disposition: CreateDisposition::FILE_OPEN, + create_options: CreateOptions::empty(), + path: String::new(), + } + } + + fn write_request(file_id: u32, completion_id: u32, write_data: Vec) -> DeviceWriteRequest { + DeviceWriteRequest { + device_io_request: device_io_request(file_id, completion_id, MajorFunction::Write), + offset: 0, + write_data, + } + } + + fn close_request(file_id: u32, completion_id: u32) -> DeviceCloseRequest { + DeviceCloseRequest { + device_io_request: device_io_request(file_id, completion_id, MajorFunction::Close), + } + } + + fn response_bytes(messages: Vec) -> Vec { + assert_eq!(messages.len(), 1); + messages[0].encode_unframed_pdu().unwrap() + } + + fn read_u32(bytes: &[u8]) -> u32 { + u32::from_le_bytes(bytes[..4].try_into().unwrap()) + } + + fn response_status(encoded: &[u8]) -> NtStatus { + NtStatus::from(read_u32(&encoded[12..])) + } + + fn expect_job_created(rx: &mut mpsc::UnboundedReceiver, expected_file_id: u32) { + match rx.try_recv().unwrap() { + RdpInputEvent::Printer(PrinterBackendMessage::Created { file_id }) => { + assert_eq!(file_id, expected_file_id); + } + other => panic!("unexpected event: {other:?}"), + } + } + + fn expect_job_data(rx: &mut mpsc::UnboundedReceiver, expected_file_id: u32, expected_data: &[u8]) { + match rx.try_recv().unwrap() { + RdpInputEvent::Printer(PrinterBackendMessage::Data { + file_id, + document_bytes, + _queued_bytes: _, + }) => { + assert_eq!(file_id, expected_file_id); + assert_eq!(document_bytes, expected_data); + } + other => panic!("unexpected event: {other:?}"), + } + } + + fn expect_job_completed(rx: &mut mpsc::UnboundedReceiver, expected_file_id: u32) { + match rx.try_recv().unwrap() { + RdpInputEvent::Printer(PrinterBackendMessage::Completed { file_id }) => { + assert_eq!(file_id, expected_file_id); + } + other => panic!("unexpected event: {other:?}"), + } + } + + fn expect_job_aborted(rx: &mut mpsc::UnboundedReceiver, expected_file_id: u32) { + match rx.try_recv().unwrap() { + RdpInputEvent::Printer(PrinterBackendMessage::Aborted { file_id }) => { + assert_eq!(file_id, expected_file_id); + } + other => panic!("unexpected event: {other:?}"), + } + } + + #[test] + fn create_write_close_streams_print_job_events() { + let (mut backend, mut rx) = printer_backend(); + + let create_response = response_bytes( + backend + .handle_printer_io_request(PrinterIoRequest::Create(create_request(1))) + .unwrap(), + ); + assert_eq!(response_status(&create_response), NtStatus::SUCCESS); + let file_id = read_u32(&create_response[16..]); + expect_job_created(&mut rx, file_id); + + let write_response = response_bytes( + backend + .handle_printer_io_request(PrinterIoRequest::Write(write_request(file_id, 2, b"hello".to_vec()))) + .unwrap(), + ); + assert_eq!(response_status(&write_response), NtStatus::SUCCESS); + assert_eq!(read_u32(&write_response[16..]), 5); + expect_job_data(&mut rx, file_id, b"hello"); + + let close_response = response_bytes( + backend + .handle_printer_io_request(PrinterIoRequest::Close(close_request(file_id, 3))) + .unwrap(), + ); + assert_eq!(response_status(&close_response), NtStatus::SUCCESS); + expect_job_completed(&mut rx, file_id); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn write_for_unknown_file_id_is_rejected() { + let (mut backend, _rx) = printer_backend(); + + let response = response_bytes( + backend + .handle_printer_io_request(PrinterIoRequest::Write(write_request(99, 1, b"lost".to_vec()))) + .unwrap(), + ); + + assert_eq!(response_status(&response), NtStatus::UNSUCCESSFUL); + assert_eq!(read_u32(&response[16..]), 0); + } + + #[test] + fn oversized_write_rejects_and_drops_partial_job() { + let (mut backend, mut rx) = printer_backend_with_limits(4, MAX_QUEUED_PRINT_DATA_BYTES); + + let create_response = response_bytes( + backend + .handle_printer_io_request(PrinterIoRequest::Create(create_request(1))) + .unwrap(), + ); + let file_id = read_u32(&create_response[16..]); + expect_job_created(&mut rx, file_id); + + let write_response = response_bytes( + backend + .handle_printer_io_request(PrinterIoRequest::Write(write_request( + file_id, + 2, + b"too large".to_vec(), + ))) + .unwrap(), + ); + assert_eq!(response_status(&write_response), NtStatus::UNSUCCESSFUL); + assert_eq!(read_u32(&write_response[16..]), 0); + expect_job_aborted(&mut rx, file_id); + + let close_response = response_bytes( + backend + .handle_printer_io_request(PrinterIoRequest::Close(close_request(file_id, 3))) + .unwrap(), + ); + assert_eq!(response_status(&close_response), NtStatus::UNSUCCESSFUL); + } + + #[test] + fn queued_print_data_budget_rejects_second_pending_chunk() { + let (mut backend, mut rx) = printer_backend_with_limits(MAX_PRINT_JOB_BYTES, 4); + + let first_create = response_bytes( + backend + .handle_printer_io_request(PrinterIoRequest::Create(create_request(1))) + .unwrap(), + ); + let first_file_id = read_u32(&first_create[16..]); + expect_job_created(&mut rx, first_file_id); + response_bytes( + backend + .handle_printer_io_request(PrinterIoRequest::Write(write_request( + first_file_id, + 2, + b"1234".to_vec(), + ))) + .unwrap(), + ); + + let second_create = response_bytes( + backend + .handle_printer_io_request(PrinterIoRequest::Create(create_request(4))) + .unwrap(), + ); + let second_file_id = read_u32(&second_create[16..]); + let second_write = response_bytes( + backend + .handle_printer_io_request(PrinterIoRequest::Write(write_request(second_file_id, 5, b"1".to_vec()))) + .unwrap(), + ); + assert_eq!(response_status(&second_write), NtStatus::UNSUCCESSFUL); + assert_eq!(read_u32(&second_write[16..]), 0); + + expect_job_data(&mut rx, first_file_id, b"1234"); + expect_job_created(&mut rx, second_file_id); + expect_job_aborted(&mut rx, second_file_id); + + let first_close = response_bytes( + backend + .handle_printer_io_request(PrinterIoRequest::Close(close_request(first_file_id, 3))) + .unwrap(), + ); + assert_eq!(response_status(&first_close), NtStatus::SUCCESS); + expect_job_completed(&mut rx, first_file_id); + + let second_close = response_bytes( + backend + .handle_printer_io_request(PrinterIoRequest::Close(close_request(second_file_id, 6))) + .unwrap(), + ); + assert_eq!(response_status(&second_close), NtStatus::UNSUCCESSFUL); + assert!(rx.try_recv().is_err()); + } +} diff --git a/crates/ironrdp-web/src/session.rs b/crates/ironrdp-web/src/session.rs index 92a50fc77f..b22e44482b 100644 --- a/crates/ironrdp-web/src/session.rs +++ b/crates/ironrdp-web/src/session.rs @@ -9,12 +9,14 @@ use anyhow::Context as _; use base64::Engine as _; use futures_channel::mpsc; use futures_util::io::{ReadHalf, WriteHalf}; -use futures_util::{select, AsyncWriteExt as _, FutureExt as _, StreamExt as _}; +use futures_util::{AsyncWriteExt as _, FutureExt as _, StreamExt as _, select}; use gloo_net::websocket; use gloo_net::websocket::futures::WebSocket; +use gloo_timers::future::IntervalStream; use iron_remote_desktop::{CursorStyle, DesktopSize, Extension, IronErrorKind}; -use ironrdp::cliprdr::backend::ClipboardMessage; use ironrdp::cliprdr::CliprdrClient; +use ironrdp::cliprdr::backend::ClipboardMessage; +use ironrdp::cliprdr::pdu::{FileContentsFlags, FileContentsRequest, FileContentsResponse, FileDescriptor}; use ironrdp::connector::connection_activation::ConnectionActivationState; use ironrdp::connector::credssp::KerberosConfig; use ironrdp::connector::{self, ClientConnector, Credentials}; @@ -24,24 +26,28 @@ use ironrdp::graphics::image_processing::PixelFormat; use ironrdp::pdu::input::fast_path::FastPathInputEvent; use ironrdp::pdu::rdp::capability_sets::client_codecs_capabilities; use ironrdp::pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; +use ironrdp::rdpdr::Rdpdr; +use ironrdp::rdpdr::pdu::efs::{DEFAULT_PRINTER_DRIVER_NAME, MICROSOFT_PRINT_TO_PDF_DRIVER_NAME}; +use ironrdp::rdpsnd::client::{NoopRdpsndBackend, Rdpsnd}; use ironrdp::session::image::DecodedImage; -use ironrdp::session::{fast_path, ActiveStage, ActiveStageOutput, GracefulDisconnectReason}; +use ironrdp::session::{ActiveStageBuilder, ActiveStageOutput, GracefulDisconnectReason, fast_path}; use ironrdp_core::WriteBuf; -use ironrdp_futures::{single_sequence_step_read, FramedWrite}; +use ironrdp_futures::{FramedWrite, single_sequence_step_read}; use rgb::AsPixels as _; use tap::prelude::*; use tracing::{debug, error, info, trace, warn}; -use wasm_bindgen::JsValue; +use wasm_bindgen::{JsCast as _, JsValue}; use wasm_bindgen_futures::spawn_local; use web_sys::HtmlCanvasElement; use crate::canvas::Canvas; use crate::clipboard; -use crate::clipboard::{ClipboardData, WasmClipboard, WasmClipboardBackend, WasmClipboardBackendMessage}; +use crate::clipboard::{ClipboardData, FileMetadata, WasmClipboard, WasmClipboardBackend, WasmClipboardBackendMessage}; use crate::error::IronError; use crate::image::extract_partial_image; use crate::input::InputTransaction; use crate::network_client::WasmNetworkClient; +use crate::printer::{JsPrinterStreamCallbacks, WasmPrinter, WasmPrinterBackend, wasm_printer_pair}; const DEFAULT_WIDTH: u16 = 1280; const DEFAULT_HEIGHT: u16 = 720; @@ -65,12 +71,26 @@ struct SessionBuilderInner { set_cursor_style_callback: Option, set_cursor_style_callback_context: Option, remote_clipboard_changed_callback: Option, - remote_received_format_list_callback: Option, force_clipboard_update_callback: Option, + // File transfer callbacks + files_available_callback: Option, + file_contents_request_callback: Option, + file_contents_response_callback: Option, + lock_callback: Option, + unlock_callback: Option, + locks_expired_callback: Option, + format_list_response_callback: Option, + + // Setting printer stream callbacks activates the virtual printer. + invalid_print_job_stream_callbacks: bool, + print_job_stream_callbacks: Option, + printer_name: Option, + printer_device_id: Option, + printer_driver_name: Option, use_display_control: bool, enable_credssp: bool, - outbound_message_size_limit: Option, + outbound_message_size_limit: Option, } impl Default for SessionBuilderInner { @@ -94,8 +114,20 @@ impl Default for SessionBuilderInner { set_cursor_style_callback: None, set_cursor_style_callback_context: None, remote_clipboard_changed_callback: None, - remote_received_format_list_callback: None, force_clipboard_update_callback: None, + files_available_callback: None, + file_contents_request_callback: None, + file_contents_response_callback: None, + lock_callback: None, + unlock_callback: None, + locks_expired_callback: None, + format_list_response_callback: None, + + invalid_print_job_stream_callbacks: false, + print_job_stream_callbacks: None, + printer_name: None, + printer_device_id: None, + printer_driver_name: None, use_display_control: false, enable_credssp: true, @@ -198,12 +230,6 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { self.clone() } - /// Optional - fn remote_received_format_list_callback(&self, callback: js_sys::Function) -> Self { - self.0.borrow_mut().remote_received_format_list_callback = Some(callback); - self.clone() - } - /// Optional fn force_clipboard_update_callback(&self, callback: js_sys::Function) -> Self { self.0.borrow_mut().force_clipboard_update_callback = Some(callback); @@ -224,14 +250,74 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { |enable_credssp: bool| { self.0.borrow_mut().enable_credssp = enable_credssp }; |outbound_message_size_limit: f64| { let limit = if outbound_message_size_limit >= 0.0 && outbound_message_size_limit <= f64::from(u32::MAX) { - #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - { outbound_message_size_limit as u32 } + #[expect(clippy::as_conversions, clippy::cast_possible_truncation, clippy::cast_sign_loss)] + { outbound_message_size_limit as usize } } else { warn!(outbound_message_size_limit, "Invalid outbound message size limit; fallback to unlimited"); 0 // Fallback to no limit for invalid values. }; self.0.borrow_mut().outbound_message_size_limit = if limit > 0 { Some(limit) } else { None }; }; + // File transfer callbacks - protocol-specific, routed through extension() + // rather than dedicated trait methods to keep iron-remote-desktop protocol-agnostic. + |files_available_callback: JsValue| { + self.0.borrow_mut().files_available_callback = files_available_callback.dyn_into::().ok(); + }; + |file_contents_request_callback: JsValue| { + self.0.borrow_mut().file_contents_request_callback = file_contents_request_callback.dyn_into::().ok(); + }; + |file_contents_response_callback: JsValue| { + self.0.borrow_mut().file_contents_response_callback = file_contents_response_callback.dyn_into::().ok(); + }; + |lock_callback: JsValue| { + self.0.borrow_mut().lock_callback = lock_callback.dyn_into::().ok(); + }; + |unlock_callback: JsValue| { + self.0.borrow_mut().unlock_callback = unlock_callback.dyn_into::().ok(); + }; + |locks_expired_callback: JsValue| { + self.0.borrow_mut().locks_expired_callback = locks_expired_callback.dyn_into::().ok(); + }; + |format_list_response_callback: JsValue| { + self.0.borrow_mut().format_list_response_callback = format_list_response_callback.dyn_into::().ok(); + }; + |print_job_stream_callbacks: JsValue| { + let mut inner = self.0.borrow_mut(); + match parse_print_job_stream_callbacks(print_job_stream_callbacks) { + Ok(callbacks) => { + inner.invalid_print_job_stream_callbacks = false; + inner.print_job_stream_callbacks = Some(callbacks); + } + Err(error) => { + inner.invalid_print_job_stream_callbacks = true; + inner.print_job_stream_callbacks = None; + warn!(%error, "Invalid print_job_stream_callbacks; printer streaming requires onJobData and onJobComplete functions"); + } + } + }; + |printer_name: String| { + let mut inner = self.0.borrow_mut(); + inner.printer_name = if printer_name.is_empty() { None } else { Some(printer_name) }; + }; + |printer_device_id: f64| { + let id = if printer_device_id >= 0.0 && printer_device_id <= f64::from(u32::MAX) { + #[expect(clippy::as_conversions, clippy::cast_possible_truncation, clippy::cast_sign_loss)] + { printer_device_id as u32 } + } else { + warn!(printer_device_id, "Invalid printer_device_id; falling back to default"); + 0 + }; + let mut inner = self.0.borrow_mut(); + inner.printer_device_id = if id > 0 { Some(id) } else { None }; + }; + |printer_driver_name: String| { + let mut inner = self.0.borrow_mut(); + inner.printer_driver_name = if printer_driver_name.is_empty() { + None + } else { + Some(printer_driver_name) + }; + }; } self.clone() @@ -253,8 +339,19 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { set_cursor_style_callback, set_cursor_style_callback_context, remote_clipboard_changed_callback, - remote_received_format_list_callback, force_clipboard_update_callback, + files_available_callback, + file_contents_request_callback, + file_contents_response_callback, + lock_callback, + unlock_callback, + locks_expired_callback, + format_list_response_callback, + invalid_print_job_stream_callbacks, + print_job_stream_callbacks, + printer_name, + printer_device_id, + printer_driver_name, outbound_message_size_limit, ); @@ -283,14 +380,25 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { .clone() .context("set_cursor_style_callback_context missing")?; remote_clipboard_changed_callback = inner.remote_clipboard_changed_callback.clone(); - remote_received_format_list_callback = inner.remote_received_format_list_callback.clone(); force_clipboard_update_callback = inner.force_clipboard_update_callback.clone(); + files_available_callback = inner.files_available_callback.clone(); + file_contents_request_callback = inner.file_contents_request_callback.clone(); + file_contents_response_callback = inner.file_contents_response_callback.clone(); + lock_callback = inner.lock_callback.clone(); + unlock_callback = inner.unlock_callback.clone(); + locks_expired_callback = inner.locks_expired_callback.clone(); + format_list_response_callback = inner.format_list_response_callback.clone(); + invalid_print_job_stream_callbacks = inner.invalid_print_job_stream_callbacks; + print_job_stream_callbacks = inner.print_job_stream_callbacks.clone(); + printer_name = inner.printer_name.clone(); + printer_device_id = inner.printer_device_id; + printer_driver_name = inner.printer_driver_name.clone(); outbound_message_size_limit = inner.outbound_message_size_limit; } info!("Connect to RDP host"); - let mut config = build_config(username, password, server_domain, client_name, desktop_size); + let mut config = build_config(username, password, server_domain, client_name.clone(), desktop_size); let enable_credssp = self.0.borrow().enable_credssp; config.enable_credssp = enable_credssp; @@ -302,12 +410,42 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { clipboard::WasmClipboardMessageProxy::new(input_events_tx.clone()), clipboard::JsClipboardCallbacks { on_remote_clipboard_changed: callback, - on_remote_received_format_list: remote_received_format_list_callback, on_force_clipboard_update: force_clipboard_update_callback, + on_files_available: files_available_callback, + on_file_contents_request: file_contents_request_callback, + on_file_contents_response: file_contents_response_callback, + on_lock: lock_callback, + on_unlock: unlock_callback, + on_locks_expired: locks_expired_callback, + on_format_list_response: format_list_response_callback, }, ) }); + if invalid_print_job_stream_callbacks { + return Err(IronError::from(anyhow::anyhow!( + "printer redirection requires valid print_job_stream_callbacks" + ))); + } + + // Build the virtual-printer pair when JS printer callbacks were + // registered via extension(). Backend is Send (holds the mpsc proxy + // only) and goes into the SVC processor below; the front-end + // `WasmPrinter` owns the JS callbacks and lives on `Session`. + let (printer_backend, printer) = match print_job_stream_callbacks { + Some(callbacks) => { + let (backend, printer) = wasm_printer_pair(input_events_tx.clone(), callbacks); + (Some(backend), Some(printer)) + } + None => (None, None), + }; + + // Default to 2 to avoid a potential collision if drive redirection is + // enabled in the same session. + let printer_device_id = printer_device_id.unwrap_or(2); + let printer_name = printer_name.unwrap_or_else(|| "IronRDP Virtual Printer".to_owned()); + let printer_driver_name = printer_driver_name.unwrap_or_else(default_printer_driver_name); + let ws = WebSocket::open(&proxy_address).context("couldn't open WebSocket")?; // NOTE: ideally, when the WebSocket can't be opened, the above call should fail with details on why is that @@ -344,6 +482,11 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { pcb, kdc_proxy_url, clipboard_backend: clipboard.as_ref().map(|clip| clip.backend()), + printer_backend, + printer_device_id, + printer_name, + printer_driver_name, + computer_name: client_name.clone(), use_display_control, }) .await?; @@ -370,6 +513,7 @@ impl iron_remote_desktop::SessionBuilder for SessionBuilder { rdp_reader: RefCell::new(Some(rdp_reader)), connection_result: RefCell::new(Some(connection_result)), clipboard: RefCell::new(Some(clipboard)), + printer: RefCell::new(Some(printer)), }) } } @@ -380,6 +524,9 @@ pub(crate) type FastPathInputEvents = smallvec::SmallVec<[FastPathInputEvent; 2] pub(crate) enum RdpInputEvent { Cliprdr(ClipboardMessage), ClipboardBackend(WasmClipboardBackendMessage), + /// Printer backend → event loop: a print job finished and its bytes are + /// ready for delivery to JS. See [`crate::printer::PrinterBackendMessage`]. + Printer(crate::printer::PrinterBackendMessage), FastPath(FastPathInputEvents), Resize { width: u32, @@ -415,6 +562,7 @@ pub(crate) struct Session { connection_result: RefCell>, rdp_reader: RefCell>>, clipboard: RefCell>>, + printer: RefCell>>, } impl Session { @@ -483,17 +631,19 @@ impl iron_remote_desktop::Session for Session { .expect("run called only once"); let mut clipboard = self.clipboard.borrow_mut().take().expect("run called only once"); + let mut wasm_printer = self.printer.borrow_mut().take().expect("run called only once"); let mut framed = ironrdp_futures::LocalFuturesFramed::new(rdp_reader); debug!("Initialize canvas"); - let mut gui = Canvas::new( - self.render_canvas.clone(), - u32::from(connection_result.desktop_size.width), - u32::from(connection_result.desktop_size.height), - ) - .context("canvas initialization")?; + let desktop_width = + NonZeroU32::new(u32::from(connection_result.desktop_size.width)).context("desktop width is zero")?; + let desktop_height = + NonZeroU32::new(u32::from(connection_result.desktop_size.height)).context("desktop height is zero")?; + + let mut gui = + Canvas::new(self.render_canvas.clone(), desktop_width, desktop_height).context("canvas initialization")?; debug!("Canvas initialized"); @@ -507,7 +657,26 @@ impl iron_remote_desktop::Session for Session { let mut requested_resize = None; - let mut active_stage = ActiveStage::new(connection_result); + // Reused across frames so per-region extraction doesn't allocate on every draw. + let mut draw_buffer = WriteBuf::new(); + + // We retain the factory to drive the Deactivation-Reactivation Sequence locally. + let activation_factory = connection_result.activation_factory; + + let mut active_stage = ActiveStageBuilder { + static_channels: connection_result.static_channels, + user_channel_id: connection_result.user_channel_id, + io_channel_id: connection_result.io_channel_id, + message_channel_id: connection_result.message_channel_id, + share_id: connection_result.share_id, + compression_type: connection_result.compression_type, + enable_server_pointer: connection_result.enable_server_pointer, + pointer_software_rendering: connection_result.pointer_software_rendering, + } + .build(); + + // Timer interval for driving clipboard lock timeouts (5 second interval) + let mut cleanup_interval = IntervalStream::new(5_000).fuse(); let disconnect_reason = 'outer: loop { let outputs = select! { @@ -522,22 +691,34 @@ impl iron_remote_desktop::Session for Session { match event { RdpInputEvent::Cliprdr(message) => { - if let Some(cliprdr) = active_stage.get_svc_processor::() { + if let Some(cliprdr) = active_stage.get_svc_processor_mut::() { if let Some(svc_messages) = match message { ClipboardMessage::SendInitiateCopy(formats) => Some( cliprdr.initiate_copy(&formats) - .context("CLIPRDR initiate copy")? + .context("cliprdr initiate copy")? + ), + ClipboardMessage::SendInitiateFileCopy(files) => Some( + cliprdr.initiate_file_copy(files) + .context("cliprdr initiate file copy")? ), ClipboardMessage::SendFormatData(response) => Some( cliprdr.submit_format_data(response) - .context("CLIPRDR submit format data")? + .context("cliprdr submit format data")? ), ClipboardMessage::SendInitiatePaste(format) => Some( cliprdr.initiate_paste(format) - .context("CLIPRDR initiate paste")? + .context("cliprdr initiate paste")? + ), + ClipboardMessage::SendFileContentsRequest(request) => Some( + cliprdr.request_file_contents(request) + .context("cliprdr request file contents")? + ), + ClipboardMessage::SendFileContentsResponse(response) => Some( + cliprdr.submit_file_contents(response) + .context("cliprdr submit file contents")? ), ClipboardMessage::Error(e) => { - error!("Clipboard backend error: {}", e); + error!(error = %e, "Clipboard backend error"); None } } { @@ -554,11 +735,96 @@ impl iron_remote_desktop::Session for Session { } } RdpInputEvent::ClipboardBackend(event) => { - if let Some(clipboard) = &mut clipboard { - clipboard.process_event(event)?; + use crate::clipboard::WasmClipboardBackendMessage; + + // Handle messages that need direct cliprdr access + match event { + WasmClipboardBackendMessage::FileContentsRequestSend { stream_id, index, flags, position, size, clip_data_id } => { + if let Some(cliprdr) = active_stage.get_svc_processor_mut::() { + let request = FileContentsRequest { + stream_id, + index, + flags, + position, + requested_size: size, + data_id: clip_data_id, + }; + match cliprdr.request_file_contents(request) { + Ok(svc_messages) => { + let frame = active_stage.process_svc_processor_messages(svc_messages)?; + vec![ActiveStageOutput::ResponseFrame(frame)] + } + Err(e) => { + error!(error = %e, "File contents request failed"); + Vec::new() + } + } + } else { + warn!("Request file contents received, but Cliprdr is not available"); + Vec::new() + } + } + WasmClipboardBackendMessage::FileContentsResponseSend { stream_id, is_error, data } => { + if let Some(cliprdr) = active_stage.get_svc_processor_mut::() { + let response = if is_error { + FileContentsResponse::new_error(stream_id) + } else { + FileContentsResponse::new_data_response(stream_id, data) + }; + match cliprdr.submit_file_contents(response) { + Ok(svc_messages) => { + let frame = active_stage.process_svc_processor_messages(svc_messages)?; + vec![ActiveStageOutput::ResponseFrame(frame)] + } + Err(e) => { + error!(error = %e, "File contents submit failed"); + Vec::new() + } + } + } else { + warn!("Submit file contents received, but Cliprdr is not available"); + Vec::new() + } + } + WasmClipboardBackendMessage::InitiateFileCopy { files } => { + if let Some(cliprdr) = active_stage.get_svc_processor_mut::() { + // Convert FileMetadata to FileDescriptor using the + // validated conversion that checks name length/emptiness + // and sets proper file attributes. + let file_descriptors: Vec = files + .into_iter() + .filter_map(|f| match f.to_file_descriptor() { + Ok(desc) => Some(desc), + Err(e) => { + warn!(error = format!("{e:#}"), "Skipping file with invalid metadata"); + None + } + }) + .collect(); + + match cliprdr.initiate_file_copy(file_descriptors) { + Ok(svc_messages) => { + let frame = active_stage.process_svc_processor_messages(svc_messages)?; + vec![ActiveStageOutput::ResponseFrame(frame)] + } + Err(e) => { + error!(error = %e, "Initiate file copy failed"); + Vec::new() + } + } + } else { + warn!("Initiate file copy received, but Cliprdr is not available"); + Vec::new() + } + } + // All other messages are forwarded to clipboard backend + other => { + if let Some(clipboard) = &mut clipboard { + clipboard.process_event(other)?; + } + Vec::new() + } } - // No RDP output frames for backend event processing - Vec::new() } RdpInputEvent::FastPath(events) => { active_stage.process_fastpath_input(&mut image, &events) @@ -570,19 +836,55 @@ impl iron_remote_desktop::Session for Session { warn!("Resize event ignored: width or height is zero"); Vec::new() } else if let Some(response_frame) = active_stage.encode_resize(width, height, scale_factor, physical_size) { - requested_resize = Some((NonZeroU32::new(width).unwrap(), NonZeroU32::new(height).unwrap())); + let width = NonZeroU32::new(width).expect("width is guaranteed to be non-zero due to the prior check"); + let height = NonZeroU32::new(height).expect("height is guaranteed to be non-zero due to the prior check"); + + requested_resize = Some((width, height)); vec![ActiveStageOutput::ResponseFrame(response_frame?)] } else { debug!("Resize event ignored"); Vec::new() } }, + RdpInputEvent::Printer(message) => { + // The printer backend lives inside the Rdpdr SVC + // processor (Send-only); the front-end + // `WasmPrinter` owns the JS callback (!Send) and + // lives here. Just forward the message. + if let Some(ref mut wasm_printer) = wasm_printer { + wasm_printer.process_message(message); + } else { + warn!("Printer event received, but no printer is configured"); + } + Vec::new() + } RdpInputEvent::TerminateSession => { active_stage.graceful_shutdown() .context("graceful shutdown")? } } } + _ = cleanup_interval.next() => { + // Drive clipboard lock timeout cleanup + if let Some(cliprdr) = active_stage.get_svc_processor_mut::() { + match cliprdr.drive_timeouts() { + Ok(svc_messages) => { + let frame = active_stage.process_svc_processor_messages(svc_messages)?; + if !frame.is_empty() { + vec![ActiveStageOutput::ResponseFrame(frame)] + } else { + Vec::new() + } + } + Err(e) => { + warn!(error = %e, "Clipboard timeout cleanup failed"); + Vec::new() + } + } + } else { + Vec::new() + } + } }; for out in outputs { @@ -593,9 +895,10 @@ impl iron_remote_desktop::Session for Session { .context("Send frame to writer task")?; } ActiveStageOutput::GraphicsUpdate(region) => { - // PERF: some copies and conversion could be optimized - let (region, buffer) = extract_partial_image(&image, region); - gui.draw(&buffer, region).context("draw updated region")?; + let region = extract_partial_image(&image, region, &mut draw_buffer); + gui.draw(draw_buffer.filled_mut(), region) + .context("draw updated region")?; + draw_buffer.clear(); } ActiveStageOutput::PointerDefault => { self.set_cursor_style(CursorStyle::Default)?; @@ -683,7 +986,7 @@ impl iron_remote_desktop::Session for Session { encoder.set_compression(png::Compression::Fast); let mut writer = encoder.write_header().context("PNG encoder header write failed")?; writer - .write_image_data(rgba_buffer.as_ref()) + .write_image_data(&rgba_buffer) .context("failed to encode pointer PNG")?; } @@ -697,7 +1000,7 @@ impl iron_remote_desktop::Session for Session { hotspot_y, })?; } - ActiveStageOutput::DeactivateAll(mut box_connection_activation) => { + ActiveStageOutput::DeactivateAll => { // Execute the Deactivation-Reactivation Sequence: // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 debug!("Received Server Deactivate All PDU, executing Deactivation-Reactivation Sequence"); @@ -705,17 +1008,15 @@ impl iron_remote_desktop::Session for Session { // We need to perform resize after receiving the Deactivate All PDU, because there may be frames // with the previous dimensions arriving between the resize request and this message. if let Some((width, height)) = requested_resize { - self.render_canvas.set_width(width.get()); - self.render_canvas.set_height(height.get()); gui.resize(width, height); requested_resize = None; } + let mut connection_activation = activation_factory.create(); let mut buf = WriteBuf::new(); 'activation_seq: loop { let written = - single_sequence_step_read(&mut framed, &mut *box_connection_activation, &mut buf) - .await?; + single_sequence_step_read(&mut framed, &mut connection_activation, &mut buf).await?; if written.size().is_some() { self.writer_tx @@ -724,12 +1025,11 @@ impl iron_remote_desktop::Session for Session { } if let ConnectionActivationState::Finalized { - io_channel_id, - user_channel_id, desktop_size, + share_id, enable_server_pointer, pointer_software_rendering, - } = box_connection_activation.state + } = connection_activation.connection_activation_state() { debug!("Deactivation-Reactivation Sequence completed"); image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height); @@ -737,18 +1037,31 @@ impl iron_remote_desktop::Session for Session { // io/user channel ids. active_stage.set_fastpath_processor( fast_path::ProcessorBuilder { - io_channel_id, - user_channel_id, + io_channel_id: connection_activation.io_channel_id(), + user_channel_id: connection_activation.user_channel_id(), + share_id, enable_server_pointer, pointer_software_rendering, + bulk_decompressor: None, } .build(), ); + active_stage.set_share_id(share_id); active_stage.set_enable_server_pointer(enable_server_pointer); break 'activation_seq; } } } + ActiveStageOutput::MultitransportRequest(pdu) => { + debug!( + request_id = pdu.request_id, + requested_protocol = ?pdu.requested_protocol, + "Multitransport request received (UDP transport not implemented)" + ); + } + ActiveStageOutput::AutoDetect(request) => { + debug!(?request, "Auto-detect"); + } ActiveStageOutput::Terminate(reason) => break 'outer reason, } } @@ -788,7 +1101,7 @@ impl iron_remote_desktop::Session for Session { use ironrdp::pdu::input::fast_path::FastPathInput; let event = ironrdp::input::synchronize_event(scroll_lock, num_lock, caps_lock, kana_lock); - let fastpath_input = FastPathInput(vec![event]); + let fastpath_input = FastPathInput::single(event); let frame = ironrdp::core::encode_vec(&fastpath_input).context("FastPathInput encoding")?; @@ -807,12 +1120,12 @@ impl iron_remote_desktop::Session for Session { Ok(()) } - async fn on_clipboard_paste(&self, content: Self::ClipboardData) -> Result<(), Self::Error> { + async fn on_clipboard_paste(&self, content: &Self::ClipboardData) -> Result<(), Self::Error> { self.input_events_tx .unbounded_send(RdpInputEvent::ClipboardBackend( - WasmClipboardBackendMessage::LocalClipboardChanged(content), + WasmClipboardBackendMessage::LocalClipboardChanged(content.clone()), )) - .context("Send clipboard backend event")?; + .context("send clipboard backend event")?; Ok(()) } @@ -825,14 +1138,18 @@ impl iron_remote_desktop::Session for Session { physical_width: Option, physical_height: Option, ) { - self.input_events_tx + if self + .input_events_tx .unbounded_send(RdpInputEvent::Resize { width, height, scale_factor, physical_size: physical_width.and_then(|width| physical_height.map(|height| (width, height))), }) - .expect("send resize event to writer task"); + .is_err() + { + warn!("Failed to send resize event, receiver is closed"); + } } fn supports_unicode_keyboard_shortcuts(&self) -> bool { @@ -842,6 +1159,71 @@ impl iron_remote_desktop::Session for Session { } fn invoke_extension(&self, ext: Extension) -> Result { + // File transfer operations are protocol-specific (RDPECLIP) and routed + // through invoke_extension rather than dedicated Session trait methods + // to keep the iron-remote-desktop trait surface protocol-agnostic. + iron_remote_desktop::extension_match! { + match ext; + |request_file_contents: JsValue| { + let obj = into_object(request_file_contents)?; + let stream_id = get_u32(&obj, "stream_id")?; + let file_index = get_i32(&obj, "file_index")?; + let flags = get_u32(&obj, "flags")?; + let position = get_u64(&obj, "position")?; + let size = get_u32(&obj, "size")?; + let clip_data_id = get_u32_opt(&obj, "clip_data_id")?; + + self.input_events_tx + .unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::FileContentsRequestSend { + stream_id, + index: file_index, + flags: FileContentsFlags::from_bits_truncate(flags), + position, + size, + clip_data_id, + }, + )) + .context("send file contents request") + .map_err(IronError::from)?; + + return Ok(JsValue::NULL); + }; + |submit_file_contents: JsValue| { + let obj = into_object(submit_file_contents)?; + let stream_id = get_u32(&obj, "stream_id")?; + let is_error = get_bool(&obj, "is_error")?; + let data_val = js_sys::Reflect::get(&obj, &JsValue::from_str("data")) + .map_err(|e| IronError::from(anyhow::anyhow!("get property `data`: {e:?}")))?; + let data = js_sys::Uint8Array::new(&data_val).to_vec(); + + self.input_events_tx + .unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::FileContentsResponseSend { + stream_id, + is_error, + data, + }, + )) + .context("send file contents response") + .map_err(IronError::from)?; + + return Ok(JsValue::NULL); + }; + |initiate_file_copy: JsValue| { + let file_list = parse_file_metadata_array(initiate_file_copy)?; + + self.input_events_tx + .unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::InitiateFileCopy { files: file_list }, + )) + .context("send initiate file copy") + .map_err(IronError::from)?; + + return Ok(JsValue::NULL); + }; + } + Err( IronError::from(anyhow::Error::msg(format!("unknown extension: {}", ext.ident()))) .with_kind(IronErrorKind::General), @@ -849,6 +1231,178 @@ impl iron_remote_desktop::Session for Session { } } +fn into_object(val: JsValue) -> Result { + val.dyn_into::() + .map_err(|_| anyhow::anyhow!("expected object").into()) +} + +fn get_u32(obj: &js_sys::Object, key: &str) -> Result { + let val = js_sys::Reflect::get(obj, &JsValue::from_str(key)) + .map_err(|e| anyhow::anyhow!("get property `{key}`: {e:?}"))?; + let f = val + .as_f64() + .with_context(|| format!("invalid type for property `{key}`"))?; + Ok(f64_to_u32_saturating_cast(f)) +} + +fn get_i32(obj: &js_sys::Object, key: &str) -> Result { + let val = js_sys::Reflect::get(obj, &JsValue::from_str(key)) + .map_err(|e| anyhow::anyhow!("get property `{key}`: {e:?}"))?; + let f = val + .as_f64() + .with_context(|| format!("invalid type for property `{key}`"))?; + Ok(f64_to_i32_saturating_cast(f)) +} + +fn get_u64(obj: &js_sys::Object, key: &str) -> Result { + let val = js_sys::Reflect::get(obj, &JsValue::from_str(key)) + .map_err(|e| anyhow::anyhow!("get property `{key}`: {e:?}"))?; + let f = val + .as_f64() + .with_context(|| format!("invalid type for property `{key}`"))?; + // Validate integer precision before casting + const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; + if !f.is_finite() || f < 0.0 || f.fract() != 0.0 || f > MAX_SAFE_INTEGER { + return Err(anyhow::anyhow!( + "property `{key}` must be a finite non-negative integer <= Number.MAX_SAFE_INTEGER (got: {f})" + ) + .into()); + } + #[expect(clippy::as_conversions, clippy::cast_possible_truncation, clippy::cast_sign_loss)] + Ok(f as u64) +} + +fn get_bool(obj: &js_sys::Object, key: &str) -> Result { + let val = js_sys::Reflect::get(obj, &JsValue::from_str(key)) + .map_err(|e| anyhow::anyhow!("get property `{key}`: {e:?}"))?; + val.as_bool() + .with_context(|| format!("invalid type for property `{key}`")) + .map_err(Into::into) +} + +fn get_u32_opt(obj: &js_sys::Object, key: &str) -> Result, IronError> { + let val = js_sys::Reflect::get(obj, &JsValue::from_str(key)) + .map_err(|e| anyhow::anyhow!("get property `{key}`: {e:?}"))?; + if val.is_undefined() || val.is_null() { + return Ok(None); + } + let f = val + .as_f64() + .with_context(|| format!("invalid type for property `{key}`"))?; + Ok(Some(f64_to_u32_saturating_cast(f))) +} + +fn parse_print_job_stream_callbacks(callbacks: JsValue) -> anyhow::Result { + let callbacks = callbacks + .dyn_into::() + .map_err(|_| anyhow::anyhow!("expected object"))?; + + Ok(JsPrinterStreamCallbacks { + on_job_start: get_optional_function(&callbacks, "onJobStart")?, + on_job_data: get_required_function(&callbacks, "onJobData")?, + on_job_complete: get_required_function(&callbacks, "onJobComplete")?, + on_job_error: get_optional_function(&callbacks, "onJobError")?, + }) +} + +fn get_required_function(obj: &js_sys::Object, key: &str) -> anyhow::Result { + get_optional_function(obj, key)?.with_context(|| format!("missing function `{key}`")) +} + +fn get_optional_function(obj: &js_sys::Object, key: &str) -> anyhow::Result> { + let val = js_sys::Reflect::get(obj, &JsValue::from_str(key)) + .map_err(|e| anyhow::anyhow!("get property `{key}`: {e:?}"))?; + + if val.is_undefined() || val.is_null() { + return Ok(None); + } + + val.dyn_into::() + .map(Some) + .map_err(|_| anyhow::anyhow!("property `{key}` must be a function")) +} + +#[expect(clippy::as_conversions, clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn f64_to_u32_saturating_cast(f: f64) -> u32 { + f.clamp(0.0, f64::from(u32::MAX)) as u32 +} + +#[expect(clippy::as_conversions, clippy::cast_possible_truncation)] +fn f64_to_i32_saturating_cast(f: f64) -> i32 { + f.clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32 +} + +/// Parse a JsValue (expected to be a JS array of file metadata objects) +/// into a `Vec`. +fn parse_file_metadata_array(files: JsValue) -> Result, IronError> { + let js_array = js_sys::Array::from(&files); + #[expect( + clippy::as_conversions, + reason = "JavaScript array length is u32, safe to convert to usize" + )] + let mut file_list = Vec::with_capacity(js_array.length() as usize); + + for i in 0..js_array.length() { + let file_obj = js_array.get(i); + let name = js_sys::Reflect::get(&file_obj, &JsValue::from_str("name")) + .ok() + .and_then(|v| v.as_string()) + .context("file name is required")?; + let size_f64 = js_sys::Reflect::get(&file_obj, &JsValue::from_str("size")) + .ok() + .and_then(|v| v.as_f64()) + .context("file size is required")?; + // JS numbers are f64; reject fractional or out-of-safe-integer-range values + // to avoid silent truncation when casting to u64 + const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; + if !size_f64.is_finite() || size_f64 < 0.0 || size_f64.fract() != 0.0 || size_f64 > MAX_SAFE_INTEGER { + return Err(anyhow::anyhow!( + "file size must be a finite non-negative integer <= Number.MAX_SAFE_INTEGER (got: {size_f64})" + ) + .into()); + } + #[expect(clippy::as_conversions, clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let size = size_f64 as u64; + + let last_modified_f64 = js_sys::Reflect::get(&file_obj, &JsValue::from_str("lastModified")) + .ok() + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + // Store as JS timestamp (ms since Unix epoch). FileMetadata::to_file_descriptor() + // handles the conversion to Windows FILETIME for the wire format. + const MAX_SAFE_TS: f64 = 9_007_199_254_740_991.0; + #[expect(clippy::as_conversions, clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let last_modified = if last_modified_f64.is_finite() + && (0.0..=MAX_SAFE_TS).contains(&last_modified_f64) + && last_modified_f64.fract() == 0.0 + { + last_modified_f64 as u64 + } else { + 0 + }; + + let path = js_sys::Reflect::get(&file_obj, &JsValue::from_str("path")) + .ok() + .and_then(|v| v.as_string()) + .filter(|s| !s.is_empty()); + + let is_directory = js_sys::Reflect::get(&file_obj, &JsValue::from_str("isDirectory")) + .ok() + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + file_list.push(FileMetadata { + name, + path, + size, + last_modified, + is_directory, + }); + } + + Ok(file_list) +} + fn build_config( username: String, password: String, @@ -875,49 +1429,55 @@ fn build_config( bitmap: Some(connector::BitmapConfig { color_depth: 16, lossy_compression: true, - codecs: client_codecs_capabilities(&[]).unwrap(), + codecs: client_codecs_capabilities(&[]).expect("can't panic for &[]"), }), - #[expect(clippy::arithmetic_side_effects)] // fine unless we end up with an insanely big version + #[expect( + clippy::arithmetic_side_effects, + reason = "fine unless we end up with an insanely big version" + )] client_build: semver::Version::parse(env!("CARGO_PKG_VERSION")) - .map(|version| version.major * 100 + version.minor * 10 + version.patch) - .unwrap_or(0) + .map_or(0, |version| version.major * 100 + version.minor * 10 + version.patch) .pipe(u32::try_from) - .unwrap(), + .expect("fine until major ~42949672"), client_name, // NOTE: hardcode this value like in freerdp // https://github.com/FreeRDP/FreeRDP/blob/4e24b966c86fdf494a782f0dfcfc43a057a2ea60/libfreerdp/core/settings.c#LL49C34-L49C70 client_dir: "C:\\Windows\\System32\\mstscax.dll".to_owned(), platform: ironrdp::pdu::rdp::capability_sets::MajorPlatformType::UNSPECIFIED, + compression_type: None, enable_server_pointer: false, autologon: false, enable_audio_playback: false, request_data: None, pointer_software_rendering: false, + multitransport_flags: None, performance_flags: PerformanceFlags::default(), desktop_scale_factor: 0, hardware_id: None, license_cache: None, timezone_info: TimezoneInfo::default(), + alternate_shell: String::new(), + work_dir: String::new(), } } async fn writer_task( rx: mpsc::UnboundedReceiver>, rdp_writer: WriteHalf, - outbound_limit: Option, + outbound_limit: Option, ) { debug!("writer task started"); async fn inner( mut rx: mpsc::UnboundedReceiver>, mut rdp_writer: WriteHalf, - outbound_limit: Option, + outbound_limit: Option, ) -> anyhow::Result<()> { while let Some(frame) = rx.next().await { match outbound_limit { - Some(max_size) if frame.len() > max_size as usize => { + Some(max_size) if frame.len() > max_size => { // Send in chunks. - for chunk in frame.chunks(max_size as usize) { + for chunk in frame.chunks(max_size) { rdp_writer.write_all(chunk).await.context("couldn't write chunk")?; rdp_writer.flush().await.context("couldn't flush chunk")?; } @@ -947,9 +1507,48 @@ struct ConnectParams { pcb: Option, kdc_proxy_url: Option, clipboard_backend: Option, + printer_backend: Option, + printer_device_id: u32, + printer_name: String, + printer_driver_name: String, + /// Matches the `client_name` in the connector config; used as the + /// `computer_name` when constructing the `Rdpdr` processor. + computer_name: String, use_display_control: bool, } +fn default_printer_driver_name() -> String { + printer_driver_name_for_macos_major_version(browser_macos_major_version()).to_owned() +} + +fn printer_driver_name_for_macos_major_version(macos_major_version: Option) -> &'static str { + if macos_major_version.is_some_and(|major| 14 <= major) { + MICROSOFT_PRINT_TO_PDF_DRIVER_NAME + } else { + DEFAULT_PRINTER_DRIVER_NAME + } +} + +#[cfg(target_arch = "wasm32")] +fn browser_macos_major_version() -> Option { + let user_agent = web_sys::window()?.navigator().user_agent().ok()?; + macos_major_version_from_user_agent(&user_agent) +} + +#[cfg(not(target_arch = "wasm32"))] +fn browser_macos_major_version() -> Option { + None +} + +#[cfg(any(target_arch = "wasm32", test))] +fn macos_major_version_from_user_agent(user_agent: &str) -> Option { + let (_, version) = user_agent.split_once("Mac OS X ")?; + version + .split(|ch: char| !ch.is_ascii_digit()) + .next() + .and_then(|major| major.parse().ok()) +} + async fn connect( ConnectParams { ws, @@ -959,6 +1558,11 @@ async fn connect( pcb, kdc_proxy_url, clipboard_backend, + printer_backend, + printer_device_id, + printer_name, + printer_driver_name, + computer_name, use_display_control, }: ConnectParams, ) -> Result<(connector::ConnectionResult, WebSocket), IronError> { @@ -973,6 +1577,20 @@ async fn connect( connector.attach_static_channel(CliprdrClient::new(Box::new(clipboard_backend))); } + if let Some(printer_backend) = printer_backend { + // Windows servers only speak on RDPDR when RDPSND is advertised too + // (MS-RDPEFS Appendix A<1>). We do not play audio in the web client, + // but the no-op RDPSND processor satisfies that channel dependency. + connector.attach_static_channel(Rdpsnd::new(Box::new(NoopRdpsndBackend))); + connector.attach_static_channel( + Rdpdr::new(Box::new(printer_backend), computer_name).with_printer_driver( + printer_device_id, + printer_name, + printer_driver_name, + ), + ); + } + if use_display_control { connector.attach_static_channel( DrdynvcClient::new().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new()))), @@ -984,18 +1602,18 @@ async fn connect( let connection_result = ironrdp_futures::connect_finalize( upgraded, - &mut framed, connector, + &mut framed, + &mut WasmNetworkClient, (&destination).into(), server_public_key, - Some(&mut WasmNetworkClient), url::Url::parse(kdc_proxy_url.unwrap_or_default().as_str()) // if kdc_proxy_url does not exit, give url parser a empty string, it will fail anyway and map to a None .ok() .map(|url| KerberosConfig { kdc_proxy_url: Some(url), // HACK: It's supposed to be the computer name of the client, but since it's not easy to retrieve this information in the browser, // we set the destination hostname instead because it happens to work. - hostname: Some(destination), + hostname: destination, }), ) .await?; @@ -1090,9 +1708,15 @@ where server_addr: _, } => (x224_connection_response, server_cert_chain), ironrdp_rdcleanpath::RDCleanPath::GeneralErr(error) => { + let details = iron_remote_desktop::RDCleanPathDetails::new( + error.http_status_code, + error.wsa_last_error, + error.tls_alert_code, + ); return Err( IronError::from(anyhow::Error::new(error).context("received an RDCleanPath error")) - .with_kind(IronErrorKind::RDCleanPath), + .with_kind(IronErrorKind::RDCleanPath) + .with_rdcleanpath_details(details), ); } ironrdp_rdcleanpath::RDCleanPath::NegotiationErr { @@ -1158,8 +1782,414 @@ where } } -#[expect(clippy::cast_sign_loss)] -#[expect(clippy::cast_possible_truncation)] +#[expect(clippy::as_conversions, clippy::cast_sign_loss, clippy::cast_possible_truncation)] fn f64_to_u16_saturating_cast(value: f64) -> u16 { value as u16 } + +#[cfg(test)] +mod tests { + use super::*; + + // Test helpers + fn create_test_input_channel() -> ( + mpsc::UnboundedSender, + mpsc::UnboundedReceiver, + ) { + mpsc::unbounded() + } + + #[test] + fn printer_driver_defaults_to_postscript_when_macos_version_is_unknown() { + assert_eq!( + printer_driver_name_for_macos_major_version(None), + DEFAULT_PRINTER_DRIVER_NAME + ); + } + + #[test] + fn printer_driver_uses_pdf_for_macos_14_and_newer() { + assert_eq!( + printer_driver_name_for_macos_major_version(Some(13)), + DEFAULT_PRINTER_DRIVER_NAME + ); + assert_eq!( + printer_driver_name_for_macos_major_version(Some(14)), + MICROSOFT_PRINT_TO_PDF_DRIVER_NAME + ); + assert_eq!( + printer_driver_name_for_macos_major_version(Some(15)), + MICROSOFT_PRINT_TO_PDF_DRIVER_NAME + ); + } + + #[test] + fn macos_major_version_is_parsed_from_user_agent() { + assert_eq!( + macos_major_version_from_user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_1) AppleWebKit/605.1.15"), + Some(14) + ); + assert_eq!( + macos_major_version_from_user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"), + Some(10) + ); + assert_eq!( + macos_major_version_from_user_agent("Mozilla/5.0 (Windows NT 10.0)"), + None + ); + } + + #[test] + fn test_request_file_contents_parameter_marshalling() { + let (tx, mut rx) = create_test_input_channel(); + + // Send request with various parameters + tx.unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::FileContentsRequestSend { + stream_id: 123, + index: 5, + flags: FileContentsFlags::RANGE, + position: 1024, + size: 4096, + clip_data_id: Some(42), + }, + )) + .unwrap(); + + // Verify message parameters + match rx.try_recv() { + Ok(RdpInputEvent::ClipboardBackend(WasmClipboardBackendMessage::FileContentsRequestSend { + stream_id, + index, + flags, + position, + size, + clip_data_id, + })) => { + assert_eq!(stream_id, 123); + assert_eq!(index, 5); + assert_eq!(flags, FileContentsFlags::RANGE); + assert_eq!(position, 1024); + assert_eq!(size, 4096); + assert_eq!(clip_data_id, Some(42)); + } + _ => panic!("Expected FileContentsRequestSend with correct parameters"), + } + } + + #[test] + fn test_request_file_contents_size_flag() { + let (tx, mut rx) = create_test_input_channel(); + + // Send SIZE request + tx.unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::FileContentsRequestSend { + stream_id: 1, + index: 0, + flags: FileContentsFlags::SIZE, + position: 0, + size: 8, + clip_data_id: Some(1), + }, + )) + .unwrap(); + + match rx.try_recv() { + Ok(RdpInputEvent::ClipboardBackend(WasmClipboardBackendMessage::FileContentsRequestSend { + flags, .. + })) => { + assert_eq!(flags, FileContentsFlags::SIZE); + } + _ => panic!("Expected SIZE request"), + } + } + + #[test] + fn test_request_file_contents_without_clip_data_id() { + let (tx, mut rx) = create_test_input_channel(); + + // Send request without clip_data_id + tx.unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::FileContentsRequestSend { + stream_id: 10, + index: 0, + flags: FileContentsFlags::RANGE, + position: 0, + size: 1024, + clip_data_id: None, + }, + )) + .unwrap(); + + match rx.try_recv() { + Ok(RdpInputEvent::ClipboardBackend(WasmClipboardBackendMessage::FileContentsRequestSend { + clip_data_id, + .. + })) => { + assert_eq!(clip_data_id, None); + } + _ => panic!("Expected request without clip_data_id"), + } + } + + #[test] + fn test_submit_file_contents_success_response() { + let (tx, mut rx) = create_test_input_channel(); + + let data = vec![1, 2, 3, 4, 5]; + tx.unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::FileContentsResponseSend { + stream_id: 42, + is_error: false, + data: data.clone(), + }, + )) + .unwrap(); + + match rx.try_recv() { + Ok(RdpInputEvent::ClipboardBackend(WasmClipboardBackendMessage::FileContentsResponseSend { + stream_id, + is_error, + data: received_data, + })) => { + assert_eq!(stream_id, 42); + assert!(!is_error); + assert_eq!(received_data, data); + } + _ => panic!("Expected FileContentsResponseSend success"), + } + } + + #[test] + fn test_submit_file_contents_error_response() { + let (tx, mut rx) = create_test_input_channel(); + + tx.unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::FileContentsResponseSend { + stream_id: 99, + is_error: true, + data: vec![], + }, + )) + .unwrap(); + + match rx.try_recv() { + Ok(RdpInputEvent::ClipboardBackend(WasmClipboardBackendMessage::FileContentsResponseSend { + is_error, + .. + })) => { + assert!(is_error); + } + _ => panic!("Expected error response"), + } + } + + #[test] + fn test_submit_file_contents_size_response() { + let (tx, mut rx) = create_test_input_channel(); + + // 8-byte size response (little-endian) + let size_data = vec![0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // 4096 bytes + + tx.unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::FileContentsResponseSend { + stream_id: 1, + is_error: false, + data: size_data.clone(), + }, + )) + .unwrap(); + + match rx.try_recv() { + Ok(RdpInputEvent::ClipboardBackend(WasmClipboardBackendMessage::FileContentsResponseSend { + data, .. + })) => { + assert_eq!(data.len(), 8); + assert_eq!(data, size_data); + } + _ => panic!("Expected size response"), + } + } + + #[test] + fn test_initiate_file_copy_message() { + let (tx, mut rx) = create_test_input_channel(); + + let files = vec![ + FileMetadata { + name: "file1.txt".to_owned(), + path: None, + size: 1024, + last_modified: 1_700_000_000_000, + is_directory: false, + }, + FileMetadata { + name: "file2.pdf".to_owned(), + path: Some("docs".to_owned()), + size: 2048, + last_modified: 1_700_000_001_000, + is_directory: false, + }, + ]; + + tx.unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::InitiateFileCopy { files }, + )) + .unwrap(); + + match rx.try_recv() { + Ok(RdpInputEvent::ClipboardBackend(WasmClipboardBackendMessage::InitiateFileCopy { + files: received_files, + })) => { + assert_eq!(received_files.len(), 2); + assert_eq!(received_files[0].name, "file1.txt"); + assert_eq!(received_files[0].path, None); + assert_eq!(received_files[0].size, 1024); + assert_eq!(received_files[1].name, "file2.pdf"); + assert_eq!(received_files[1].path, Some("docs".to_owned())); + assert_eq!(received_files[1].size, 2048); + } + _ => panic!("Expected InitiateFileCopy message"), + } + } + + #[test] + fn test_large_position_value_marshalling() { + let (tx, mut rx) = create_test_input_channel(); + + // Test with large position value (near u64 max) + let large_position = u64::MAX - 1000; + + tx.unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::FileContentsRequestSend { + stream_id: 1, + index: 0, + flags: FileContentsFlags::RANGE, + position: large_position, + size: 1024, + clip_data_id: Some(1), + }, + )) + .unwrap(); + + match rx.try_recv() { + Ok(RdpInputEvent::ClipboardBackend(WasmClipboardBackendMessage::FileContentsRequestSend { + position, + .. + })) => { + assert_eq!(position, large_position); + } + _ => panic!("Expected correct position marshalling"), + } + } + + #[test] + fn test_zero_size_file() { + let (tx, mut rx) = create_test_input_channel(); + + let files = vec![FileMetadata { + name: "empty.txt".to_owned(), + path: None, + size: 0, + last_modified: 0, + is_directory: false, + }]; + + tx.unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::InitiateFileCopy { files }, + )) + .unwrap(); + + match rx.try_recv() { + Ok(RdpInputEvent::ClipboardBackend(WasmClipboardBackendMessage::InitiateFileCopy { + files: received_files, + })) => { + assert_eq!(received_files[0].size, 0); + } + _ => panic!("Expected zero-size file"), + } + } + + #[test] + fn test_file_with_special_characters_in_name() { + let (tx, mut rx) = create_test_input_channel(); + + let files = vec![FileMetadata { + name: "test file (1) [copy].txt".to_owned(), + path: None, + size: 100, + last_modified: 0, + is_directory: false, + }]; + + tx.unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::InitiateFileCopy { files }, + )) + .unwrap(); + + match rx.try_recv() { + Ok(RdpInputEvent::ClipboardBackend(WasmClipboardBackendMessage::InitiateFileCopy { + files: received_files, + })) => { + assert_eq!(received_files[0].name, "test file (1) [copy].txt"); + } + _ => panic!("Expected file with special characters"), + } + } + + #[test] + fn test_empty_file_list() { + let (tx, mut rx) = create_test_input_channel(); + + let files: Vec = vec![]; + + tx.unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::InitiateFileCopy { files }, + )) + .unwrap(); + + match rx.try_recv() { + Ok(RdpInputEvent::ClipboardBackend(WasmClipboardBackendMessage::InitiateFileCopy { + files: received_files, + })) => { + assert!(received_files.is_empty()); + } + _ => panic!("Expected empty file list"), + } + } + + #[test] + fn test_flags_bits_conversion() { + let (tx, mut rx) = create_test_input_channel(); + + // Test SIZE flag (0x1) + let size_flags = FileContentsFlags::SIZE; + assert_eq!(size_flags.bits(), 0x1); + + // Test DATA flag (0x2) + let data_flags = FileContentsFlags::RANGE; + assert_eq!(data_flags.bits(), 0x2); + + // Test that flags convert correctly through the channel + tx.unbounded_send(RdpInputEvent::ClipboardBackend( + WasmClipboardBackendMessage::FileContentsRequestSend { + stream_id: 1, + index: 0, + flags: FileContentsFlags::from_bits_truncate(0x1), + position: 0, + size: 8, + clip_data_id: None, + }, + )) + .unwrap(); + + match rx.try_recv() { + Ok(RdpInputEvent::ClipboardBackend(WasmClipboardBackendMessage::FileContentsRequestSend { + flags, .. + })) => { + assert_eq!(flags.bits(), 0x1); + } + _ => panic!("Expected correct flags conversion"), + } + } +} diff --git a/crates/ironrdp/CHANGELOG.md b/crates/ironrdp/CHANGELOG.md index b0d0902fde..04b8244a06 100644 --- a/crates/ironrdp/CHANGELOG.md +++ b/crates/ironrdp/CHANGELOG.md @@ -6,6 +6,85 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[0.17.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.16.0...ironrdp-v0.17.0)] - 2026-07-10 + +### Security + +- [**breaking**] Send NetworkAutoDetect over the MCS message channel ([#1348](https://github.com/Devolutions/IronRDP/issues/1348)) ([8a1fd0118e](https://github.com/Devolutions/IronRDP/commit/8a1fd0118e0bac214c9050b6ca6b36a040046dd3)) + + Corrects Network Auto-Detect framing and routing to match MS-RDPBCGR by + moving it off the I/O channel slow-path Share Data PDUs and onto the MCS + message channel with the required Basic Security Header + (SEC_AUTODETECT_REQ / SEC_AUTODETECT_RSP). This aligns IronRDP with + mstsc/xfreerdp behavior and enables both connect-time and continuous + auto-detection to actually function. + +### Features + +- Gate native backends behind Cargo features ([#1338](https://github.com/Devolutions/IronRDP/issues/1338)) ([f7e6106e0f](https://github.com/Devolutions/IronRDP/commit/f7e6106e0f293c1e0f8129be82aa2d86737ba92a)) + + - Added: client, client-all, client-sound, client-clipboard, + client-rdpdr, client-smartcard, client-gateway, + client-dvc-pipe-proxy, client-dvc-com-plugin, and + top-level rustls / native-tls (forwarded to ironrdp-client) + - Modified: qoi, qoiz now also gate ironrdp-client's codec + +- [**breaking**] Misuse-resistant format negotiation for RdpsndServerHandler ([#1359](https://github.com/Devolutions/IronRDP/issues/1359)) ([2d3bdef1a7](https://github.com/Devolutions/IronRDP/commit/2d3bdef1a7167d2acdc478a92917cbb2f018960b)) + + Move the negotiation into the crate and split selection from lifecycle: + + ```rust + fn choose_format<'a>(&mut self, common: &'a [NegotiatedFormat]) -> Option<&'a NegotiatedFormat>; + fn start(&mut self, format: &NegotiatedFormat); + ``` + +### Bug Fixes + +- [**breaking**] Remove ironrdp-connector dependency ([#1435](https://github.com/Devolutions/IronRDP/issues/1435)) ([c6a0286dcb](https://github.com/Devolutions/IronRDP/commit/c6a0286dcb49d9ac54c65c4f9325b41e05d541b8)) + + Removes the last ironrdp-connector coupling from ironrdp-session by + turning Deactivate-All handling into a bare signal and shifting ownership + of the Deactivation-Reactivation activation sequence back to each consumer. + It introduces a ConnectionActivationFactory (fresh sequence per reactivation) + and an ActiveStageBuilder so session construction no longer depends on + ConnectionResult. + + + +## [[0.16.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.15.0...ironrdp-v0.16.0)] - 2026-06-05 + +### Build + +- [**breaking**] Update `ironrdp-displaycontrol`, `ironrdp-dvc`, `ironrdp-echo`, `ironrdp-server`, and `ironrdp-session` public dependencies + + + +## [[0.15.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.14.0...ironrdp-v0.15.0)] - 2026-05-27 + +### Build + +- Update dependencies + +## [[0.14.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.13.0...ironrdp-v0.14.0)] - 2025-12-18 + +### Build + +- Bump picky and sspi ([#1028](https://github.com/Devolutions/IronRDP/issues/1028)) ([5bd319126d](https://github.com/Devolutions/IronRDP/commit/5bd319126d32fbd8e505508e27ab2b1a18a83d04)) + + This fixes build issues with some dependencies. + +## [[0.13.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.12.0...ironrdp-v0.13.0)] - 2025-09-24 + +### Build + +- Update dependencies + +## [[0.12.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.11.0...ironrdp-v0.12.0)] - 2025-08-29 + +### Build + +- Update dependencies + ## [[0.11.0](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.10.0...ironrdp-v0.11.0)] - 2025-07-08 ### Build @@ -50,7 +129,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Inline documentation for re-exported items (#619) ([cff5c1a59c](https://github.com/Devolutions/IronRDP/commit/cff5c1a59cdc2da73cabcb675fcf2d85dc81fd68)) - ## [[0.7.2](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.7.1...ironrdp-v0.7.2)] - 2024-12-15 ### Documentation @@ -62,10 +140,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 workspace). - ## [[0.7.1](https://github.com/Devolutions/IronRDP/compare/ironrdp-v0.7.0...ironrdp-v0.7.1)] - 2024-12-14 ### Other - Symlinks to license files in packages ([#604](https://github.com/Devolutions/IronRDP/pull/604)) ([6c2de344c2](https://github.com/Devolutions/IronRDP/commit/6c2de344c2dd93ce9621834e0497ed7c3bfaf91a)) - diff --git a/crates/ironrdp/Cargo.toml b/crates/ironrdp/Cargo.toml index 8598b4640d..3f6cb1ded6 100644 --- a/crates/ironrdp/Cargo.toml +++ b/crates/ironrdp/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "ironrdp" -version = "0.11.0" +version = "0.17.0" readme = "README.md" description = "A meta crate re-exporting IronRDP crates for convenience" edition.workspace = true +rust-version = "1.89" license.workspace = true homepage.workspace = true repository.workspace = true @@ -31,42 +32,61 @@ dvc = ["dep:ironrdp-dvc"] rdpdr = ["dep:ironrdp-rdpdr"] rdpsnd = ["dep:ironrdp-rdpsnd"] displaycontrol = ["dep:ironrdp-displaycontrol"] -qoi = ["ironrdp-server?/qoi", "ironrdp-pdu?/qoi", "ironrdp-connector?/qoi", "ironrdp-session?/qoi"] -qoiz = ["ironrdp-server?/qoiz", "ironrdp-pdu?/qoiz", "ironrdp-connector?/qoiz", "ironrdp-session?/qoiz"] +echo = ["dep:ironrdp-echo"] +mstsgu = ["dep:ironrdp-mstsgu"] +client = ["dep:ironrdp-client"] +# TLS backends for the client (exactly one is required when the client is enabled). +rustls = ["ironrdp-client?/rustls", "ironrdp-mstsgu?/rustls"] +native-tls = ["ironrdp-client?/native-tls", "ironrdp-mstsgu?/native-tls"] +# Optional client subsystems, forwarded so consumers can opt in without naming `ironrdp-client`. +client-sound = ["ironrdp-client?/sound"] +client-clipboard = ["ironrdp-client?/clipboard"] +client-rdpdr = ["ironrdp-client?/rdpdr"] +client-smartcard = ["ironrdp-client?/smartcard"] +client-gateway = ["ironrdp-client?/gateway"] +client-dvc-pipe-proxy = ["ironrdp-client?/dvc-pipe-proxy"] +client-dvc-com-plugin = ["ironrdp-client?/dvc-com-plugin"] +client-all = ["ironrdp-client?/all"] +qoi = ["ironrdp-server?/qoi", "ironrdp-pdu?/qoi", "ironrdp-connector?/qoi", "ironrdp-session?/qoi", "ironrdp-client?/qoi"] +qoiz = ["ironrdp-server?/qoiz", "ironrdp-pdu?/qoiz", "ironrdp-connector?/qoiz", "ironrdp-session?/qoiz", "ironrdp-client?/qoiz"] + # Internal (PRIVATE!) features used to aid testing. # Don't rely on these whatsoever. They may disappear at any time. __bench = ["ironrdp-server/__bench"] [dependencies] -ironrdp-core = { path = "../ironrdp-core", version = "0.1", optional = true } # public -ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.5", optional = true } # public -ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.3", optional = true } # public -ironrdp-connector = { path = "../ironrdp-connector", version = "0.6", optional = true } # public -ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.6", optional = true } # public -ironrdp-session = { path = "../ironrdp-session", version = "0.5", optional = true } # public -ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.4", optional = true } # public -ironrdp-input = { path = "../ironrdp-input", version = "0.3", optional = true } # public -ironrdp-server = { path = "../ironrdp-server", version = "0.7", optional = true, features = ["helper"] } # public -ironrdp-svc = { path = "../ironrdp-svc", version = "0.4", optional = true } # public -ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.3", optional = true } # public -ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.3", optional = true } # public -ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.5", optional = true } # public -ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.3", optional = true } # public +ironrdp-core = { path = "../ironrdp-core", version = "0.2", optional = true } # public +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.9", optional = true } # public +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.7", optional = true } # public +ironrdp-connector = { path = "../ironrdp-connector", version = "0.10", optional = true } # public +ironrdp-acceptor = { path = "../ironrdp-acceptor", version = "0.10", optional = true } # public +ironrdp-session = { path = "../ironrdp-session", version = "0.11", optional = true } # public +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.9", optional = true } # public +ironrdp-input = { path = "../ironrdp-input", version = "0.7", optional = true } # public +ironrdp-server = { path = "../ironrdp-server", version = "0.13", optional = true, features = ["helper"] } # public +ironrdp-svc = { path = "../ironrdp-svc", version = "0.8", optional = true } # public +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.8", optional = true } # public +ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.7", optional = true } # public +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.9", optional = true } # public +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.8", optional = true } # public +ironrdp-echo = { path = "../ironrdp-echo", version = "0.4", optional = true } # public +ironrdp-mstsgu = { path = "../ironrdp-mstsgu", version = "0.0.1", optional = true } # public +ironrdp-client = { path = "../ironrdp-client", version = "0.1", optional = true } # public [dev-dependencies] -ironrdp-blocking = { path = "../ironrdp-blocking", version = "0.6.0" } -ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.3.0" } +ironrdp-blocking = { path = "../ironrdp-blocking", version = "0.10" } +ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.7" } anyhow = "1" async-trait = "0.1" -image = { version = "0.25.6", default-features = false, features = ["png"] } +image = { version = "0.25", default-features = false, features = ["png"] } pico-args = "0.5" x509-cert = { version = "0.2", default-features = false, features = ["std"] } -sspi = { version = "0.16", features = ["network_client"] } +sspi = { version = "0.21", features = ["network_client"] } tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } tokio-rustls = "0.26" rand = "0.9" -opus = "0.3" +opus2 = "0.4" [package.metadata.docs.rs] cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] diff --git a/crates/ironrdp/examples/screenshot.rs b/crates/ironrdp/examples/screenshot.rs index 431b86a291..b62a4c51f8 100644 --- a/crates/ironrdp/examples/screenshot.rs +++ b/crates/ironrdp/examples/screenshot.rs @@ -29,8 +29,8 @@ use ironrdp::connector::ConnectionResult; use ironrdp::pdu::gcc::KeyboardType; use ironrdp::pdu::rdp::capability_sets::MajorPlatformType; use ironrdp::session::image::DecodedImage; -use ironrdp::session::{ActiveStage, ActiveStageOutput}; -use ironrdp_pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; +use ironrdp::session::{ActiveStageBuilder, ActiveStageOutput}; +use ironrdp_pdu::rdp::client_info::{CompressionType, PerformanceFlags, TimezoneInfo}; use sspi::network_client::reqwest_network_client::ReqwestNetworkClient; use tokio_rustls::rustls; use tracing::{debug, info, trace}; @@ -40,6 +40,7 @@ USAGE: cargo run --example=screenshot -- --host --port -u/--username -p/--password [-o/--output ] [-d/--domain ] + [--compression-enabled ] [--compression-level <0..3>] "; fn main() -> anyhow::Result<()> { @@ -65,13 +66,45 @@ fn main() -> anyhow::Result<()> { password, output, domain, + compression_enabled, + compression_level, } => { - info!(host, port, username, password, output = %output.display(), domain, "run"); - run(host, port, username, password, output, domain) + info!( + host, + port, + username, + output = %output.display(), + domain, + compression_enabled, + compression_level, + "run" + ); + run(RunConfig { + host, + port, + username, + password, + output, + domain, + compression_enabled, + compression_level, + }) } } } +#[derive(Debug)] +struct RunConfig { + host: String, + port: u16, + username: String, + password: String, + output: PathBuf, + domain: Option, + compression_enabled: bool, + compression_level: u32, +} + #[derive(Debug)] enum Action { ShowHelp, @@ -82,6 +115,8 @@ enum Action { password: String, output: PathBuf, domain: Option, + compression_enabled: bool, + compression_level: u32, }, } @@ -99,6 +134,12 @@ fn parse_args() -> anyhow::Result { .opt_value_from_str(["-o", "--output"])? .unwrap_or_else(|| PathBuf::from("out.png")); let domain = args.opt_value_from_str(["-d", "--domain"])?; + let compression_enabled = args.opt_value_from_str("--compression-enabled")?.unwrap_or(true); + let compression_level = args.opt_value_from_str("--compression-level")?.unwrap_or(3); + + if compression_level > 3 { + anyhow::bail!("Invalid compression level. Valid values are 0, 1, 2, 3."); + } Action::Run { host, @@ -107,6 +148,8 @@ fn parse_args() -> anyhow::Result { password, output, domain, + compression_enabled, + compression_level, } }; @@ -115,8 +158,8 @@ fn parse_args() -> anyhow::Result { fn setup_logging() -> anyhow::Result<()> { use tracing::metadata::LevelFilter; - use tracing_subscriber::prelude::*; use tracing_subscriber::EnvFilter; + use tracing_subscriber::prelude::*; let fmt_layer = tracing_subscriber::fmt::layer().compact(); @@ -134,17 +177,17 @@ fn setup_logging() -> anyhow::Result<()> { Ok(()) } -fn run( - server_name: String, - port: u16, - username: String, - password: String, - output: PathBuf, - domain: Option, -) -> anyhow::Result<()> { - let config = build_config(username, password, domain); +fn run(config: RunConfig) -> anyhow::Result<()> { + let connector_config = build_config( + config.username, + config.password, + config.domain, + config.compression_enabled, + config.compression_level, + )?; - let (connection_result, framed) = connect(config, server_name, port).context("connect")?; + let (connection_result, framed) = connect(connector_config, config.host, config.port).context("connect")?; + info!(compression_type = ?connection_result.compression_type, "Negotiated compression"); let mut image = DecodedImage::new( ironrdp_graphics::image_processing::PixelFormat::RgbA32, @@ -158,13 +201,25 @@ fn run( image::ImageBuffer::from_raw(u32::from(image.width()), u32::from(image.height()), image.data()) .context("invalid image")?; - img.save(output).context("save image to disk")?; + img.save(config.output).context("save image to disk")?; Ok(()) } -fn build_config(username: String, password: String, domain: Option) -> connector::Config { - connector::Config { +fn build_config( + username: String, + password: String, + domain: Option, + compression_enabled: bool, + compression_level: u32, +) -> anyhow::Result { + let compression_type = if compression_enabled { + Some(compression_type_from_level(compression_level)?) + } else { + None + }; + + Ok(connector::Config { credentials: Credentials::UsernamePassword { username, password }, domain, enable_tls: false, // This example does not expose any frontend. @@ -207,12 +262,26 @@ fn build_config(username: String, password: String, domain: Option) -> c request_data: None, autologon: false, enable_audio_playback: false, + compression_type, pointer_software_rendering: true, + multitransport_flags: None, performance_flags: PerformanceFlags::default(), desktop_scale_factor: 0, hardware_id: None, license_cache: None, timezone_info: TimezoneInfo::default(), + alternate_shell: String::new(), + work_dir: String::new(), + }) +} + +fn compression_type_from_level(level: u32) -> anyhow::Result { + match level { + 0 => Ok(CompressionType::K8), + 1 => Ok(CompressionType::K64), + 2 => Ok(CompressionType::Rdp6), + 3 => Ok(CompressionType::Rdp61), + _ => anyhow::bail!("Invalid compression level. Valid values are 0, 1, 2, 3."), } } @@ -258,11 +327,11 @@ fn connect( let mut network_client = ReqwestNetworkClient; let connection_result = ironrdp_blocking::connect_finalize( upgraded, - &mut upgraded_framed, connector, + &mut upgraded_framed, + &mut network_client, server_name.into(), server_public_key, - &mut network_client, None, ) .context("finalize connection")?; @@ -275,7 +344,17 @@ fn active_stage( mut framed: UpgradedFramed, image: &mut DecodedImage, ) -> anyhow::Result<()> { - let mut active_stage = ActiveStage::new(connection_result); + let mut active_stage = ActiveStageBuilder { + static_channels: connection_result.static_channels, + user_channel_id: connection_result.user_channel_id, + io_channel_id: connection_result.io_channel_id, + message_channel_id: connection_result.message_channel_id, + share_id: connection_result.share_id, + compression_type: connection_result.compression_type, + enable_server_pointer: connection_result.enable_server_pointer, + pointer_software_rendering: connection_result.pointer_software_rendering, + } + .build(); 'outer: loop { let (action, payload) = match framed.read_pdu() { @@ -302,7 +381,10 @@ fn active_stage( fn lookup_addr(hostname: &str, port: u16) -> anyhow::Result { use std::net::ToSocketAddrs as _; - let addr = (hostname, port).to_socket_addrs()?.next().unwrap(); + let addr = (hostname, port) + .to_socket_addrs()? + .next() + .context("socket address not found")?; Ok(addr) } @@ -327,7 +409,7 @@ fn tls_upgrade( let config = std::sync::Arc::new(config); - let server_name = server_name.try_into().unwrap(); + let server_name = server_name.try_into()?; let client = rustls::ClientConnection::new(config, server_name)?; @@ -368,7 +450,7 @@ fn extract_tls_server_public_key(cert: &[u8]) -> anyhow::Result> { mod danger { use tokio_rustls::rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; - use tokio_rustls::rustls::{pki_types, DigitallySignedStruct, Error, SignatureScheme}; + use tokio_rustls::rustls::{DigitallySignedStruct, Error, SignatureScheme, pki_types}; #[derive(Debug)] pub(super) struct NoCertificateVerification; diff --git a/crates/ironrdp/examples/server.rs b/crates/ironrdp/examples/server.rs index 57e488e9a1..ef5d44028f 100644 --- a/crates/ironrdp/examples/server.rs +++ b/crates/ironrdp/examples/server.rs @@ -4,21 +4,22 @@ #![allow(clippy::print_stdout)] use core::net::SocketAddr; -use core::num::{NonZero, NonZeroU16, NonZeroUsize}; +use core::num::{NonZeroU16, NonZeroUsize}; +use std::io; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use anyhow::Context as _; use ironrdp::cliprdr::backend::{CliprdrBackend, CliprdrBackendFactory}; use ironrdp::connector::DesktopSize; -use ironrdp::rdpsnd::pdu::{AudioFormat, ClientAudioFormatPdu, WaveFormat}; -use ironrdp::rdpsnd::server::{RdpsndServerHandler, RdpsndServerMessage}; +use ironrdp::rdpsnd::pdu::{AudioFormat, WaveFormat}; +use ironrdp::rdpsnd::server::{NegotiatedFormat, RdpsndError, RdpsndServerHandler, RdpsndServerMessage}; use ironrdp::server::tokio::sync::mpsc::UnboundedSender; -use ironrdp::server::tokio::time::{self, sleep, Duration}; +use ironrdp::server::tokio::time::{self, Duration, sleep}; use ironrdp::server::{ - tokio, BitmapUpdate, CliprdrServerFactory, Credentials, DisplayUpdate, KeyboardEvent, MouseEvent, PixelFormat, - RdpServer, RdpServerDisplay, RdpServerDisplayUpdates, RdpServerInputHandler, ServerEvent, ServerEventSender, - SoundServerFactory, TlsIdentityCtx, + BitmapUpdate, CliprdrServerFactory, Credentials, DisplayUpdate, KeyboardEvent, MouseEvent, PixelFormat, RdpServer, + RdpServerDisplay, RdpServerDisplayUpdates, RdpServerInputHandler, ServerEvent, ServerEventSender, + SoundServerFactory, TlsIdentityCtx, tokio, }; use ironrdp_cliprdr_native::StubCliprdrBackend; use rand::prelude::*; @@ -108,8 +109,8 @@ fn parse_args() -> anyhow::Result { fn setup_logging() -> anyhow::Result<()> { use tracing::metadata::LevelFilter; - use tracing_subscriber::prelude::*; use tracing_subscriber::EnvFilter; + use tracing_subscriber::prelude::*; let fmt_layer = tracing_subscriber::fmt::layer().compact(); @@ -153,21 +154,24 @@ struct DisplayUpdates; #[async_trait::async_trait] impl RdpServerDisplayUpdates for DisplayUpdates { - async fn next_update(&mut self) -> Option { + async fn next_update(&mut self) -> anyhow::Result> { sleep(Duration::from_millis(100)).await; let mut rng = rand::rng(); let y: u16 = rng.random_range(0..HEIGHT); - let height = NonZeroU16::new(rng.random_range(1..=HEIGHT.checked_sub(y).unwrap())).unwrap(); + let height = rng.random_range(1..=HEIGHT.checked_sub(y).expect("never underflow")); + let height = NonZeroU16::new(height).expect("never zero"); + let x: u16 = rng.random_range(0..WIDTH); - let width = NonZeroU16::new(rng.random_range(1..=WIDTH.checked_sub(x).unwrap())).unwrap(); + let width = rng.random_range(1..=WIDTH.checked_sub(x).expect("never underflow")); + let width = NonZeroU16::new(width).expect("never zero"); + let capacity = NonZeroUsize::from(width) .checked_mul(NonZeroUsize::from(height)) - .unwrap() + .expect("never overflow") .get() .checked_mul(4) - .unwrap(); - + .expect("never overflow"); let mut data = Vec::with_capacity(capacity); for _ in 0..(data.capacity() / 4) { data.push(rng.random()); @@ -177,7 +181,9 @@ impl RdpServerDisplayUpdates for DisplayUpdates { } info!("get_update +{x}+{y} {width}x{height}"); - let stride = NonZeroUsize::from(width).checked_mul(NonZero::new(4).unwrap()).unwrap(); + let stride = NonZeroUsize::from(width) + .checked_mul(NonZeroUsize::new(4).expect("never zero")) + .expect("never overflow"); let bitmap = BitmapUpdate { x, y, @@ -187,7 +193,7 @@ impl RdpServerDisplayUpdates for DisplayUpdates { data: data.into(), stride, }; - Some(DisplayUpdate::Bitmap(bitmap)) + Ok(Some(DisplayUpdate::Bitmap(bitmap))) } } @@ -230,8 +236,7 @@ struct StubSoundServerFactory { impl ServerEventSender for StubSoundServerFactory { fn set_sender(&mut self, sender: UnboundedSender) { - let mut inner = self.inner.lock().unwrap(); - + let mut inner = self.inner.lock().expect("poisoned"); inner.ev_sender = Some(sender); } } @@ -251,17 +256,6 @@ struct SndHandler { task: Option>, } -impl SndHandler { - fn choose_format(&self, client_formats: &[AudioFormat]) -> Option { - for (n, fmt) in client_formats.iter().enumerate() { - if self.get_formats().contains(fmt) { - return u16::try_from(n).ok(); - } - } - None - } -} - impl RdpsndServerHandler for SndHandler { fn get_formats(&self) -> &[AudioFormat] { &[ @@ -286,30 +280,32 @@ impl RdpsndServerHandler for SndHandler { ] } - fn start(&mut self, client_format: &ClientAudioFormatPdu) -> Option { - debug!(?client_format); + fn choose_format<'a>(&mut self, common: &'a [NegotiatedFormat]) -> Option<&'a NegotiatedFormat> { + debug!(?common); - let Some(nfmt) = self.choose_format(&client_format.formats) else { - return Some(0); - }; + // The crate hands us the formats common to both peers in our preference + // order; take the most-preferred one. + common.first() + } - let fmt = client_format.formats[usize::from(nfmt)].clone(); + fn start(&mut self, format: &NegotiatedFormat) -> Result<(), Box> { + let fmt = format.format().clone(); let mut opus_enc = if fmt.format == WaveFormat::OPUS { - let n_channels: opus::Channels = match fmt.n_channels { - 1 => opus::Channels::Mono, - 2 => opus::Channels::Stereo, - n => { - warn!("Invalid OPUS channels: {}", n); - return Some(0); - } + let n_channels: opus2::Channels = match fmt.n_channels { + 1 => opus2::Channels::Mono, + 2 => opus2::Channels::Stereo, + // Init failure: decline the format instead of leaving the channel + // negotiated-but-silent (the crate logs the error and skips audio). + n => return Err(Box::new(io::Error::other(format!("invalid OPUS channels: {n}")))), }; - match opus::Encoder::new(fmt.n_samples_per_sec, n_channels, opus::Application::Audio) { + match opus2::Encoder::new(fmt.n_samples_per_sec, n_channels, opus2::Application::Audio) { Ok(enc) => Some(enc), Err(err) => { - warn!("Failed to create OPUS encoder: {}", err); - return Some(0); + return Err(Box::new(io::Error::other(format!( + "failed to create OPUS encoder: {err}" + )))); } } } else { @@ -337,7 +333,7 @@ impl RdpsndServerHandler for SndHandler { wave.into_iter().flat_map(|value| value.to_le_bytes()).collect() }; - let inner = inner.lock().unwrap(); + let inner = inner.lock().expect("poisoned"); if let Some(sender) = inner.ev_sender.as_ref() { let _ = sender.send(ServerEvent::Rdpsnd(RdpsndServerMessage::Wave(data, ts))); } @@ -345,7 +341,7 @@ impl RdpsndServerHandler for SndHandler { } })); - Some(nfmt) + Ok(()) } fn stop(&mut self) { @@ -360,19 +356,23 @@ fn generate_sine_wave(sample_rate: u32, frequency: f32, duration_ms: u64, phase: use core::f32::consts::PI; let total_samples = (u64::from(sample_rate) * duration_ms) / 1000; + + #[expect(clippy::as_conversions)] let delta_phase = 2.0 * PI * frequency / sample_rate as f32; + let amplitude = 32767.0; // Max amplitude for 16-bit audio - let capacity = (total_samples as usize) * 2; // 2 channels + let capacity = usize::try_from(total_samples).expect("u64-to-usize") * 2; // 2 channels let mut samples = Vec::with_capacity(capacity); for _ in 0..total_samples { let sample = (*phase).sin(); *phase += delta_phase; - // Wrap phase to maintain precision and avoid overflow + + // Wrap phase to maintain precision and avoid overflow. *phase %= 2.0 * PI; - #[expect(clippy::cast_possible_truncation)] + #[expect(clippy::as_conversions, clippy::cast_possible_truncation)] let sample_i16 = (sample * amplitude) as i16; // Write same sample to both channels (stereo) diff --git a/crates/ironrdp/src/lib.rs b/crates/ironrdp/src/lib.rs index eb920a3a9c..1ca6ae70e7 100644 --- a/crates/ironrdp/src/lib.rs +++ b/crates/ironrdp/src/lib.rs @@ -4,7 +4,7 @@ #[cfg(test)] use { - anyhow as _, async_trait as _, image as _, ironrdp_blocking as _, ironrdp_cliprdr_native as _, opus as _, + anyhow as _, async_trait as _, image as _, ironrdp_blocking as _, ironrdp_cliprdr_native as _, opus2 as _, pico_args as _, rand as _, sspi as _, tokio_rustls as _, tracing as _, tracing_subscriber as _, x509_cert as _, }; @@ -16,6 +16,10 @@ pub use ironrdp_acceptor as acceptor; #[doc(inline)] pub use ironrdp_cliprdr as cliprdr; +#[cfg(feature = "client")] +#[doc(inline)] +pub use ironrdp_client as client; + #[cfg(feature = "connector")] #[doc(inline)] pub use ironrdp_connector as connector; @@ -28,6 +32,10 @@ pub use ironrdp_core as core; #[doc(inline)] pub use ironrdp_displaycontrol as displaycontrol; +#[cfg(feature = "echo")] +#[doc(inline)] +pub use ironrdp_echo as echo; + #[cfg(feature = "dvc")] #[doc(inline)] pub use ironrdp_dvc as dvc; @@ -40,6 +48,10 @@ pub use ironrdp_graphics as graphics; #[doc(inline)] pub use ironrdp_input as input; +#[cfg(feature = "mstsgu")] +#[doc(inline)] +pub use ironrdp_mstsgu as mstsgu; + #[cfg(feature = "pdu")] #[doc(inline)] pub use ironrdp_pdu as pdu; diff --git a/ffi/Cargo.toml b/ffi/Cargo.toml index 6d08c88aee..b4e6f5e547 100644 --- a/ffi/Cargo.toml +++ b/ffi/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ffi" version = "0.0.0" -edition = "2021" +edition = "2024" publish = false [lib] @@ -16,11 +16,14 @@ diplomat = "0.7" diplomat-runtime = "0.7" ironrdp = { path = "../crates/ironrdp", features = ["session", "connector", "dvc", "svc", "rdpdr", "rdpsnd", "graphics", "input", "cliprdr", "displaycontrol"] } ironrdp-cliprdr-native.path = "../crates/ironrdp-cliprdr-native" +ironrdp-dvc-pipe-proxy.path = "../crates/ironrdp-dvc-pipe-proxy" ironrdp-core = { path = "../crates/ironrdp-core", features = ["alloc"] } -sspi = { version = "0.16", features = ["network_client"] } +ironrdp-rdcleanpath.path = "../crates/ironrdp-rdcleanpath" +sspi = { version = "0.21", features = ["network_client"] } thiserror = "2" tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1.0" [target.'cfg(windows)'.build-dependencies] embed-resource = "3.0" diff --git a/ffi/build.rs b/ffi/build.rs index 4f2cacea74..98bb256791 100644 --- a/ffi/build.rs +++ b/ffi/build.rs @@ -4,7 +4,7 @@ use other::main_stub; use win::main_stub; fn main() { - main_stub(); + main_stub() } #[cfg(target_os = "windows")] @@ -19,7 +19,8 @@ mod win { let company_name = "Devolutions Inc."; let legal_copyright = format!("Copyright 2019-2024 {company_name}"); - let mut cargo_version = env::var("CARGO_PKG_VERSION").unwrap(); + let mut cargo_version = + env::var("CARGO_PKG_VERSION").expect("failed to fetch `CARGO_PKG_VERSION` environment variable"); cargo_version.push_str(".0"); let version_number = cargo_version; @@ -74,14 +75,15 @@ END } pub(crate) fn main_stub() { - let out_dir = env::var("OUT_DIR").unwrap(); + let out_dir = env::var("OUT_DIR").expect("failed to fetch `OUT_DIR` environment variable"); let version_rc_file = format!("{out_dir}/version.rc"); let version_rc_data = generate_version_rc(); - let mut file = File::create(&version_rc_file).expect("cannot create version.rc file"); - file.write_all(version_rc_data.as_bytes()).unwrap(); + let mut file = File::create(&version_rc_file).expect("failed to create version.rc file"); + file.write_all(version_rc_data.as_bytes()) + .expect("failed to write data to version.rc file"); embed_resource::compile(&version_rc_file, embed_resource::NONE) .manifest_required() - .unwrap(); + .expect("failed to compiler the Windows resource file"); } } diff --git a/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/Devolutions.IronRdp.AvaloniaExample.csproj b/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/Devolutions.IronRdp.AvaloniaExample.csproj index e29164c4ea..802b8ad765 100644 --- a/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/Devolutions.IronRdp.AvaloniaExample.csproj +++ b/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/Devolutions.IronRdp.AvaloniaExample.csproj @@ -10,18 +10,18 @@ - - - - + + + + - + - + diff --git a/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/MainWindow.axaml.cs b/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/MainWindow.axaml.cs index 341485271e..ee8c52c496 100644 --- a/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/MainWindow.axaml.cs +++ b/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/MainWindow.axaml.cs @@ -7,6 +7,7 @@ using System; using System.ComponentModel; using System.Diagnostics; +using System.IO; using System.Net.Security; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -22,7 +23,7 @@ public partial class MainWindow : Window readonly InputDatabase? _inputDatabase = InputDatabase.New(); ActiveStage? _activeStage; DecodedImage? _decodedImage; - Framed? _framed; + Framed? _framed; WinCliprdr? _cliprdr; private readonly RendererModel _renderModel; private Image? _imageControl; @@ -77,18 +78,49 @@ private void OnOpened(object? sender, EventArgs e) var username = Environment.GetEnvironmentVariable("IRONRDP_USERNAME"); var password = Environment.GetEnvironmentVariable("IRONRDP_PASSWORD"); - var domain = Environment.GetEnvironmentVariable("IRONRDP_DOMAIN"); + var domain = Environment.GetEnvironmentVariable("IRONRDP_DOMAIN"); // Optional var server = Environment.GetEnvironmentVariable("IRONRDP_SERVER"); + var portEnv = Environment.GetEnvironmentVariable("IRONRDP_PORT"); // Optional - if (username == null || password == null || domain == null || server == null) + // Gateway configuration (optional) + var gatewayUrl = Environment.GetEnvironmentVariable("IRONRDP_GATEWAY_URL"); + var gatewayToken = Environment.GetEnvironmentVariable("IRONRDP_GATEWAY_TOKEN"); + var tokengenUrl = Environment.GetEnvironmentVariable("IRONRDP_TOKENGEN_URL"); + + if (username == null || password == null || server == null) { var errorMessage = - "Please set the IRONRDP_USERNAME, IRONRDP_PASSWORD, IRONRDP_DOMAIN, and RONRDP_SERVER environment variables"; + "Please set the IRONRDP_USERNAME, IRONRDP_PASSWORD, and IRONRDP_SERVER environment variables"; Trace.TraceError(errorMessage); Close(); throw new InvalidProgramException(errorMessage); } + // Validate server is only domain or IP (no port allowed) + // i.e. "example.com" or "10.10.0.3" the port should go to the dedicated env var IRONRDP_PORT + if (server.Contains(':')) + { + var errorMessage = $"IRONRDP_SERVER must be a domain or IP address only, not '{server}'. Use IRONRDP_PORT for the port."; + Trace.TraceError(errorMessage); + Close(); + throw new InvalidProgramException(errorMessage); + } + + // Parse port from environment variable or use default + int port = 3389; + if (!string.IsNullOrEmpty(portEnv)) + { + if (!int.TryParse(portEnv, out port) || port <= 0 || port > 65535) + { + var errorMessage = $"IRONRDP_PORT must be a valid port number (1-65535), got '{portEnv}'"; + Trace.TraceError(errorMessage); + Close(); + throw new InvalidProgramException(errorMessage); + } + } + + Trace.TraceInformation($"Target server: {server}:{port}"); + var config = BuildConfig(username, password, domain, _renderModel.Width, _renderModel.Height); CliprdrBackendFactory? factory = null; @@ -106,15 +138,81 @@ private void OnOpened(object? sender, EventArgs e) BeforeConnectSetup(); Task.Run(async () => { - var (res, framed) = await Connection.Connect(config, server, factory); - this._decodedImage = DecodedImage.New(PixelFormat.RgbA32, res.GetDesktopSize().GetWidth(), - res.GetDesktopSize().GetHeight()); - this._activeStage = ActiveStage.New(res); - this._framed = framed; - ReadPduAndProcessActiveStage(); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + try { - HandleClipboardEvents(); + ConnectionResult res; + + // Determine connection mode: Gateway or Direct + if (!string.IsNullOrEmpty(gatewayUrl)) + { + Trace.TraceInformation("=== GATEWAY MODE ==="); + Trace.TraceInformation($"Gateway URL: {gatewayUrl}"); + Trace.TraceInformation($"Destination: {server}:{port}"); + + var tokenGen = new TokenGenerator(tokengenUrl ?? "http://localhost:8080"); + + // Generate RDP token if not provided + if (string.IsNullOrEmpty(gatewayToken)) + { + Trace.TraceInformation("No RDP token provided, generating token..."); + + try + { + gatewayToken = await tokenGen.GenerateRdpTlsToken( + dstHost: server!, + proxyUser: string.IsNullOrEmpty(domain) ? username : $"{username}@{domain}", + proxyPassword: password!, + destUser: username!, + destPassword: password! + ); + Trace.TraceInformation($"RDP token generated successfully (length: {gatewayToken.Length})"); + } + catch (Exception ex) + { + Trace.TraceError($"Failed to generate RDP token: {ex.Message}"); + Trace.TraceInformation("Make sure tokengen server is running:"); + Trace.TraceInformation($" cargo run --manifest-path tools/tokengen/Cargo.toml -- server"); + throw; + } + } + + // Connect via gateway - destination needs "hostname:port" format for RDCleanPath + string destination = $"{server}:{port}"; + + var (gatewayRes, gatewayFramed) = await RDCleanPathConnection.ConnectRDCleanPath( + config, gatewayUrl, gatewayToken!, destination, null, factory); + res = gatewayRes; + this._framed = new Framed(gatewayFramed.GetInner().Item1); + + Trace.TraceInformation("=== GATEWAY CONNECTION SUCCESSFUL ==="); + } + else + { + Trace.TraceInformation("=== DIRECT MODE ==="); + + // Direct connection (original behavior) + var (directRes, directFramed) = await Connection.Connect(config, server, factory, port); + res = directRes; + this._framed = new Framed(directFramed.GetInner().Item1); + + Trace.TraceInformation("=== DIRECT CONNECTION SUCCESSFUL ==="); + } + + this._decodedImage = DecodedImage.New(PixelFormat.RgbA32, res.GetDesktopSize().GetWidth(), + res.GetDesktopSize().GetHeight()); + this._activeStage = ActiveStage.New(res); + ReadPduAndProcessActiveStage(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + HandleClipboardEvents(); + } + } + catch (Exception ex) + { + Trace.TraceError($"Connection failed: {ex.Message}"); + Trace.TraceError($"Stack trace: {ex.StackTrace}"); + throw; } }); } @@ -260,12 +358,16 @@ private void HandleClipboardEvents() }); } - private static Config BuildConfig(string username, string password, string domain, int width, int height) + private static Config BuildConfig(string username, string password, string? domain, int width, int height) { ConfigBuilder configBuilder = ConfigBuilder.New(); configBuilder.WithUsernameAndPassword(username, password); - configBuilder.SetDomain(domain); + if (domain != null) + { + configBuilder.SetDomain(domain); + } + configBuilder.SetDesktopSize((ushort)height, (ushort)width); configBuilder.SetClientName("IronRdp"); configBuilder.SetClientDir("C:\\"); @@ -414,19 +516,20 @@ private async Task HandleActiveStageOutput(ActiveStageOutputIterator outpu } else if (output.GetEnumType() == ActiveStageOutputType.DeactivateAll) { - var activationSequence = output.GetDeactivateAll(); + var activationSequence = _activeStage!.CreateConnectionActivation(); var writeBuf = WriteBuf.New(); while (true) { - await Connection.SingleSequenceStep(activationSequence, writeBuf,_framed!); + await Connection.SingleSequenceStep(activationSequence, writeBuf, _framed!); if (activationSequence.GetState().GetType() != ConnectionActivationStateType.Finalized) continue; var finalized = activationSequence.GetState().GetFinalized(); var desktopSize = finalized.GetDesktopSize(); - var ioChannelId = finalized.GetIoChannelId(); - var userChannelId = finalized.GetUserChannelId(); + var ioChannelId = activationSequence.GetIoChannelId(); + var userChannelId = activationSequence.GetUserChannelId(); + var shareId = finalized.GetShareId(); var enableServerPointer = finalized.GetEnableServerPointer(); var pointerSoftwareRendering = finalized.GetPointerSoftwareRendering(); @@ -436,6 +539,7 @@ private async Task HandleActiveStageOutput(ActiveStageOutputIterator outpu _activeStage!.SetFastpathProcessor( ioChannelId, userChannelId, + shareId, enableServerPointer, pointerSoftwareRendering ); diff --git a/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/TokenGenerator.cs b/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/TokenGenerator.cs new file mode 100644 index 0000000000..00946c6047 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp.AvaloniaExample/TokenGenerator.cs @@ -0,0 +1,192 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Devolutions.IronRdp.AvaloniaExample; + +/// +/// Client for requesting JWT tokens from a Devolutions Gateway tokengen server. +/// +public class TokenGenerator : IDisposable +{ + private readonly HttpClient _client; + private readonly string _tokengenUrl; + + /// + /// Creates a new TokenGenerator instance. + /// + /// The base URL of the tokengen server (e.g., "http://localhost:8080") + public TokenGenerator(string tokengenUrl = "http://localhost:8080") + { + _tokengenUrl = tokengenUrl; + _client = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + } + + /// + /// Generates an RDP token with credential injection for gateway-based connections. + /// + /// Destination RDP server (e.g., "10.10.0.3:3389") + /// Gateway proxy username + /// Gateway proxy password + /// Destination RDP server username + /// Destination RDP server password + /// Optional session UUID + /// Token validity in seconds (default: 3600) + /// A JWT token string + public async Task GenerateRdpTlsToken( + string dstHost, + string proxyUser, + string proxyPassword, + string destUser, + string destPassword, + string? jetAid = null, + int validityDuration = 3600) + { + var request = new RdpTlsTokenRequest + { + DstHst = dstHost, + PrxUsr = proxyUser, + PrxPwd = proxyPassword, + DstUsr = destUser, + DstPwd = destPassword, + JetAid = jetAid, + ValidityDuration = validityDuration + }; + + try + { + var response = await _client.PostAsJsonAsync($"{_tokengenUrl}/rdp_tls", request); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(); + if (result?.Token == null) + { + throw new Exception("Token generation failed: Empty response"); + } + + return result.Token; + } + catch (HttpRequestException ex) + { + throw new Exception($"Failed to connect to tokengen server at {_tokengenUrl}: {ex.Message}", ex); + } + catch (TaskCanceledException ex) + { + throw new Exception($"Token generation request timed out: {ex.Message}", ex); + } + } + + /// + /// Generates a forward mode token for simple RDP forwarding without credential injection. + /// + /// Destination host + /// Application protocol (default: "rdp") + /// Enable recording + /// Token validity in seconds (default: 3600) + /// A JWT token string + public async Task GenerateForwardToken( + string dstHost, + string jetAp = "rdp", + bool jetRec = false, + int validityDuration = 3600) + { + var request = new ForwardTokenRequest + { + DstHst = dstHost, + JetAp = jetAp, + JetRec = jetRec, + ValidityDuration = validityDuration + }; + + try + { + var response = await _client.PostAsJsonAsync($"{_tokengenUrl}/forward", request); + response.EnsureSuccessStatusCode(); + + var result = await response.Content.ReadFromJsonAsync(); + if (result?.Token == null) + { + throw new Exception("Token generation failed: Empty response"); + } + + return result.Token; + } + catch (HttpRequestException ex) + { + throw new Exception($"Failed to connect to tokengen server at {_tokengenUrl}: {ex.Message}", ex); + } + } + + /// + /// Checks if the tokengen server is reachable. + /// + /// True if server is reachable, false otherwise + public async Task IsServerReachable() + { + try + { + var response = await _client.GetAsync(_tokengenUrl); + return response.IsSuccessStatusCode || response.StatusCode == System.Net.HttpStatusCode.NotFound; + } + catch + { + return false; + } + } + + public void Dispose() + { + _client?.Dispose(); + } + + // Request/Response DTOs + private class RdpTlsTokenRequest + { + [JsonPropertyName("dst_hst")] + public string DstHst { get; set; } = string.Empty; + + [JsonPropertyName("prx_usr")] + public string PrxUsr { get; set; } = string.Empty; + + [JsonPropertyName("prx_pwd")] + public string PrxPwd { get; set; } = string.Empty; + + [JsonPropertyName("dst_usr")] + public string DstUsr { get; set; } = string.Empty; + + [JsonPropertyName("dst_pwd")] + public string DstPwd { get; set; } = string.Empty; + + [JsonPropertyName("jet_aid")] + public string? JetAid { get; set; } + + [JsonPropertyName("validity_duration")] + public int ValidityDuration { get; set; } + } + + private class ForwardTokenRequest + { + [JsonPropertyName("dst_hst")] + public string DstHst { get; set; } = string.Empty; + + [JsonPropertyName("jet_ap")] + public string JetAp { get; set; } = "rdp"; + + [JsonPropertyName("jet_rec")] + public bool JetRec { get; set; } + + [JsonPropertyName("validity_duration")] + public int ValidityDuration { get; set; } + } + + private class TokenResponse + { + [JsonPropertyName("token")] + public string? Token { get; set; } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Devolutions.IronRdp.ConnectExample.csproj b/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Devolutions.IronRdp.ConnectExample.csproj index 1ef474af52..61014ed132 100644 --- a/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Devolutions.IronRdp.ConnectExample.csproj +++ b/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Devolutions.IronRdp.ConnectExample.csproj @@ -16,7 +16,7 @@ https://learn.microsoft.com/en-us/dotnet/api/system.drawing?view=net-8.0 --> - + diff --git a/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Program.cs b/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Program.cs index b685455ca1..3c85f25872 100644 --- a/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Program.cs +++ b/ffi/dotnet/Devolutions.IronRdp.ConnectExample/Program.cs @@ -23,7 +23,7 @@ static async Task Main(string[] args) try { - var (res, framed) = await Connection.Connect(buildConfig(serverName, username, password, domain, 1980, 1080), serverName, null); + var (res, framed) = await Connection.Connect(buildConfig(username, password, domain, 1980, 1080), serverName, null); var decodedImage = DecodedImage.New(PixelFormat.RgbA32, res.GetDesktopSize().GetWidth(), res.GetDesktopSize().GetHeight()); var activeState = ActiveStage.New(res); var keepLooping = true; @@ -175,7 +175,7 @@ static void PrintHelp() Console.WriteLine(" --help Show this message and exit."); } - private static Config buildConfig(string servername, string username, string password, string domain, int width, int height) + private static Config buildConfig(string username, string password, string domain, int width, int height) { ConfigBuilder configBuilder = ConfigBuilder.New(); diff --git a/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.Build.iOS.props b/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.Build.iOS.props index af8d97ccad..79d86c0257 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.Build.iOS.props +++ b/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.Build.iOS.props @@ -1,12 +1,20 @@ - net8.0-ios + net9.0-ios 12.1 - - - runtimes/ios/native/ + + + runtimes/ios-arm64/native/ + true + Never + + + + + + runtimes/iossimulator-arm64/native/ true Never diff --git a/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.csproj b/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.csproj index bd99928c2c..b9aa05118f 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.csproj +++ b/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.csproj @@ -4,7 +4,7 @@ Devolutions Bindings to Rust IronRDP native library latest - 2024.5.22.0 + 2025.12.4.0 enable enable true @@ -25,10 +25,8 @@ $(RuntimesPath)/android-arm/native/libDevolutionsIronRdp.so $(RuntimesPath)/android-x64/native/libDevolutionsIronRdp.so $(RuntimesPath)/android-x86/native/libDevolutionsIronRdp.so - $(RuntimesPath)/ios-x64/native/libDevolutionsIronRdp.dylib - $(RuntimesPath)/ios-arm64/native/libDevolutionsIronRdp.dylib - $(RuntimesPath)/ios-universal/native/libDevolutionsIronRdp.dylib - $(RuntimesPath)/ios-universal/native/libDevolutionsIronRdp.framework + $(RuntimesPath)/ios-arm64/native/libDevolutionsIronRdp.framework + $(RuntimesPath)/iossimulator-arm64/native/libDevolutionsIronRdp.framework diff --git a/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.iOS.props b/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.iOS.props index 2831365331..eaa40889cc 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.iOS.props +++ b/ffi/dotnet/Devolutions.IronRdp/Devolutions.IronRdp.iOS.props @@ -1,7 +1,18 @@ - - + + true + true + + + + + Framework + + + + + Framework diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStage.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStage.cs index 456876009c..b6a0f3079a 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStage.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStage.cs @@ -61,6 +61,30 @@ public static ActiveStage New(ConnectionResult connectionResult) } } + /// + /// Produces a fresh connection activation sequence to drive the Deactivation-Reactivation + /// Sequence. + /// + /// + /// Call this upon receiving a [`ActiveStageOutputType::DeactivateAll`] output, drive the + /// returned sequence until it is finalized, then discard it. + /// + /// + /// A ConnectionActivationSequence allocated on Rust side. + /// + public ConnectionActivationSequence CreateConnectionActivation() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ActiveStage"); + } + Raw.ConnectionActivationSequence* retVal = Raw.ActiveStage.CreateConnectionActivation(_inner); + return new ConnectionActivationSequence(retVal); + } + } + /// /// /// A ActiveStageOutputIterator allocated on Rust side. @@ -217,6 +241,34 @@ public VecU8 SubmitClipboardFormatData(FormatDataResponse formatDataResponse) } } + /// + /// + /// A VecU8 allocated on Rust side. + /// + public VecU8 SendDvcPipeProxyMessage(DvcPipeProxyMessage message) + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ActiveStage"); + } + Raw.DvcPipeProxyMessage* messageRaw; + messageRaw = message.AsFFI(); + if (messageRaw == null) + { + throw new ObjectDisposedException("DvcPipeProxyMessage"); + } + Raw.SessionFfiResultBoxVecU8BoxIronRdpError result = Raw.ActiveStage.SendDvcPipeProxyMessage(_inner, messageRaw); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.VecU8* retVal = result.Ok; + return new VecU8(retVal); + } + } + /// /// /// A ActiveStageOutputIterator allocated on Rust side. @@ -265,7 +317,7 @@ public ActiveStageOutputIterator EncodedResize(uint width, uint height) } } - public void SetFastpathProcessor(ushort ioChannelId, ushort userChannelId, bool enableServerPointer, bool pointerSoftwareRendering) + public void SetFastpathProcessor(ushort ioChannelId, ushort userChannelId, uint shareId, bool enableServerPointer, bool pointerSoftwareRendering) { unsafe { @@ -273,7 +325,7 @@ public void SetFastpathProcessor(ushort ioChannelId, ushort userChannelId, bool { throw new ObjectDisposedException("ActiveStage"); } - Raw.ActiveStage.SetFastpathProcessor(_inner, ioChannelId, userChannelId, enableServerPointer, pointerSoftwareRendering); + Raw.ActiveStage.SetFastpathProcessor(_inner, ioChannelId, userChannelId, shareId, enableServerPointer, pointerSoftwareRendering); } } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutput.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutput.cs index 44e407576a..a9f5f82a42 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutput.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutput.cs @@ -15,11 +15,11 @@ public partial class ActiveStageOutput: IDisposable { private unsafe Raw.ActiveStageOutput* _inner; - public ConnectionActivationSequence DeactivateAll + public NetworkCharacteristics AutodetectNetworkCharacteristics { get { - return GetDeactivateAll(); + return GetAutodetectNetworkCharacteristics(); } } @@ -39,6 +39,14 @@ public InclusiveRectangle GraphicsUpdate } } + public MultitransportRequest MultitransportRequest + { + get + { + return GetMultitransportRequest(); + } + } + public DecodedPointer PointerBitmap { get @@ -211,11 +219,46 @@ public GracefulDisconnectReason GetTerminate() } } + /// + /// Returns the multitransport request ID and requested protocol. + /// + /// + /// The security cookie is intentionally not exposed — it is sensitive + /// and only needed internally for transport binding. + /// + /// + /// + /// A MultitransportRequest allocated on C# side. + /// + public MultitransportRequest GetMultitransportRequest() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ActiveStageOutput"); + } + Raw.SessionFfiResultMultitransportRequestBoxIronRdpError result = Raw.ActiveStageOutput.GetMultitransportRequest(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.MultitransportRequest retVal = result.Ok; + return new MultitransportRequest(retVal); + } + } + + /// + /// Connection quality signals from the server's auto-detect mechanism. + /// Returns RTT and bandwidth measurements for health monitoring. + /// These values will feed into FramePacingFeedback when the + /// library-level health observer traits from #1158 land. + /// /// /// - /// A ConnectionActivationSequence allocated on Rust side. + /// A NetworkCharacteristics allocated on C# side. /// - public ConnectionActivationSequence GetDeactivateAll() + public NetworkCharacteristics GetAutodetectNetworkCharacteristics() { unsafe { @@ -223,13 +266,13 @@ public ConnectionActivationSequence GetDeactivateAll() { throw new ObjectDisposedException("ActiveStageOutput"); } - Raw.SessionFfiResultBoxConnectionActivationSequenceBoxIronRdpError result = Raw.ActiveStageOutput.GetDeactivateAll(_inner); + Raw.SessionFfiResultNetworkCharacteristicsBoxIronRdpError result = Raw.ActiveStageOutput.GetAutodetectNetworkCharacteristics(_inner); if (!result.isOk) { throw new IronRdpException(new IronRdpError(result.Err)); } - Raw.ConnectionActivationSequence* retVal = result.Ok; - return new ConnectionActivationSequence(retVal); + Raw.NetworkCharacteristics retVal = result.Ok; + return new NetworkCharacteristics(retVal); } } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutputType.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutputType.cs index e4763bd9ef..1b3f995537 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutputType.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ActiveStageOutputType.cs @@ -21,4 +21,11 @@ public enum ActiveStageOutputType PointerBitmap = 5, Terminate = 6, DeactivateAll = 7, + MultitransportRequest = 8, + /// + /// Auto-detect network characteristics from server. + /// Use `get_autodetect_network_characteristics()` to retrieve + /// RTT and bandwidth values for connection quality monitoring. + /// + AutoDetect = 9, } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateCapabilitiesExchange.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/CertificateChainIterator.cs similarity index 53% rename from ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateCapabilitiesExchange.cs rename to ffi/dotnet/Devolutions.IronRdp/Generated/CertificateChainIterator.cs index 4b47b9ed96..9830d732da 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateCapabilitiesExchange.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/CertificateChainIterator.cs @@ -11,28 +11,12 @@ namespace Devolutions.IronRdp; #nullable enable -public partial class ConnectionActivationStateCapabilitiesExchange: IDisposable +public partial class CertificateChainIterator: IDisposable { - private unsafe Raw.ConnectionActivationStateCapabilitiesExchange* _inner; - - public ushort IoChannelId - { - get - { - return GetIoChannelId(); - } - } - - public ushort UserChannelId - { - get - { - return GetUserChannelId(); - } - } + private unsafe Raw.CertificateChainIterator* _inner; /// - /// Creates a managed ConnectionActivationStateCapabilitiesExchange from a raw handle. + /// Creates a managed CertificateChainIterator from a raw handle. /// /// /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). @@ -40,33 +24,53 @@ public ushort UserChannelId /// This constructor assumes the raw struct is allocated on Rust side. /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. /// - public unsafe ConnectionActivationStateCapabilitiesExchange(Raw.ConnectionActivationStateCapabilitiesExchange* handle) + public unsafe CertificateChainIterator(Raw.CertificateChainIterator* handle) { _inner = handle; } - public ushort GetIoChannelId() + /// + /// A VecU8 allocated on Rust side. + /// + public VecU8? Next() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("CertificateChainIterator"); + } + Raw.VecU8* retVal = Raw.CertificateChainIterator.Next(_inner); + if (retVal == null) + { + return null; + } + return new VecU8(retVal); + } + } + + public nuint Len() { unsafe { if (_inner == null) { - throw new ObjectDisposedException("ConnectionActivationStateCapabilitiesExchange"); + throw new ObjectDisposedException("CertificateChainIterator"); } - ushort retVal = Raw.ConnectionActivationStateCapabilitiesExchange.GetIoChannelId(_inner); + nuint retVal = Raw.CertificateChainIterator.Len(_inner); return retVal; } } - public ushort GetUserChannelId() + public bool IsEmpty() { unsafe { if (_inner == null) { - throw new ObjectDisposedException("ConnectionActivationStateCapabilitiesExchange"); + throw new ObjectDisposedException("CertificateChainIterator"); } - ushort retVal = Raw.ConnectionActivationStateCapabilitiesExchange.GetUserChannelId(_inner); + bool retVal = Raw.CertificateChainIterator.IsEmpty(_inner); return retVal; } } @@ -74,7 +78,7 @@ public ushort GetUserChannelId() /// /// Returns the underlying raw handle. /// - public unsafe Raw.ConnectionActivationStateCapabilitiesExchange* AsFFI() + public unsafe Raw.CertificateChainIterator* AsFFI() { return _inner; } @@ -91,14 +95,14 @@ public void Dispose() return; } - Raw.ConnectionActivationStateCapabilitiesExchange.Destroy(_inner); + Raw.CertificateChainIterator.Destroy(_inner); _inner = null; GC.SuppressFinalize(this); } } - ~ConnectionActivationStateCapabilitiesExchange() + ~CertificateChainIterator() { Dispose(); } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ClientConnector.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ClientConnector.cs index 6bc80d2876..dc9e45c214 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ClientConnector.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ClientConnector.cs @@ -128,6 +128,29 @@ public void WithDynamicChannelDisplayControl() } } + /// + public void WithDynamicChannelPipeProxy(DvcPipeProxyConfig config) + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ClientConnector"); + } + Raw.DvcPipeProxyConfig* configRaw; + configRaw = config.AsFFI(); + if (configRaw == null) + { + throw new ObjectDisposedException("DvcPipeProxyConfig"); + } + Raw.ConnectorFfiResultVoidBoxIronRdpError result = Raw.ClientConnector.WithDynamicChannelPipeProxy(_inner, configRaw); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + } + } + /// public bool ShouldPerformSecurityUpgrade() { diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardMessage.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardMessage.cs index 5d0441ce8e..f8af20d668 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardMessage.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardMessage.cs @@ -15,6 +15,14 @@ public partial class ClipboardMessage: IDisposable { private unsafe Raw.ClipboardMessage* _inner; + public IronRdpError? Error + { + get + { + return GetError(); + } + } + public ClipboardMessageType MessageType { get @@ -23,6 +31,22 @@ public ClipboardMessageType MessageType } } + public FfiFileContentsRequest? SendFileContentsRequest + { + get + { + return GetSendFileContentsRequest(); + } + } + + public FfiFileContentsResponse? SendFileContentsResponse + { + get + { + return GetSendFileContentsResponse(); + } + } + public FormatDataResponse? SendFormatData { get @@ -137,6 +161,66 @@ public ClipboardMessageType GetMessageType() } } + /// + /// A FfiFileContentsRequest allocated on Rust side. + /// + public FfiFileContentsRequest? GetSendFileContentsRequest() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ClipboardMessage"); + } + Raw.FfiFileContentsRequest* retVal = Raw.ClipboardMessage.GetSendFileContentsRequest(_inner); + if (retVal == null) + { + return null; + } + return new FfiFileContentsRequest(retVal); + } + } + + /// + /// A FfiFileContentsResponse allocated on Rust side. + /// + public FfiFileContentsResponse? GetSendFileContentsResponse() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ClipboardMessage"); + } + Raw.FfiFileContentsResponse* retVal = Raw.ClipboardMessage.GetSendFileContentsResponse(_inner); + if (retVal == null) + { + return null; + } + return new FfiFileContentsResponse(retVal); + } + } + + /// + /// A IronRdpError allocated on Rust side. + /// + public IronRdpError? GetError() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ClipboardMessage"); + } + Raw.IronRdpError* retVal = Raw.ClipboardMessage.GetError(_inner); + if (retVal == null) + { + return null; + } + return new IronRdpError(retVal); + } + } + /// /// Returns the underlying raw handle. /// diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardMessageType.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardMessageType.cs index 8c2b901e6b..fcd8ef6381 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardMessageType.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardMessageType.cs @@ -14,7 +14,10 @@ namespace Devolutions.IronRdp; public enum ClipboardMessageType { SendInitiateCopy = 0, - SendFormatData = 1, - SendInitiatePaste = 2, - Error = 3, + SendInitiateFileCopy = 1, + SendFormatData = 2, + SendInitiatePaste = 3, + SendFileContentsRequest = 4, + SendFileContentsResponse = 5, + Error = 6, } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardSvgMessage.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardSvcMessage.cs similarity index 75% rename from ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardSvgMessage.cs rename to ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardSvcMessage.cs index 676f4ac029..3c0a7b9473 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardSvgMessage.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ClipboardSvcMessage.cs @@ -11,12 +11,12 @@ namespace Devolutions.IronRdp; #nullable enable -public partial class ClipboardSvgMessage: IDisposable +public partial class ClipboardSvcMessage: IDisposable { - private unsafe Raw.ClipboardSvgMessage* _inner; + private unsafe Raw.ClipboardSvcMessage* _inner; /// - /// Creates a managed ClipboardSvgMessage from a raw handle. + /// Creates a managed ClipboardSvcMessage from a raw handle. /// /// /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). @@ -24,7 +24,7 @@ public partial class ClipboardSvgMessage: IDisposable /// This constructor assumes the raw struct is allocated on Rust side. /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. /// - public unsafe ClipboardSvgMessage(Raw.ClipboardSvgMessage* handle) + public unsafe ClipboardSvcMessage(Raw.ClipboardSvcMessage* handle) { _inner = handle; } @@ -32,7 +32,7 @@ public unsafe ClipboardSvgMessage(Raw.ClipboardSvgMessage* handle) /// /// Returns the underlying raw handle. /// - public unsafe Raw.ClipboardSvgMessage* AsFFI() + public unsafe Raw.ClipboardSvcMessage* AsFFI() { return _inner; } @@ -49,14 +49,14 @@ public void Dispose() return; } - Raw.ClipboardSvgMessage.Destroy(_inner); + Raw.ClipboardSvcMessage.Destroy(_inner); _inner = null; GC.SuppressFinalize(this); } } - ~ClipboardSvgMessage() + ~ClipboardSvcMessage() { Dispose(); } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/Config.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/Config.cs index e234f48232..7a37da1f39 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/Config.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/Config.cs @@ -15,6 +15,14 @@ public partial class Config: IDisposable { private unsafe Raw.Config* _inner; + public DvcPipeProxyConfig? DvcPipeProxy + { + get + { + return GetDvcPipeProxy(); + } + } + /// /// Creates a managed Config from a raw handle. /// @@ -41,6 +49,26 @@ public static ConfigBuilder GetBuilder() } } + /// + /// A DvcPipeProxyConfig allocated on Rust side. + /// + public DvcPipeProxyConfig? GetDvcPipeProxy() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("Config"); + } + Raw.DvcPipeProxyConfig* retVal = Raw.Config.GetDvcPipeProxy(_inner); + if (retVal == null) + { + return null; + } + return new DvcPipeProxyConfig(retVal); + } + } + /// /// Returns the underlying raw handle. /// diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ConfigBuilder.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ConfigBuilder.cs index 13d86c18d8..9531f84f0d 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ConfigBuilder.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ConfigBuilder.cs @@ -71,6 +71,14 @@ public string Domain } } + public DvcPipeProxyConfig DvcPipeProxy + { + set + { + SetDvcPipeProxy(value); + } + } + public bool EnableCredssp { set @@ -454,6 +462,24 @@ public void SetPointerSoftwareRendering(bool pointerSoftwareRendering) } } + public void SetDvcPipeProxy(DvcPipeProxyConfig dvcPipeProxy) + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ConfigBuilder"); + } + Raw.DvcPipeProxyConfig* dvcPipeProxyRaw; + dvcPipeProxyRaw = dvcPipeProxy.AsFFI(); + if (dvcPipeProxyRaw == null) + { + throw new ObjectDisposedException("DvcPipeProxyConfig"); + } + Raw.ConfigBuilder.SetDvcPipeProxy(_inner, dvcPipeProxyRaw); + } + } + /// /// /// A Config allocated on Rust side. diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationSequence.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationSequence.cs index e0d8700719..1abefa8164 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationSequence.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationSequence.cs @@ -15,6 +15,14 @@ public partial class ConnectionActivationSequence: IDisposable { private unsafe Raw.ConnectionActivationSequence* _inner; + public ushort IoChannelId + { + get + { + return GetIoChannelId(); + } + } + public ConnectionActivationState State { get @@ -23,6 +31,14 @@ public ConnectionActivationState State } } + public ushort UserChannelId + { + get + { + return GetUserChannelId(); + } + } + /// /// Creates a managed ConnectionActivationSequence from a raw handle. /// @@ -139,6 +155,32 @@ public Written StepNoInput(WriteBuf buf) } } + public ushort GetIoChannelId() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ConnectionActivationSequence"); + } + ushort retVal = Raw.ConnectionActivationSequence.GetIoChannelId(_inner); + return retVal; + } + } + + public ushort GetUserChannelId() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ConnectionActivationSequence"); + } + ushort retVal = Raw.ConnectionActivationSequence.GetUserChannelId(_inner); + return retVal; + } + } + /// /// Returns the underlying raw handle. /// diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationState.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationState.cs index 3b6052bcc4..4c238c7743 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationState.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationState.cs @@ -15,14 +15,6 @@ public partial class ConnectionActivationState: IDisposable { private unsafe Raw.ConnectionActivationState* _inner; - public ConnectionActivationStateCapabilitiesExchange CapabilitiesExchange - { - get - { - return GetCapabilitiesExchange(); - } - } - public ConnectionActivationStateConnectionFinalization ConnectionFinalization { get @@ -77,28 +69,6 @@ public ConnectionActivationStateType GetType() } } - /// - /// - /// A ConnectionActivationStateCapabilitiesExchange allocated on Rust side. - /// - public ConnectionActivationStateCapabilitiesExchange GetCapabilitiesExchange() - { - unsafe - { - if (_inner == null) - { - throw new ObjectDisposedException("ConnectionActivationState"); - } - Raw.ConnectorActivationFfiResultBoxConnectionActivationStateCapabilitiesExchangeBoxIronRdpError result = Raw.ConnectionActivationState.GetCapabilitiesExchange(_inner); - if (!result.isOk) - { - throw new IronRdpException(new IronRdpError(result.Err)); - } - Raw.ConnectionActivationStateCapabilitiesExchange* retVal = result.Ok; - return new ConnectionActivationStateCapabilitiesExchange(retVal); - } - } - /// /// /// A ConnectionActivationStateConnectionFinalization allocated on Rust side. diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateConnectionFinalization.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateConnectionFinalization.cs index 8011fbfa8b..545817e335 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateConnectionFinalization.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateConnectionFinalization.cs @@ -23,22 +23,6 @@ public DesktopSize DesktopSize } } - public ushort IoChannelId - { - get - { - return GetIoChannelId(); - } - } - - public ushort UserChannelId - { - get - { - return GetUserChannelId(); - } - } - /// /// Creates a managed ConnectionActivationStateConnectionFinalization from a raw handle. /// @@ -53,32 +37,6 @@ public unsafe ConnectionActivationStateConnectionFinalization(Raw.ConnectionActi _inner = handle; } - public ushort GetIoChannelId() - { - unsafe - { - if (_inner == null) - { - throw new ObjectDisposedException("ConnectionActivationStateConnectionFinalization"); - } - ushort retVal = Raw.ConnectionActivationStateConnectionFinalization.GetIoChannelId(_inner); - return retVal; - } - } - - public ushort GetUserChannelId() - { - unsafe - { - if (_inner == null) - { - throw new ObjectDisposedException("ConnectionActivationStateConnectionFinalization"); - } - ushort retVal = Raw.ConnectionActivationStateConnectionFinalization.GetUserChannelId(_inner); - return retVal; - } - } - /// /// A DesktopSize allocated on Rust side. /// diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateFinalized.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateFinalized.cs index f140a6c4df..a14f588eac 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateFinalized.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionActivationStateFinalized.cs @@ -31,14 +31,6 @@ public bool EnableServerPointer } } - public ushort IoChannelId - { - get - { - return GetIoChannelId(); - } - } - public bool PointerSoftwareRendering { get @@ -47,11 +39,11 @@ public bool PointerSoftwareRendering } } - public ushort UserChannelId + public uint ShareId { get { - return GetUserChannelId(); + return GetShareId(); } } @@ -69,20 +61,7 @@ public unsafe ConnectionActivationStateFinalized(Raw.ConnectionActivationStateFi _inner = handle; } - public ushort GetIoChannelId() - { - unsafe - { - if (_inner == null) - { - throw new ObjectDisposedException("ConnectionActivationStateFinalized"); - } - ushort retVal = Raw.ConnectionActivationStateFinalized.GetIoChannelId(_inner); - return retVal; - } - } - - public ushort GetUserChannelId() + public uint GetShareId() { unsafe { @@ -90,7 +69,7 @@ public ushort GetUserChannelId() { throw new ObjectDisposedException("ConnectionActivationStateFinalized"); } - ushort retVal = Raw.ConnectionActivationStateFinalized.GetUserChannelId(_inner); + uint retVal = Raw.ConnectionActivationStateFinalized.GetShareId(_inner); return retVal; } } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionResult.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionResult.cs index ed9c47791b..ffe51173d1 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionResult.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/ConnectionResult.cs @@ -47,6 +47,14 @@ public bool PointerSoftwareRendering } } + public uint ShareId + { + get + { + return GetShareId(); + } + } + public ushort UserChannelId { get @@ -107,6 +115,25 @@ public ushort GetUserChannelId() } } + /// + public uint GetShareId() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("ConnectionResult"); + } + Raw.ConnectorResultFfiResultU32BoxIronRdpError result = Raw.ConnectionResult.GetShareId(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + uint retVal = result.Ok; + return retVal; + } + } + /// /// /// A DesktopSize allocated on Rust side. diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyConfig.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyConfig.cs new file mode 100644 index 0000000000..b90fca7004 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyConfig.cs @@ -0,0 +1,123 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class DvcPipeProxyConfig: IDisposable +{ + private unsafe Raw.DvcPipeProxyConfig* _inner; + + public DvcPipeProxyMessageSink MessageSink + { + get + { + return GetMessageSink(); + } + } + + /// + /// Creates a managed DvcPipeProxyConfig from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe DvcPipeProxyConfig(Raw.DvcPipeProxyConfig* handle) + { + _inner = handle; + } + + /// + /// A DvcPipeProxyConfig allocated on Rust side. + /// + public static DvcPipeProxyConfig New(DvcPipeProxyMessageSink messageSink) + { + unsafe + { + Raw.DvcPipeProxyMessageSink* messageSinkRaw; + messageSinkRaw = messageSink.AsFFI(); + if (messageSinkRaw == null) + { + throw new ObjectDisposedException("DvcPipeProxyMessageSink"); + } + Raw.DvcPipeProxyConfig* retVal = Raw.DvcPipeProxyConfig.New(messageSinkRaw); + return new DvcPipeProxyConfig(retVal); + } + } + + public void AddPipeProxy(DvcPipeProxyDescriptor descriptor) + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("DvcPipeProxyConfig"); + } + Raw.DvcPipeProxyDescriptor* descriptorRaw; + descriptorRaw = descriptor.AsFFI(); + if (descriptorRaw == null) + { + throw new ObjectDisposedException("DvcPipeProxyDescriptor"); + } + Raw.DvcPipeProxyConfig.AddPipeProxy(_inner, descriptorRaw); + } + } + + /// + /// A DvcPipeProxyMessageSink allocated on Rust side. + /// + public DvcPipeProxyMessageSink GetMessageSink() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("DvcPipeProxyConfig"); + } + Raw.DvcPipeProxyMessageSink* retVal = Raw.DvcPipeProxyConfig.GetMessageSink(_inner); + return new DvcPipeProxyMessageSink(retVal); + } + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.DvcPipeProxyConfig* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.DvcPipeProxyConfig.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~DvcPipeProxyConfig() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyDescriptor.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyDescriptor.cs new file mode 100644 index 0000000000..668507a472 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyDescriptor.cs @@ -0,0 +1,85 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class DvcPipeProxyDescriptor: IDisposable +{ + private unsafe Raw.DvcPipeProxyDescriptor* _inner; + + /// + /// Creates a managed DvcPipeProxyDescriptor from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe DvcPipeProxyDescriptor(Raw.DvcPipeProxyDescriptor* handle) + { + _inner = handle; + } + + /// + /// A DvcPipeProxyDescriptor allocated on Rust side. + /// + public static DvcPipeProxyDescriptor New(string channelName, string pipeName) + { + unsafe + { + byte[] channelNameBuf = DiplomatUtils.StringToUtf8(channelName); + byte[] pipeNameBuf = DiplomatUtils.StringToUtf8(pipeName); + nuint channelNameBufLength = (nuint)channelNameBuf.Length; + nuint pipeNameBufLength = (nuint)pipeNameBuf.Length; + fixed (byte* channelNameBufPtr = channelNameBuf) + { + fixed (byte* pipeNameBufPtr = pipeNameBuf) + { + Raw.DvcPipeProxyDescriptor* retVal = Raw.DvcPipeProxyDescriptor.New(channelNameBufPtr, channelNameBufLength, pipeNameBufPtr, pipeNameBufLength); + return new DvcPipeProxyDescriptor(retVal); + } + } + } + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.DvcPipeProxyDescriptor* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.DvcPipeProxyDescriptor.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~DvcPipeProxyDescriptor() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessage.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessage.cs new file mode 100644 index 0000000000..3343d9f712 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessage.cs @@ -0,0 +1,84 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class DvcPipeProxyMessage: IDisposable +{ + private unsafe Raw.DvcPipeProxyMessage* _inner; + + public uint ChannelId + { + get + { + return GetChannelId(); + } + } + + /// + /// Creates a managed DvcPipeProxyMessage from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe DvcPipeProxyMessage(Raw.DvcPipeProxyMessage* handle) + { + _inner = handle; + } + + public uint GetChannelId() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("DvcPipeProxyMessage"); + } + uint retVal = Raw.DvcPipeProxyMessage.GetChannelId(_inner); + return retVal; + } + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.DvcPipeProxyMessage* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.DvcPipeProxyMessage.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~DvcPipeProxyMessage() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessageQueue.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessageQueue.cs new file mode 100644 index 0000000000..b53ac53cfe --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessageQueue.cs @@ -0,0 +1,147 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class DvcPipeProxyMessageQueue: IDisposable +{ + private unsafe Raw.DvcPipeProxyMessageQueue* _inner; + + public DvcPipeProxyMessageSink Sink + { + get + { + return GetSink(); + } + } + + /// + /// Creates a managed DvcPipeProxyMessageQueue from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe DvcPipeProxyMessageQueue(Raw.DvcPipeProxyMessageQueue* handle) + { + _inner = handle; + } + + /// + /// A DvcPipeProxyMessageQueue allocated on Rust side. + /// + public static DvcPipeProxyMessageQueue New(uint queueSize) + { + unsafe + { + Raw.DvcPipeProxyMessageQueue* retVal = Raw.DvcPipeProxyMessageQueue.New(queueSize); + return new DvcPipeProxyMessageQueue(retVal); + } + } + + /// + /// + /// A DvcPipeProxyMessage allocated on Rust side. + /// + public DvcPipeProxyMessage NextMessage() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("DvcPipeProxyMessageQueue"); + } + Raw.DvcDvcPipeProxyMessageQueueFfiResultOptBoxDvcPipeProxyMessageBoxIronRdpError result = Raw.DvcPipeProxyMessageQueue.NextMessage(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.DvcPipeProxyMessage* retVal = result.Ok; + if (retVal == null) + { + return null; + } + return new DvcPipeProxyMessage(retVal); + } + } + + /// + /// + /// A DvcPipeProxyMessage allocated on Rust side. + /// + public DvcPipeProxyMessage NextMessageBlocking() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("DvcPipeProxyMessageQueue"); + } + Raw.DvcDvcPipeProxyMessageQueueFfiResultBoxDvcPipeProxyMessageBoxIronRdpError result = Raw.DvcPipeProxyMessageQueue.NextMessageBlocking(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.DvcPipeProxyMessage* retVal = result.Ok; + return new DvcPipeProxyMessage(retVal); + } + } + + /// + /// A DvcPipeProxyMessageSink allocated on Rust side. + /// + public DvcPipeProxyMessageSink GetSink() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("DvcPipeProxyMessageQueue"); + } + Raw.DvcPipeProxyMessageSink* retVal = Raw.DvcPipeProxyMessageQueue.GetSink(_inner); + return new DvcPipeProxyMessageSink(retVal); + } + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.DvcPipeProxyMessageQueue* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.DvcPipeProxyMessageQueue.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~DvcPipeProxyMessageQueue() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessageSink.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessageSink.cs new file mode 100644 index 0000000000..c2d9b98b11 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/DvcPipeProxyMessageSink.cs @@ -0,0 +1,63 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class DvcPipeProxyMessageSink: IDisposable +{ + private unsafe Raw.DvcPipeProxyMessageSink* _inner; + + /// + /// Creates a managed DvcPipeProxyMessageSink from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe DvcPipeProxyMessageSink(Raw.DvcPipeProxyMessageSink* handle) + { + _inner = handle; + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.DvcPipeProxyMessageSink* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.DvcPipeProxyMessageSink.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~DvcPipeProxyMessageSink() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/FfiFileContentsRequest.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/FfiFileContentsRequest.cs new file mode 100644 index 0000000000..49ed637b9d --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/FfiFileContentsRequest.cs @@ -0,0 +1,173 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class FfiFileContentsRequest: IDisposable +{ + private unsafe Raw.FfiFileContentsRequest* _inner; + + /// + /// Creates a managed FfiFileContentsRequest from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe FfiFileContentsRequest(Raw.FfiFileContentsRequest* handle) + { + _inner = handle; + } + + public uint StreamId() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("FfiFileContentsRequest"); + } + uint retVal = Raw.FfiFileContentsRequest.StreamId(_inner); + return retVal; + } + } + + public int Index() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("FfiFileContentsRequest"); + } + int retVal = Raw.FfiFileContentsRequest.Index(_inner); + return retVal; + } + } + + public bool IsSizeRequest() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("FfiFileContentsRequest"); + } + bool retVal = Raw.FfiFileContentsRequest.IsSizeRequest(_inner); + return retVal; + } + } + + public bool IsRangeRequest() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("FfiFileContentsRequest"); + } + bool retVal = Raw.FfiFileContentsRequest.IsRangeRequest(_inner); + return retVal; + } + } + + public ulong Position() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("FfiFileContentsRequest"); + } + ulong retVal = Raw.FfiFileContentsRequest.Position(_inner); + return retVal; + } + } + + public uint RequestedSize() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("FfiFileContentsRequest"); + } + uint retVal = Raw.FfiFileContentsRequest.RequestedSize(_inner); + return retVal; + } + } + + public bool HasDataId() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("FfiFileContentsRequest"); + } + bool retVal = Raw.FfiFileContentsRequest.HasDataId(_inner); + return retVal; + } + } + + /// + public uint DataId() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("FfiFileContentsRequest"); + } + Raw.ClipboardMessageFfiResultU32BoxIronRdpError result = Raw.FfiFileContentsRequest.DataId(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + uint retVal = result.Ok; + return retVal; + } + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.FfiFileContentsRequest* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.FfiFileContentsRequest.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~FfiFileContentsRequest() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/FfiFileContentsResponse.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/FfiFileContentsResponse.cs new file mode 100644 index 0000000000..f1f3b68b26 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/FfiFileContentsResponse.cs @@ -0,0 +1,109 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +/// +/// Wraps `OwnedFileContentsResponse`, which is a type alias for +/// `FileContentsResponse<'static>` (generated by `impl_pdu_borrowing!`). +/// +public partial class FfiFileContentsResponse: IDisposable +{ + private unsafe Raw.FfiFileContentsResponse* _inner; + + /// + /// Creates a managed FfiFileContentsResponse from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe FfiFileContentsResponse(Raw.FfiFileContentsResponse* handle) + { + _inner = handle; + } + + public uint StreamId() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("FfiFileContentsResponse"); + } + uint retVal = Raw.FfiFileContentsResponse.StreamId(_inner); + return retVal; + } + } + + public bool IsError() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("FfiFileContentsResponse"); + } + bool retVal = Raw.FfiFileContentsResponse.IsError(_inner); + return retVal; + } + } + + /// + /// A VecU8 allocated on Rust side. + /// + public VecU8 Data() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("FfiFileContentsResponse"); + } + Raw.VecU8* retVal = Raw.FfiFileContentsResponse.Data(_inner); + return new VecU8(retVal); + } + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.FfiFileContentsResponse* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.FfiFileContentsResponse.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~FfiFileContentsResponse() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/Log.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/Log.cs index ec2769f3b7..c0e3af52ff 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/Log.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/Log.cs @@ -29,6 +29,13 @@ public unsafe Log(Raw.Log* handle) _inner = handle; } + /// + /// # Panics + /// + /// + /// - Panics if log directory creation fails. + /// - Panics if tracing initialization fails. + /// public static void InitWithEnv() { unsafe diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/MultitransportRequest.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/MultitransportRequest.cs new file mode 100644 index 0000000000..45ea7431bd --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/MultitransportRequest.cs @@ -0,0 +1,69 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class MultitransportRequest +{ + private Raw.MultitransportRequest _inner; + + public uint RequestId + { + get + { + unsafe + { + return _inner.request_id; + } + } + set + { + unsafe + { + _inner.request_id = value; + } + } + } + + public ushort RequestedProtocol + { + get + { + unsafe + { + return _inner.requested_protocol; + } + } + set + { + unsafe + { + _inner.requested_protocol = value; + } + } + } + + /// + /// Creates a managed MultitransportRequest from the raw representation. + /// + public unsafe MultitransportRequest(Raw.MultitransportRequest data) + { + _inner = data; + } + + /// + /// Returns a copy of the underlying raw representation. + /// + public Raw.MultitransportRequest AsFFI() + { + return _inner; + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/NetworkCharacteristics.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/NetworkCharacteristics.cs new file mode 100644 index 0000000000..be08be0468 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/NetworkCharacteristics.cs @@ -0,0 +1,137 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +/// +/// Connection quality measurements from server auto-detect (MS-RDPBCGR 2.2.14). +/// +public partial class NetworkCharacteristics +{ + private Raw.NetworkCharacteristics _inner; + + /// + /// Lowest detected round-trip time in milliseconds. + /// Only valid when `has_base_rtt` is true. + /// + public uint BaseRttMs + { + get + { + unsafe + { + return _inner.base_rtt_ms; + } + } + set + { + unsafe + { + _inner.base_rtt_ms = value; + } + } + } + + public bool HasBaseRtt + { + get + { + unsafe + { + return _inner.has_base_rtt; + } + } + set + { + unsafe + { + _inner.has_base_rtt = value; + } + } + } + + /// + /// Current average round-trip time in milliseconds. + /// + public uint AverageRttMs + { + get + { + unsafe + { + return _inner.average_rtt_ms; + } + } + set + { + unsafe + { + _inner.average_rtt_ms = value; + } + } + } + + /// + /// Estimated bandwidth in kilobits per second. + /// Only valid when `has_bandwidth` is true. + /// + public uint BandwidthKbps + { + get + { + unsafe + { + return _inner.bandwidth_kbps; + } + } + set + { + unsafe + { + _inner.bandwidth_kbps = value; + } + } + } + + public bool HasBandwidth + { + get + { + unsafe + { + return _inner.has_bandwidth; + } + } + set + { + unsafe + { + _inner.has_bandwidth = value; + } + } + } + + /// + /// Creates a managed NetworkCharacteristics from the raw representation. + /// + public unsafe NetworkCharacteristics(Raw.NetworkCharacteristics data) + { + _inner = data; + } + + /// + /// Returns a copy of the underlying raw representation. + /// + public Raw.NetworkCharacteristics AsFFI() + { + return _inner; + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathDetectionResult.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathDetectionResult.cs new file mode 100644 index 0000000000..644c997e64 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathDetectionResult.cs @@ -0,0 +1,129 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class RDCleanPathDetectionResult: IDisposable +{ + private unsafe Raw.RDCleanPathDetectionResult* _inner; + + public nuint TotalLength + { + get + { + return GetTotalLength(); + } + } + + /// + /// Creates a managed RDCleanPathDetectionResult from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe RDCleanPathDetectionResult(Raw.RDCleanPathDetectionResult* handle) + { + _inner = handle; + } + + public bool IsDetected() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathDetectionResult"); + } + bool retVal = Raw.RDCleanPathDetectionResult.IsDetected(_inner); + return retVal; + } + } + + public bool IsNotEnoughBytes() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathDetectionResult"); + } + bool retVal = Raw.RDCleanPathDetectionResult.IsNotEnoughBytes(_inner); + return retVal; + } + } + + public bool IsFailed() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathDetectionResult"); + } + bool retVal = Raw.RDCleanPathDetectionResult.IsFailed(_inner); + return retVal; + } + } + + /// + public nuint GetTotalLength() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathDetectionResult"); + } + Raw.RdcleanpathFfiResultUsizeBoxIronRdpError result = Raw.RDCleanPathDetectionResult.GetTotalLength(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + nuint retVal = result.Ok; + return retVal; + } + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.RDCleanPathDetectionResult* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.RDCleanPathDetectionResult.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~RDCleanPathDetectionResult() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathPdu.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathPdu.cs new file mode 100644 index 0000000000..b4c5d6278c --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathPdu.cs @@ -0,0 +1,424 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public partial class RDCleanPathPdu: IDisposable +{ + private unsafe Raw.RDCleanPathPdu* _inner; + + public ushort ErrorCode + { + get + { + return GetErrorCode(); + } + } + + public string ErrorMessage + { + get + { + return GetErrorMessage(); + } + } + + public ushort HttpStatusCode + { + get + { + return GetHttpStatusCode(); + } + } + + public string ServerAddr + { + get + { + return GetServerAddr(); + } + } + + public CertificateChainIterator ServerCertChain + { + get + { + return GetServerCertChain(); + } + } + + public RDCleanPathResultType Type + { + get + { + return GetType(); + } + } + + public VecU8 X224Response + { + get + { + return GetX224Response(); + } + } + + /// + /// Creates a managed RDCleanPathPdu from a raw handle. + /// + /// + /// Safety: you should not build two managed objects using the same raw handle (may causes use-after-free and double-free). + ///
+ /// This constructor assumes the raw struct is allocated on Rust side. + /// If implemented, the custom Drop implementation on Rust side WILL run on destruction. + ///
+ public unsafe RDCleanPathPdu(Raw.RDCleanPathPdu* handle) + { + _inner = handle; + } + + /// + /// Creates a new RDCleanPath request PDU + /// + /// + /// # Arguments + /// * `x224_pdu` - The X.224 Connection Request PDU bytes + /// * `destination` - The destination RDP server address (e.g., "10.10.0.3:3389") + /// * `proxy_auth` - The JWT authentication token + /// * `pcb` - Optional preconnection blob (for Hyper-V VM connections, empty string if not needed) + /// + /// + /// + /// A RDCleanPathPdu allocated on Rust side. + /// + public static RDCleanPathPdu NewRequest(byte[] x224Pdu, string destination, string proxyAuth, string pcb) + { + unsafe + { + byte[] destinationBuf = DiplomatUtils.StringToUtf8(destination); + byte[] proxyAuthBuf = DiplomatUtils.StringToUtf8(proxyAuth); + byte[] pcbBuf = DiplomatUtils.StringToUtf8(pcb); + nuint x224PduLength = (nuint)x224Pdu.Length; + nuint destinationBufLength = (nuint)destinationBuf.Length; + nuint proxyAuthBufLength = (nuint)proxyAuthBuf.Length; + nuint pcbBufLength = (nuint)pcbBuf.Length; + fixed (byte* x224PduPtr = x224Pdu) + { + fixed (byte* destinationBufPtr = destinationBuf) + { + fixed (byte* proxyAuthBufPtr = proxyAuthBuf) + { + fixed (byte* pcbBufPtr = pcbBuf) + { + Raw.RdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError result = Raw.RDCleanPathPdu.NewRequest(x224PduPtr, x224PduLength, destinationBufPtr, destinationBufLength, proxyAuthBufPtr, proxyAuthBufLength, pcbBufPtr, pcbBufLength); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.RDCleanPathPdu* retVal = result.Ok; + return new RDCleanPathPdu(retVal); + } + } + } + } + } + } + + /// + /// Decodes a RDCleanPath PDU from DER-encoded bytes + /// + /// + /// + /// A RDCleanPathPdu allocated on Rust side. + /// + public static RDCleanPathPdu FromDer(byte[] bytes) + { + unsafe + { + nuint bytesLength = (nuint)bytes.Length; + fixed (byte* bytesPtr = bytes) + { + Raw.RdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError result = Raw.RDCleanPathPdu.FromDer(bytesPtr, bytesLength); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.RDCleanPathPdu* retVal = result.Ok; + return new RDCleanPathPdu(retVal); + } + } + } + + /// + /// Encodes the RDCleanPath PDU to DER-encoded bytes + /// + /// + /// + /// A VecU8 allocated on Rust side. + /// + public VecU8 ToDer() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RdcleanpathFfiResultBoxVecU8BoxIronRdpError result = Raw.RDCleanPathPdu.ToDer(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.VecU8* retVal = result.Ok; + return new VecU8(retVal); + } + } + + /// + /// Detects if the bytes contain a valid RDCleanPath PDU and returns detection result + /// + /// + /// A RDCleanPathDetectionResult allocated on Rust side. + /// + public static RDCleanPathDetectionResult Detect(byte[] bytes) + { + unsafe + { + nuint bytesLength = (nuint)bytes.Length; + fixed (byte* bytesPtr = bytes) + { + Raw.RDCleanPathDetectionResult* retVal = Raw.RDCleanPathPdu.Detect(bytesPtr, bytesLength); + return new RDCleanPathDetectionResult(retVal); + } + } + } + + /// + /// Gets the type of this RDCleanPath PDU + /// + /// + /// + /// A RDCleanPathResultType allocated on C# side. + /// + public RDCleanPathResultType GetType() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RdcleanpathFfiResultRDCleanPathResultTypeBoxIronRdpError result = Raw.RDCleanPathPdu.GetType(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.RDCleanPathResultType retVal = result.Ok; + return (RDCleanPathResultType)retVal; + } + } + + /// + /// Gets the X.224 connection response bytes (for Response or NegotiationError variants) + /// + /// + /// + /// A VecU8 allocated on Rust side. + /// + public VecU8 GetX224Response() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RdcleanpathFfiResultBoxVecU8BoxIronRdpError result = Raw.RDCleanPathPdu.GetX224Response(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.VecU8* retVal = result.Ok; + return new VecU8(retVal); + } + } + + /// + /// Gets the server certificate chain (for Response variant) + /// Returns a vector iterator of certificate bytes + /// + /// + /// + /// A CertificateChainIterator allocated on Rust side. + /// + public CertificateChainIterator GetServerCertChain() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RdcleanpathFfiResultBoxCertificateChainIteratorBoxIronRdpError result = Raw.RDCleanPathPdu.GetServerCertChain(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + Raw.CertificateChainIterator* retVal = result.Ok; + return new CertificateChainIterator(retVal); + } + } + + /// + /// Gets the server address string (for Response variant) + /// + public void GetServerAddr(DiplomatWriteable writeable) + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RDCleanPathPdu.GetServerAddr(_inner, &writeable); + } + } + + /// + /// Gets the server address string (for Response variant) + /// + public string GetServerAddr() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + DiplomatWriteable writeable = new DiplomatWriteable(); + Raw.RDCleanPathPdu.GetServerAddr(_inner, &writeable); + string retVal = writeable.ToUnicode(); + writeable.Dispose(); + return retVal; + } + } + + /// + /// Gets error message (for GeneralError variant) + /// + public void GetErrorMessage(DiplomatWriteable writeable) + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RDCleanPathPdu.GetErrorMessage(_inner, &writeable); + } + } + + /// + /// Gets error message (for GeneralError variant) + /// + public string GetErrorMessage() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + DiplomatWriteable writeable = new DiplomatWriteable(); + Raw.RDCleanPathPdu.GetErrorMessage(_inner, &writeable); + string retVal = writeable.ToUnicode(); + writeable.Dispose(); + return retVal; + } + } + + /// + /// Gets the error code (for GeneralError variant) + /// + /// + public ushort GetErrorCode() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RdcleanpathFfiResultU16BoxIronRdpError result = Raw.RDCleanPathPdu.GetErrorCode(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + ushort retVal = result.Ok; + return retVal; + } + } + + /// + /// Gets the HTTP status code if present (for GeneralError variant) + /// Returns error if not present or not a GeneralError variant + /// + /// + public ushort GetHttpStatusCode() + { + unsafe + { + if (_inner == null) + { + throw new ObjectDisposedException("RDCleanPathPdu"); + } + Raw.RdcleanpathFfiResultU16BoxIronRdpError result = Raw.RDCleanPathPdu.GetHttpStatusCode(_inner); + if (!result.isOk) + { + throw new IronRdpException(new IronRdpError(result.Err)); + } + ushort retVal = result.Ok; + return retVal; + } + } + + /// + /// Returns the underlying raw handle. + /// + public unsafe Raw.RDCleanPathPdu* AsFFI() + { + return _inner; + } + + /// + /// Destroys the underlying object immediately. + /// + public void Dispose() + { + unsafe + { + if (_inner == null) + { + return; + } + + Raw.RDCleanPathPdu.Destroy(_inner); + _inner = null; + + GC.SuppressFinalize(this); + } + } + + ~RDCleanPathPdu() + { + Dispose(); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathResultType.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathResultType.cs new file mode 100644 index 0000000000..4145574e16 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RDCleanPathResultType.cs @@ -0,0 +1,20 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp; + +#nullable enable + +public enum RDCleanPathResultType +{ + Request = 0, + Response = 1, + GeneralError = 2, + NegotiationError = 3, +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStage.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStage.cs index 575a4f5594..005417238b 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStage.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStage.cs @@ -19,6 +19,17 @@ public partial struct ActiveStage [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStage_new", ExactSpelling = true)] public static unsafe extern SessionFfiResultBoxActiveStageBoxIronRdpError New(ConnectionResult* connectionResult); + /// + /// Produces a fresh connection activation sequence to drive the Deactivation-Reactivation + /// Sequence. + /// + /// + /// Call this upon receiving a [`ActiveStageOutputType::DeactivateAll`] output, drive the + /// returned sequence until it is finalized, then discard it. + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStage_create_connection_activation", ExactSpelling = true)] + public static unsafe extern ConnectionActivationSequence* CreateConnectionActivation(ActiveStage* self); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStage_process", ExactSpelling = true)] public static unsafe extern SessionFfiResultBoxActiveStageOutputIteratorBoxIronRdpError Process(ActiveStage* self, DecodedImage* image, Action* action, byte* payload, nuint payloadSz); @@ -34,6 +45,9 @@ public partial struct ActiveStage [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStage_submit_clipboard_format_data", ExactSpelling = true)] public static unsafe extern SessionFfiResultBoxVecU8BoxIronRdpError SubmitClipboardFormatData(ActiveStage* self, FormatDataResponse* formatDataResponse); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStage_send_dvc_pipe_proxy_message", ExactSpelling = true)] + public static unsafe extern SessionFfiResultBoxVecU8BoxIronRdpError SendDvcPipeProxyMessage(ActiveStage* self, DvcPipeProxyMessage* message); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStage_graceful_shutdown", ExactSpelling = true)] public static unsafe extern SessionFfiResultBoxActiveStageOutputIteratorBoxIronRdpError GracefulShutdown(ActiveStage* self); @@ -41,7 +55,7 @@ public partial struct ActiveStage public static unsafe extern SessionFfiResultOptBoxActiveStageOutputIteratorBoxIronRdpError EncodedResize(ActiveStage* self, uint width, uint height); [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStage_set_fastpath_processor", ExactSpelling = true)] - public static unsafe extern void SetFastpathProcessor(ActiveStage* self, ushort ioChannelId, ushort userChannelId, [MarshalAs(UnmanagedType.U1)] bool enableServerPointer, [MarshalAs(UnmanagedType.U1)] bool pointerSoftwareRendering); + public static unsafe extern void SetFastpathProcessor(ActiveStage* self, ushort ioChannelId, ushort userChannelId, uint shareId, [MarshalAs(UnmanagedType.U1)] bool enableServerPointer, [MarshalAs(UnmanagedType.U1)] bool pointerSoftwareRendering); [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStage_set_enable_server_pointer", ExactSpelling = true)] public static unsafe extern void SetEnableServerPointer(ActiveStage* self, [MarshalAs(UnmanagedType.U1)] bool enableServerPointer); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutput.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutput.cs index a39a901475..8c56dee46a 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutput.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutput.cs @@ -34,8 +34,24 @@ public partial struct ActiveStageOutput [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStageOutput_get_terminate", ExactSpelling = true)] public static unsafe extern SessionFfiResultBoxGracefulDisconnectReasonBoxIronRdpError GetTerminate(ActiveStageOutput* self); - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStageOutput_get_deactivate_all", ExactSpelling = true)] - public static unsafe extern SessionFfiResultBoxConnectionActivationSequenceBoxIronRdpError GetDeactivateAll(ActiveStageOutput* self); + /// + /// Returns the multitransport request ID and requested protocol. + /// + /// + /// The security cookie is intentionally not exposed — it is sensitive + /// and only needed internally for transport binding. + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStageOutput_get_multitransport_request", ExactSpelling = true)] + public static unsafe extern SessionFfiResultMultitransportRequestBoxIronRdpError GetMultitransportRequest(ActiveStageOutput* self); + + /// + /// Connection quality signals from the server's auto-detect mechanism. + /// Returns RTT and bandwidth measurements for health monitoring. + /// These values will feed into FramePacingFeedback when the + /// library-level health observer traits from #1158 land. + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStageOutput_get_autodetect_network_characteristics", ExactSpelling = true)] + public static unsafe extern SessionFfiResultNetworkCharacteristicsBoxIronRdpError GetAutodetectNetworkCharacteristics(ActiveStageOutput* self); [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ActiveStageOutput_destroy", ExactSpelling = true)] public static unsafe extern void Destroy(ActiveStageOutput* self); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutputType.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutputType.cs index 5f05f2f44f..bc500012d6 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutputType.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawActiveStageOutputType.cs @@ -21,4 +21,11 @@ public enum ActiveStageOutputType PointerBitmap = 5, Terminate = 6, DeactivateAll = 7, + MultitransportRequest = 8, + /// + /// Auto-detect network characteristics from server. + /// Use `get_autodetect_network_characteristics()` to retrieve + /// RTT and bandwidth values for connection quality monitoring. + /// + AutoDetect = 9, } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawCertificateChainIterator.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawCertificateChainIterator.cs new file mode 100644 index 0000000000..c451aa9912 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawCertificateChainIterator.cs @@ -0,0 +1,31 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct CertificateChainIterator +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "CertificateChainIterator_next", ExactSpelling = true)] + public static unsafe extern VecU8* Next(CertificateChainIterator* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "CertificateChainIterator_len", ExactSpelling = true)] + public static unsafe extern nuint Len(CertificateChainIterator* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "CertificateChainIterator_is_empty", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.U1)] + public static unsafe extern bool IsEmpty(CertificateChainIterator* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "CertificateChainIterator_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(CertificateChainIterator* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawClientConnector.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawClientConnector.cs index d030866791..9a4fcc42cd 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawClientConnector.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawClientConnector.cs @@ -34,6 +34,9 @@ public partial struct ClientConnector [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ClientConnector_with_dynamic_channel_display_control", ExactSpelling = true)] public static unsafe extern ConnectorFfiResultVoidBoxIronRdpError WithDynamicChannelDisplayControl(ClientConnector* self); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ClientConnector_with_dynamic_channel_pipe_proxy", ExactSpelling = true)] + public static unsafe extern ConnectorFfiResultVoidBoxIronRdpError WithDynamicChannelPipeProxy(ClientConnector* self, DvcPipeProxyConfig* config); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ClientConnector_should_perform_security_upgrade", ExactSpelling = true)] public static unsafe extern ConnectorFfiResultBoolBoxIronRdpError ShouldPerformSecurityUpgrade(ClientConnector* self); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardMessage.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardMessage.cs index e674a18f78..f708345c2e 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardMessage.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardMessage.cs @@ -28,6 +28,15 @@ public partial struct ClipboardMessage [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ClipboardMessage_get_send_initiate_paste", ExactSpelling = true)] public static unsafe extern ClipboardFormatId* GetSendInitiatePaste(ClipboardMessage* self); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ClipboardMessage_get_send_file_contents_request", ExactSpelling = true)] + public static unsafe extern FfiFileContentsRequest* GetSendFileContentsRequest(ClipboardMessage* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ClipboardMessage_get_send_file_contents_response", ExactSpelling = true)] + public static unsafe extern FfiFileContentsResponse* GetSendFileContentsResponse(ClipboardMessage* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ClipboardMessage_get_error", ExactSpelling = true)] + public static unsafe extern IronRdpError* GetError(ClipboardMessage* self); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ClipboardMessage_destroy", ExactSpelling = true)] public static unsafe extern void Destroy(ClipboardMessage* self); } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawSessionFfiResultBoxConnectionActivationSequenceBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardMessageFfiResultU32BoxIronRdpError.cs similarity index 79% rename from ffi/dotnet/Devolutions.IronRdp/Generated/RawSessionFfiResultBoxConnectionActivationSequenceBoxIronRdpError.cs rename to ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardMessageFfiResultU32BoxIronRdpError.cs index 0095580e4d..592b3455e2 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawSessionFfiResultBoxConnectionActivationSequenceBoxIronRdpError.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardMessageFfiResultU32BoxIronRdpError.cs @@ -12,13 +12,13 @@ namespace Devolutions.IronRdp.Raw; #nullable enable [StructLayout(LayoutKind.Sequential)] -public partial struct SessionFfiResultBoxConnectionActivationSequenceBoxIronRdpError +public partial struct ClipboardMessageFfiResultU32BoxIronRdpError { [StructLayout(LayoutKind.Explicit)] private unsafe struct InnerUnion { [FieldOffset(0)] - internal ConnectionActivationSequence* ok; + internal uint ok; [FieldOffset(0)] internal IronRdpError* err; } @@ -28,7 +28,7 @@ private unsafe struct InnerUnion [MarshalAs(UnmanagedType.U1)] public bool isOk; - public unsafe ConnectionActivationSequence* Ok + public unsafe uint Ok { get { diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardMessageType.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardMessageType.cs index 17c6ac37ab..dd3a3569b8 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardMessageType.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardMessageType.cs @@ -14,7 +14,10 @@ namespace Devolutions.IronRdp.Raw; public enum ClipboardMessageType { SendInitiateCopy = 0, - SendFormatData = 1, - SendInitiatePaste = 2, - Error = 3, + SendInitiateFileCopy = 1, + SendFormatData = 2, + SendInitiatePaste = 3, + SendFileContentsRequest = 4, + SendFileContentsResponse = 5, + Error = 6, } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardSvgMessage.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardSvcMessage.cs similarity index 71% rename from ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardSvgMessage.cs rename to ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardSvcMessage.cs index 77001bad88..d3d28296cf 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardSvgMessage.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawClipboardSvcMessage.cs @@ -12,10 +12,10 @@ namespace Devolutions.IronRdp.Raw; #nullable enable [StructLayout(LayoutKind.Sequential)] -public partial struct ClipboardSvgMessage +public partial struct ClipboardSvcMessage { private const string NativeLib = "DevolutionsIronRdp"; - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ClipboardSvgMessage_destroy", ExactSpelling = true)] - public static unsafe extern void Destroy(ClipboardSvgMessage* self); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ClipboardSvcMessage_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(ClipboardSvcMessage* self); } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfig.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfig.cs index 1eb7592e2a..8d2cc8ebc5 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfig.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfig.cs @@ -19,6 +19,9 @@ public partial struct Config [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "Config_get_builder", ExactSpelling = true)] public static unsafe extern ConfigBuilder* GetBuilder(); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "Config_get_dvc_pipe_proxy", ExactSpelling = true)] + public static unsafe extern DvcPipeProxyConfig* GetDvcPipeProxy(Config* self); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "Config_destroy", ExactSpelling = true)] public static unsafe extern void Destroy(Config* self); } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfigBuilder.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfigBuilder.cs index 6314b47af0..bf7652311d 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfigBuilder.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConfigBuilder.cs @@ -76,6 +76,9 @@ public partial struct ConfigBuilder [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConfigBuilder_set_pointer_software_rendering", ExactSpelling = true)] public static unsafe extern void SetPointerSoftwareRendering(ConfigBuilder* self, [MarshalAs(UnmanagedType.U1)] bool pointerSoftwareRendering); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConfigBuilder_set_dvc_pipe_proxy", ExactSpelling = true)] + public static unsafe extern void SetDvcPipeProxy(ConfigBuilder* self, DvcPipeProxyConfig* dvcPipeProxy); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConfigBuilder_build", ExactSpelling = true)] public static unsafe extern ConnectorConfigFfiResultBoxConfigBoxIronRdpError Build(ConfigBuilder* self); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationSequence.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationSequence.cs index 6dc5956609..2652633570 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationSequence.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationSequence.cs @@ -28,6 +28,12 @@ public partial struct ConnectionActivationSequence [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationSequence_step_no_input", ExactSpelling = true)] public static unsafe extern ConnectorActivationFfiResultBoxWrittenBoxIronRdpError StepNoInput(ConnectionActivationSequence* self, WriteBuf* buf); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationSequence_get_io_channel_id", ExactSpelling = true)] + public static unsafe extern ushort GetIoChannelId(ConnectionActivationSequence* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationSequence_get_user_channel_id", ExactSpelling = true)] + public static unsafe extern ushort GetUserChannelId(ConnectionActivationSequence* self); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationSequence_destroy", ExactSpelling = true)] public static unsafe extern void Destroy(ConnectionActivationSequence* self); } diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationState.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationState.cs index 65ae1c5bf2..dc4d9fe299 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationState.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationState.cs @@ -19,9 +19,6 @@ public partial struct ConnectionActivationState [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationState_get_type", ExactSpelling = true)] public static unsafe extern ConnectionActivationStateType GetType(ConnectionActivationState* self); - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationState_get_capabilities_exchange", ExactSpelling = true)] - public static unsafe extern ConnectorActivationFfiResultBoxConnectionActivationStateCapabilitiesExchangeBoxIronRdpError GetCapabilitiesExchange(ConnectionActivationState* self); - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationState_get_connection_finalization", ExactSpelling = true)] public static unsafe extern ConnectorActivationFfiResultBoxConnectionActivationStateConnectionFinalizationBoxIronRdpError GetConnectionFinalization(ConnectionActivationState* self); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateCapabilitiesExchange.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateCapabilitiesExchange.cs deleted file mode 100644 index 3214f0e3e3..0000000000 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateCapabilitiesExchange.cs +++ /dev/null @@ -1,27 +0,0 @@ -// by Diplomat - -#pragma warning disable 0105 -using System; -using System.Runtime.InteropServices; - -using Devolutions.IronRdp.Diplomat; -#pragma warning restore 0105 - -namespace Devolutions.IronRdp.Raw; - -#nullable enable - -[StructLayout(LayoutKind.Sequential)] -public partial struct ConnectionActivationStateCapabilitiesExchange -{ - private const string NativeLib = "DevolutionsIronRdp"; - - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateCapabilitiesExchange_get_io_channel_id", ExactSpelling = true)] - public static unsafe extern ushort GetIoChannelId(ConnectionActivationStateCapabilitiesExchange* self); - - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateCapabilitiesExchange_get_user_channel_id", ExactSpelling = true)] - public static unsafe extern ushort GetUserChannelId(ConnectionActivationStateCapabilitiesExchange* self); - - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateCapabilitiesExchange_destroy", ExactSpelling = true)] - public static unsafe extern void Destroy(ConnectionActivationStateCapabilitiesExchange* self); -} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateConnectionFinalization.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateConnectionFinalization.cs index 7289659086..f868284d44 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateConnectionFinalization.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateConnectionFinalization.cs @@ -16,12 +16,6 @@ public partial struct ConnectionActivationStateConnectionFinalization { private const string NativeLib = "DevolutionsIronRdp"; - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateConnectionFinalization_get_io_channel_id", ExactSpelling = true)] - public static unsafe extern ushort GetIoChannelId(ConnectionActivationStateConnectionFinalization* self); - - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateConnectionFinalization_get_user_channel_id", ExactSpelling = true)] - public static unsafe extern ushort GetUserChannelId(ConnectionActivationStateConnectionFinalization* self); - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateConnectionFinalization_get_desktop_size", ExactSpelling = true)] public static unsafe extern DesktopSize* GetDesktopSize(ConnectionActivationStateConnectionFinalization* self); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateFinalized.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateFinalized.cs index 21284bf899..f0977a75d0 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateFinalized.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionActivationStateFinalized.cs @@ -16,11 +16,8 @@ public partial struct ConnectionActivationStateFinalized { private const string NativeLib = "DevolutionsIronRdp"; - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateFinalized_get_io_channel_id", ExactSpelling = true)] - public static unsafe extern ushort GetIoChannelId(ConnectionActivationStateFinalized* self); - - [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateFinalized_get_user_channel_id", ExactSpelling = true)] - public static unsafe extern ushort GetUserChannelId(ConnectionActivationStateFinalized* self); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateFinalized_get_share_id", ExactSpelling = true)] + public static unsafe extern uint GetShareId(ConnectionActivationStateFinalized* self); [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionActivationStateFinalized_get_desktop_size", ExactSpelling = true)] public static unsafe extern DesktopSize* GetDesktopSize(ConnectionActivationStateFinalized* self); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionResult.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionResult.cs index 9ee015aa56..ad4554e51c 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionResult.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectionResult.cs @@ -22,6 +22,9 @@ public partial struct ConnectionResult [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionResult_get_user_channel_id", ExactSpelling = true)] public static unsafe extern ConnectorResultFfiResultU16BoxIronRdpError GetUserChannelId(ConnectionResult* self); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionResult_get_share_id", ExactSpelling = true)] + public static unsafe extern ConnectorResultFfiResultU32BoxIronRdpError GetShareId(ConnectionResult* self); + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "ConnectionResult_get_desktop_size", ExactSpelling = true)] public static unsafe extern ConnectorResultFfiResultBoxDesktopSizeBoxIronRdpError GetDesktopSize(ConnectionResult* self); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectorActivationFfiResultBoxConnectionActivationStateCapabilitiesExchangeBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectorResultFfiResultU32BoxIronRdpError.cs similarity index 74% rename from ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectorActivationFfiResultBoxConnectionActivationStateCapabilitiesExchangeBoxIronRdpError.cs rename to ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectorResultFfiResultU32BoxIronRdpError.cs index 1c3bda1f54..90ced600d4 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectorActivationFfiResultBoxConnectionActivationStateCapabilitiesExchangeBoxIronRdpError.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawConnectorResultFfiResultU32BoxIronRdpError.cs @@ -12,13 +12,13 @@ namespace Devolutions.IronRdp.Raw; #nullable enable [StructLayout(LayoutKind.Sequential)] -public partial struct ConnectorActivationFfiResultBoxConnectionActivationStateCapabilitiesExchangeBoxIronRdpError +public partial struct ConnectorResultFfiResultU32BoxIronRdpError { [StructLayout(LayoutKind.Explicit)] private unsafe struct InnerUnion { [FieldOffset(0)] - internal ConnectionActivationStateCapabilitiesExchange* ok; + internal uint ok; [FieldOffset(0)] internal IronRdpError* err; } @@ -28,7 +28,7 @@ private unsafe struct InnerUnion [MarshalAs(UnmanagedType.U1)] public bool isOk; - public unsafe ConnectionActivationStateCapabilitiesExchange* Ok + public unsafe uint Ok { get { diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcDvcPipeProxyMessageQueueFfiResultBoxDvcPipeProxyMessageBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcDvcPipeProxyMessageQueueFfiResultBoxDvcPipeProxyMessageBoxIronRdpError.cs new file mode 100644 index 0000000000..cb8d17407f --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcDvcPipeProxyMessageQueueFfiResultBoxDvcPipeProxyMessageBoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct DvcDvcPipeProxyMessageQueueFfiResultBoxDvcPipeProxyMessageBoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal DvcPipeProxyMessage* ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe DvcPipeProxyMessage* Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcDvcPipeProxyMessageQueueFfiResultOptBoxDvcPipeProxyMessageBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcDvcPipeProxyMessageQueueFfiResultOptBoxDvcPipeProxyMessageBoxIronRdpError.cs new file mode 100644 index 0000000000..070066774a --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcDvcPipeProxyMessageQueueFfiResultOptBoxDvcPipeProxyMessageBoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct DvcDvcPipeProxyMessageQueueFfiResultOptBoxDvcPipeProxyMessageBoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal DvcPipeProxyMessage* ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe DvcPipeProxyMessage* Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyConfig.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyConfig.cs new file mode 100644 index 0000000000..81d4ee18a0 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyConfig.cs @@ -0,0 +1,30 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct DvcPipeProxyConfig +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyConfig_new", ExactSpelling = true)] + public static unsafe extern DvcPipeProxyConfig* New(DvcPipeProxyMessageSink* messageSink); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyConfig_add_pipe_proxy", ExactSpelling = true)] + public static unsafe extern void AddPipeProxy(DvcPipeProxyConfig* self, DvcPipeProxyDescriptor* descriptor); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyConfig_get_message_sink", ExactSpelling = true)] + public static unsafe extern DvcPipeProxyMessageSink* GetMessageSink(DvcPipeProxyConfig* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyConfig_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(DvcPipeProxyConfig* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyDescriptor.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyDescriptor.cs new file mode 100644 index 0000000000..e0f8d76f4f --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyDescriptor.cs @@ -0,0 +1,24 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct DvcPipeProxyDescriptor +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyDescriptor_new", ExactSpelling = true)] + public static unsafe extern DvcPipeProxyDescriptor* New(byte* channelName, nuint channelNameSz, byte* pipeName, nuint pipeNameSz); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyDescriptor_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(DvcPipeProxyDescriptor* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessage.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessage.cs new file mode 100644 index 0000000000..9d155a70cd --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessage.cs @@ -0,0 +1,24 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct DvcPipeProxyMessage +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessage_get_channel_id", ExactSpelling = true)] + public static unsafe extern uint GetChannelId(DvcPipeProxyMessage* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessage_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(DvcPipeProxyMessage* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessageQueue.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessageQueue.cs new file mode 100644 index 0000000000..f31d991017 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessageQueue.cs @@ -0,0 +1,33 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct DvcPipeProxyMessageQueue +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessageQueue_new", ExactSpelling = true)] + public static unsafe extern DvcPipeProxyMessageQueue* New(uint queueSize); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessageQueue_next_message", ExactSpelling = true)] + public static unsafe extern DvcDvcPipeProxyMessageQueueFfiResultOptBoxDvcPipeProxyMessageBoxIronRdpError NextMessage(DvcPipeProxyMessageQueue* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessageQueue_next_message_blocking", ExactSpelling = true)] + public static unsafe extern DvcDvcPipeProxyMessageQueueFfiResultBoxDvcPipeProxyMessageBoxIronRdpError NextMessageBlocking(DvcPipeProxyMessageQueue* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessageQueue_get_sink", ExactSpelling = true)] + public static unsafe extern DvcPipeProxyMessageSink* GetSink(DvcPipeProxyMessageQueue* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessageQueue_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(DvcPipeProxyMessageQueue* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessageSink.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessageSink.cs new file mode 100644 index 0000000000..2819ce1570 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawDvcPipeProxyMessageSink.cs @@ -0,0 +1,21 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct DvcPipeProxyMessageSink +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "DvcPipeProxyMessageSink_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(DvcPipeProxyMessageSink* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawFfiFileContentsRequest.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawFfiFileContentsRequest.cs new file mode 100644 index 0000000000..64b3f9ba93 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawFfiFileContentsRequest.cs @@ -0,0 +1,48 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct FfiFileContentsRequest +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "FfiFileContentsRequest_stream_id", ExactSpelling = true)] + public static unsafe extern uint StreamId(FfiFileContentsRequest* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "FfiFileContentsRequest_index", ExactSpelling = true)] + public static unsafe extern int Index(FfiFileContentsRequest* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "FfiFileContentsRequest_is_size_request", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.U1)] + public static unsafe extern bool IsSizeRequest(FfiFileContentsRequest* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "FfiFileContentsRequest_is_range_request", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.U1)] + public static unsafe extern bool IsRangeRequest(FfiFileContentsRequest* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "FfiFileContentsRequest_position", ExactSpelling = true)] + public static unsafe extern ulong Position(FfiFileContentsRequest* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "FfiFileContentsRequest_requested_size", ExactSpelling = true)] + public static unsafe extern uint RequestedSize(FfiFileContentsRequest* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "FfiFileContentsRequest_has_data_id", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.U1)] + public static unsafe extern bool HasDataId(FfiFileContentsRequest* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "FfiFileContentsRequest_data_id", ExactSpelling = true)] + public static unsafe extern ClipboardMessageFfiResultU32BoxIronRdpError DataId(FfiFileContentsRequest* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "FfiFileContentsRequest_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(FfiFileContentsRequest* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawFfiFileContentsResponse.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawFfiFileContentsResponse.cs new file mode 100644 index 0000000000..96ec8513f5 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawFfiFileContentsResponse.cs @@ -0,0 +1,35 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +/// +/// Wraps `OwnedFileContentsResponse`, which is a type alias for +/// `FileContentsResponse<'static>` (generated by `impl_pdu_borrowing!`). +/// +[StructLayout(LayoutKind.Sequential)] +public partial struct FfiFileContentsResponse +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "FfiFileContentsResponse_stream_id", ExactSpelling = true)] + public static unsafe extern uint StreamId(FfiFileContentsResponse* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "FfiFileContentsResponse_is_error", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.U1)] + public static unsafe extern bool IsError(FfiFileContentsResponse* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "FfiFileContentsResponse_data", ExactSpelling = true)] + public static unsafe extern VecU8* Data(FfiFileContentsResponse* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "FfiFileContentsResponse_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(FfiFileContentsResponse* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawLog.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawLog.cs index e1f649b99d..4048270241 100644 --- a/ffi/dotnet/Devolutions.IronRdp/Generated/RawLog.cs +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawLog.cs @@ -16,6 +16,13 @@ public partial struct Log { private const string NativeLib = "DevolutionsIronRdp"; + /// + /// # Panics + /// + /// + /// - Panics if log directory creation fails. + /// - Panics if tracing initialization fails. + /// [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "Log_init_with_env", ExactSpelling = true)] public static unsafe extern void InitWithEnv(); diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawMultitransportRequest.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawMultitransportRequest.cs new file mode 100644 index 0000000000..d18a6b4b31 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawMultitransportRequest.cs @@ -0,0 +1,22 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct MultitransportRequest +{ + private const string NativeLib = "DevolutionsIronRdp"; + + public uint request_id; + + public ushort requested_protocol; +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawNetworkCharacteristics.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawNetworkCharacteristics.cs new file mode 100644 index 0000000000..761e1552e4 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawNetworkCharacteristics.cs @@ -0,0 +1,44 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +/// +/// Connection quality measurements from server auto-detect (MS-RDPBCGR 2.2.14). +/// +[StructLayout(LayoutKind.Sequential)] +public partial struct NetworkCharacteristics +{ + private const string NativeLib = "DevolutionsIronRdp"; + + /// + /// Lowest detected round-trip time in milliseconds. + /// Only valid when `has_base_rtt` is true. + /// + public uint base_rtt_ms; + + [MarshalAs(UnmanagedType.U1)] + public bool has_base_rtt; + + /// + /// Current average round-trip time in milliseconds. + /// + public uint average_rtt_ms; + + /// + /// Estimated bandwidth in kilobits per second. + /// Only valid when `has_bandwidth` is true. + /// + public uint bandwidth_kbps; + + [MarshalAs(UnmanagedType.U1)] + public bool has_bandwidth; +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathDetectionResult.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathDetectionResult.cs new file mode 100644 index 0000000000..a5f7d88d0b --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathDetectionResult.cs @@ -0,0 +1,36 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RDCleanPathDetectionResult +{ + private const string NativeLib = "DevolutionsIronRdp"; + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathDetectionResult_is_detected", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.U1)] + public static unsafe extern bool IsDetected(RDCleanPathDetectionResult* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathDetectionResult_is_not_enough_bytes", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.U1)] + public static unsafe extern bool IsNotEnoughBytes(RDCleanPathDetectionResult* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathDetectionResult_is_failed", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.U1)] + public static unsafe extern bool IsFailed(RDCleanPathDetectionResult* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathDetectionResult_get_total_length", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultUsizeBoxIronRdpError GetTotalLength(RDCleanPathDetectionResult* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathDetectionResult_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(RDCleanPathDetectionResult* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathPdu.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathPdu.cs new file mode 100644 index 0000000000..5710356a1d --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathPdu.cs @@ -0,0 +1,96 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RDCleanPathPdu +{ + private const string NativeLib = "DevolutionsIronRdp"; + + /// + /// Creates a new RDCleanPath request PDU + /// + /// + /// # Arguments + /// * `x224_pdu` - The X.224 Connection Request PDU bytes + /// * `destination` - The destination RDP server address (e.g., "10.10.0.3:3389") + /// * `proxy_auth` - The JWT authentication token + /// * `pcb` - Optional preconnection blob (for Hyper-V VM connections, empty string if not needed) + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_new_request", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError NewRequest(byte* x224Pdu, nuint x224PduSz, byte* destination, nuint destinationSz, byte* proxyAuth, nuint proxyAuthSz, byte* pcb, nuint pcbSz); + + /// + /// Decodes a RDCleanPath PDU from DER-encoded bytes + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_from_der", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError FromDer(byte* bytes, nuint bytesSz); + + /// + /// Encodes the RDCleanPath PDU to DER-encoded bytes + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_to_der", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultBoxVecU8BoxIronRdpError ToDer(RDCleanPathPdu* self); + + /// + /// Detects if the bytes contain a valid RDCleanPath PDU and returns detection result + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_detect", ExactSpelling = true)] + public static unsafe extern RDCleanPathDetectionResult* Detect(byte* bytes, nuint bytesSz); + + /// + /// Gets the type of this RDCleanPath PDU + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_get_type", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultRDCleanPathResultTypeBoxIronRdpError GetType(RDCleanPathPdu* self); + + /// + /// Gets the X.224 connection response bytes (for Response or NegotiationError variants) + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_get_x224_response", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultBoxVecU8BoxIronRdpError GetX224Response(RDCleanPathPdu* self); + + /// + /// Gets the server certificate chain (for Response variant) + /// Returns a vector iterator of certificate bytes + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_get_server_cert_chain", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultBoxCertificateChainIteratorBoxIronRdpError GetServerCertChain(RDCleanPathPdu* self); + + /// + /// Gets the server address string (for Response variant) + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_get_server_addr", ExactSpelling = true)] + public static unsafe extern void GetServerAddr(RDCleanPathPdu* self, DiplomatWriteable* writeable); + + /// + /// Gets error message (for GeneralError variant) + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_get_error_message", ExactSpelling = true)] + public static unsafe extern void GetErrorMessage(RDCleanPathPdu* self, DiplomatWriteable* writeable); + + /// + /// Gets the error code (for GeneralError variant) + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_get_error_code", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultU16BoxIronRdpError GetErrorCode(RDCleanPathPdu* self); + + /// + /// Gets the HTTP status code if present (for GeneralError variant) + /// Returns error if not present or not a GeneralError variant + /// + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_get_http_status_code", ExactSpelling = true)] + public static unsafe extern RdcleanpathFfiResultU16BoxIronRdpError GetHttpStatusCode(RDCleanPathPdu* self); + + [DllImport(NativeLib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "RDCleanPathPdu_destroy", ExactSpelling = true)] + public static unsafe extern void Destroy(RDCleanPathPdu* self); +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathResultType.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathResultType.cs new file mode 100644 index 0000000000..393d65092d --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRDCleanPathResultType.cs @@ -0,0 +1,20 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +public enum RDCleanPathResultType +{ + Request = 0, + Response = 1, + GeneralError = 2, + NegotiationError = 3, +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxCertificateChainIteratorBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxCertificateChainIteratorBoxIronRdpError.cs new file mode 100644 index 0000000000..01cb35908f --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxCertificateChainIteratorBoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RdcleanpathFfiResultBoxCertificateChainIteratorBoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal CertificateChainIterator* ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe CertificateChainIterator* Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError.cs new file mode 100644 index 0000000000..c9f33a5dad --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RdcleanpathFfiResultBoxRDCleanPathPduBoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal RDCleanPathPdu* ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe RDCleanPathPdu* Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxVecU8BoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxVecU8BoxIronRdpError.cs new file mode 100644 index 0000000000..7cce2e6ad1 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultBoxVecU8BoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RdcleanpathFfiResultBoxVecU8BoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal VecU8* ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe VecU8* Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultRDCleanPathResultTypeBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultRDCleanPathResultTypeBoxIronRdpError.cs new file mode 100644 index 0000000000..6a9c6e8a93 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultRDCleanPathResultTypeBoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RdcleanpathFfiResultRDCleanPathResultTypeBoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal RDCleanPathResultType ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe RDCleanPathResultType Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultU16BoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultU16BoxIronRdpError.cs new file mode 100644 index 0000000000..3eaf32007e --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultU16BoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RdcleanpathFfiResultU16BoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal ushort ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe ushort Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultUsizeBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultUsizeBoxIronRdpError.cs new file mode 100644 index 0000000000..eecbdaea4c --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawRdcleanpathFfiResultUsizeBoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct RdcleanpathFfiResultUsizeBoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal nuint ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe nuint Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawSessionFfiResultMultitransportRequestBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawSessionFfiResultMultitransportRequestBoxIronRdpError.cs new file mode 100644 index 0000000000..25d48a0288 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawSessionFfiResultMultitransportRequestBoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct SessionFfiResultMultitransportRequestBoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal MultitransportRequest ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe MultitransportRequest Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/Generated/RawSessionFfiResultNetworkCharacteristicsBoxIronRdpError.cs b/ffi/dotnet/Devolutions.IronRdp/Generated/RawSessionFfiResultNetworkCharacteristicsBoxIronRdpError.cs new file mode 100644 index 0000000000..a6adfa93f2 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/Generated/RawSessionFfiResultNetworkCharacteristicsBoxIronRdpError.cs @@ -0,0 +1,46 @@ +// by Diplomat + +#pragma warning disable 0105 +using System; +using System.Runtime.InteropServices; + +using Devolutions.IronRdp.Diplomat; +#pragma warning restore 0105 + +namespace Devolutions.IronRdp.Raw; + +#nullable enable + +[StructLayout(LayoutKind.Sequential)] +public partial struct SessionFfiResultNetworkCharacteristicsBoxIronRdpError +{ + [StructLayout(LayoutKind.Explicit)] + private unsafe struct InnerUnion + { + [FieldOffset(0)] + internal NetworkCharacteristics ok; + [FieldOffset(0)] + internal IronRdpError* err; + } + + private InnerUnion _inner; + + [MarshalAs(UnmanagedType.U1)] + public bool isOk; + + public unsafe NetworkCharacteristics Ok + { + get + { + return _inner.ok; + } + } + + public unsafe IronRdpError* Err + { + get + { + return _inner.err; + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/src/Connection.cs b/ffi/dotnet/Devolutions.IronRdp/src/Connection.cs index ed638b9342..497e70ab2b 100644 --- a/ffi/dotnet/Devolutions.IronRdp/src/Connection.cs +++ b/ffi/dotnet/Devolutions.IronRdp/src/Connection.cs @@ -11,23 +11,17 @@ public static class Connection { var client = await CreateTcpConnection(serverName, port); string clientAddr = client.Client.LocalEndPoint.ToString(); - Console.WriteLine(clientAddr); + System.Diagnostics.Debug.WriteLine(clientAddr); var framed = new Framed(client.GetStream()); var connector = ClientConnector.New(config, clientAddr); - connector.WithDynamicChannelDisplayControl(); - - if (factory != null) - { - var cliprdr = factory.BuildCliprdr(); - connector.AttachStaticCliprdr(cliprdr); - } + ConnectionHelpers.SetupConnector(connector, config, factory); await ConnectBegin(framed, connector); var (serverPublicKey, framedSsl) = await SecurityUpgrade(framed, connector); - var result = await ConnectFinalize(serverName, connector, serverPublicKey, framedSsl); + var result = await ConnectionHelpers.ConnectFinalize(serverName, connector, serverPublicKey, framedSsl); return (result, framedSsl); } @@ -62,78 +56,11 @@ private static async Task ConnectBegin(Framed framed, ClientConne } } - - private static async Task ConnectFinalize(string serverName, ClientConnector connector, - byte[] serverPubKey, Framed framedSsl) - { - var writeBuf2 = WriteBuf.New(); - if (connector.ShouldPerformCredssp()) - { - await PerformCredsspSteps(connector, serverName, writeBuf2, framedSsl, serverPubKey); - } - - while (!connector.GetDynState().IsTerminal()) - { - await SingleSequenceStep(connector, writeBuf2, framedSsl); - } - - ClientConnectorState state = connector.ConsumeAndCastToClientConnectorState(); - - if (state.GetEnumType() == ClientConnectorStateType.Connected) - { - return state.GetConnectedResult(); - } - else - { - throw new IronRdpLibException(IronRdpLibExceptionType.ConnectionFailed, "Connection failed"); - } - } - - private static async Task PerformCredsspSteps(ClientConnector connector, string serverName, WriteBuf writeBuf, - Framed framedSsl, byte[] serverpubkey) - { - var credsspSequenceInitResult = CredsspSequence.Init(connector, serverName, serverpubkey, null); - var credsspSequence = credsspSequenceInitResult.GetCredsspSequence(); - var tsRequest = credsspSequenceInitResult.GetTsRequest(); - var tcpClient = new TcpClient(); - while (true) - { - var generator = credsspSequence.ProcessTsRequest(tsRequest); - var clientState = await ResolveGenerator(generator, tcpClient); - writeBuf.Clear(); - var written = credsspSequence.HandleProcessResult(clientState, writeBuf); - - if (written.GetSize().IsSome()) - { - var actualSize = (int)written.GetSize().Get(); - var response = new byte[actualSize]; - writeBuf.ReadIntoBuf(response); - await framedSsl.Write(response); - } - - var pduHint = credsspSequence.NextPduHint(); - if (pduHint == null) - { - break; - } - - var pdu = await framedSsl.ReadByHint(pduHint); - var decoded = credsspSequence.DecodeServerMessage(pdu); - - // Don't remove, DecodeServerMessage is generated, and it can return null - if (null == decoded) - { - break; - } - - tsRequest = decoded; - } - } - - private static async Task ResolveGenerator(CredsspProcessGenerator generator, TcpClient tcpClient) + internal static async Task ResolveGenerator(CredsspProcessGenerator generator, TcpClient tcpClient) { var state = generator.Start(); NetworkStream? stream = null; + while (true) { if (state.IsSuspended()) @@ -142,16 +69,17 @@ private static async Task ResolveGenerator(CredsspProcessGenerator var protocol = request.GetProtocol(); var url = request.GetUrl(); var data = request.GetData(); - if (null == stream) - { - url = url.Replace("tcp://", ""); - var split = url.Split(":"); - await tcpClient.ConnectAsync(split[0], int.Parse(split[1])); - stream = tcpClient.GetStream(); - } if (protocol == NetworkRequestProtocol.Tcp) { + if (null == stream) + { + url = url.Replace("tcp://", ""); + var split = url.Split(":"); + await tcpClient.ConnectAsync(split[0], int.Parse(split[1])); + stream = tcpClient.GetStream(); + } + stream.Write(Utils.VecU8ToByte(data)); var readBuf = new byte[8096]; var readlen = await stream.ReadAsync(readBuf, 0, readBuf.Length); @@ -161,13 +89,29 @@ private static async Task ResolveGenerator(CredsspProcessGenerator } else { - throw new Exception("Unimplemented protocol"); + throw new Exception($"Unimplemented protocol: {protocol}"); + } + } + else if (state.IsCompleted()) + { + try + { + var clientState = state.GetClientStateIfCompleted(); + return clientState; + } + catch (IronRdpException ex) + { + System.Diagnostics.Debug.WriteLine($"[ResolveGenerator] Error getting client state: {ex.Message}"); + System.Diagnostics.Debug.WriteLine($"[ResolveGenerator] Error kind: {ex.Inner?.Kind}"); + System.Diagnostics.Debug.WriteLine($"[ResolveGenerator] Stack trace: {ex.StackTrace}"); + throw; } } else { - var clientState = state.GetClientStateIfCompleted(); - return clientState; + var errorMsg = $"[ResolveGenerator] Generator state is neither suspended nor completed. IsSuspended={state.IsSuspended()}, IsCompleted={state.IsCompleted()}"; + System.Diagnostics.Debug.WriteLine(errorMsg); + throw new InvalidOperationException(errorMsg); } } } diff --git a/ffi/dotnet/Devolutions.IronRdp/src/ConnectionHelpers.cs b/ffi/dotnet/Devolutions.IronRdp/src/ConnectionHelpers.cs new file mode 100644 index 0000000000..d57aaaca58 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/src/ConnectionHelpers.cs @@ -0,0 +1,121 @@ +using System.Net.Sockets; + +namespace Devolutions.IronRdp; + +/// +/// Internal helper class providing shared connection logic for both direct and RDCleanPath connections. +/// +internal static class ConnectionHelpers +{ + /// + /// Sets up common connector configuration including dynamic channels and clipboard. + /// + internal static void SetupConnector(ClientConnector connector, Config config, CliprdrBackendFactory? factory) + { + connector.WithDynamicChannelDisplayControl(); + + var dvcPipeProxy = config.DvcPipeProxy; + if (dvcPipeProxy != null) + { + connector.WithDynamicChannelPipeProxy(dvcPipeProxy); + } + + if (factory != null) + { + var cliprdr = factory.BuildCliprdr(); + connector.AttachStaticCliprdr(cliprdr); + } + } + + /// + /// Performs CredSSP authentication steps over any stream type. + /// + internal static async Task PerformCredsspSteps( + ClientConnector connector, + string serverName, + WriteBuf writeBuf, + Framed framed, + byte[] serverpubkey) where T : Stream + { + // Extract hostname from "hostname:port" format if needed + // CredSSP needs just the hostname for the service principal name (TERMSRV/hostname) + var hostname = serverName; + var colonIndex = serverName.IndexOf(':'); + if (colonIndex > 0) + { + hostname = serverName.Substring(0, colonIndex); + } + + var credsspSequenceInitResult = CredsspSequence.Init(connector, hostname, serverpubkey, null); + var credsspSequence = credsspSequenceInitResult.GetCredsspSequence(); + var tsRequest = credsspSequenceInitResult.GetTsRequest(); + var tcpClient = new TcpClient(); + + while (true) + { + var generator = credsspSequence.ProcessTsRequest(tsRequest); + var clientState = await Connection.ResolveGenerator(generator, tcpClient); + writeBuf.Clear(); + var written = credsspSequence.HandleProcessResult(clientState, writeBuf); + + if (written.GetSize().IsSome()) + { + var actualSize = (int)written.GetSize().Get(); + var response = new byte[actualSize]; + writeBuf.ReadIntoBuf(response); + await framed.Write(response); + } + + var pduHint = credsspSequence.NextPduHint(); + if (pduHint == null) + { + break; + } + + var pdu = await framed.ReadByHint(pduHint); + var decoded = credsspSequence.DecodeServerMessage(pdu); + + // Don't remove, DecodeServerMessage is generated, and it can return null + if (null == decoded) + { + break; + } + + tsRequest = decoded; + } + } + + /// + /// Finalizes the RDP connection after security upgrade, performing CredSSP if needed + /// and completing the connection sequence. + /// + internal static async Task ConnectFinalize( + string serverName, + ClientConnector connector, + byte[] serverPubKey, + Framed framedSsl) where T : Stream + { + var writeBuf = WriteBuf.New(); + + if (connector.ShouldPerformCredssp()) + { + await PerformCredsspSteps(connector, serverName, writeBuf, framedSsl, serverPubKey); + } + + while (!connector.GetDynState().IsTerminal()) + { + await Connection.SingleSequenceStep(connector, writeBuf, framedSsl); + } + + ClientConnectorState state = connector.ConsumeAndCastToClientConnectorState(); + + if (state.GetEnumType() == ClientConnectorStateType.Connected) + { + return state.GetConnectedResult(); + } + else + { + throw new IronRdpLibException(IronRdpLibExceptionType.ConnectionFailed, "Connection failed"); + } + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/src/Framed.cs b/ffi/dotnet/Devolutions.IronRdp/src/Framed.cs index f03530f3d7..2e8772e794 100644 --- a/ffi/dotnet/Devolutions.IronRdp/src/Framed.cs +++ b/ffi/dotnet/Devolutions.IronRdp/src/Framed.cs @@ -134,4 +134,45 @@ public async Task ReadByHint(PduHint pduHint) } } } + + /// + /// Reads data from the buffer based on a custom PDU hint function. + /// + /// A custom hint object implementing IPduHint interface. + /// An asynchronous task that represents the operation. The task result contains the read data as a byte array. + public async Task ReadByHint(IPduHint customHint) + { + while (true) + { + var result = customHint.FindSize(this._buffer.ToArray()); + if (result.HasValue) + { + return await this.ReadExact((nuint)result.Value.Item2); + } + else + { + var len = await this.Read(); + if (len == 0) + { + throw new Exception("EOF"); + } + } + } + } +} + +/// +/// Interface for custom PDU hint implementations. +/// +public interface IPduHint +{ + /// + /// Finds the size of a PDU in the given byte array. + /// + /// The byte array to analyze. + /// + /// A tuple (detected, size) if PDU is detected, null if more bytes are needed. + /// Throws exception if invalid PDU is detected. + /// + (bool, int)? FindSize(byte[] bytes); } \ No newline at end of file diff --git a/ffi/dotnet/Devolutions.IronRdp/src/RDCleanPathConnection.cs b/ffi/dotnet/Devolutions.IronRdp/src/RDCleanPathConnection.cs new file mode 100644 index 0000000000..725c0525f6 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/src/RDCleanPathConnection.cs @@ -0,0 +1,200 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace Devolutions.IronRdp; + +/// +/// Provides methods for connecting to RDP servers through an RDCleanPath-compatible gateway +/// (such as Devolutions Gateway or Cloudflare) using WebSocket. +/// +public static class RDCleanPathConnection +{ + /// + /// Connects to an RDP server through an RDCleanPath-compatible gateway using WebSocket. + /// + /// The RDP connection configuration + /// The WebSocket URL to the RDCleanPath gateway (e.g., "ws://localhost:7171/jet/rdp") + /// The JWT authentication token for the RDCleanPath gateway + /// The destination RDP server address (e.g., "10.10.0.3:3389") + /// Optional preconnection blob for Hyper-V VM connections + /// Optional clipboard backend factory + /// A tuple containing the connection result and framed WebSocket stream + public static async Task<(ConnectionResult, Framed)> ConnectRDCleanPath( + Config config, + string gatewayUrl, + string authToken, + string destination, + string? pcb = null, + CliprdrBackendFactory? factory = null) + { + // Step 1: Connect WebSocket to gateway + System.Diagnostics.Debug.WriteLine($"Connecting to gateway at {gatewayUrl}..."); + var ws = await WebSocketStream.ConnectAsync(new Uri(gatewayUrl)); + var framed = new Framed(ws); + + // Step 2: Get client local address from the WebSocket connection + // This mimics Rust: let client_addr = socket.local_addr()?; + string clientAddr = ws.ClientAddr; + System.Diagnostics.Debug.WriteLine($"Client local address: {clientAddr}"); + + // Step 3: Setup ClientConnector + var connector = ClientConnector.New(config, clientAddr); + ConnectionHelpers.SetupConnector(connector, config, factory); + + // Step 4: Perform RDCleanPath handshake + System.Diagnostics.Debug.WriteLine("Performing RDCleanPath handshake..."); + var (serverPublicKey, framedAfterHandshake) = await ConnectRdCleanPath( + framed, connector, destination, authToken, pcb ?? ""); + + // Step 5: Mark security upgrade as done (WebSocket already has TLS) + connector.MarkSecurityUpgradeAsDone(); + + // Step 6: Finalize connection + System.Diagnostics.Debug.WriteLine("Finalizing RDP connection..."); + var result = await ConnectionHelpers.ConnectFinalize(destination, connector, serverPublicKey, framedAfterHandshake); + + System.Diagnostics.Debug.WriteLine("Gateway connection established successfully!"); + return (result, framedAfterHandshake); + } + + /// + /// Performs the RDCleanPath handshake with the RDCleanPath-compatible gateway. + /// + private static async Task<(byte[], Framed)> ConnectRdCleanPath( + Framed framed, + ClientConnector connector, + string destination, + string authToken, + string pcb) + { + var writeBuf = WriteBuf.New(); + + // Step 1: Generate X.224 Connection Request + System.Diagnostics.Debug.WriteLine("Generating X.224 Connection Request..."); + var written = connector.StepNoInput(writeBuf); + var x224PduSize = (int)written.GetSize().Get(); + var x224Pdu = new byte[x224PduSize]; + writeBuf.ReadIntoBuf(x224Pdu); + + // Step 2: Create and send RDCleanPath Request + System.Diagnostics.Debug.WriteLine($"Sending RDCleanPath request to {destination}..."); + var rdCleanPathReq = RDCleanPathPdu.NewRequest(x224Pdu, destination, authToken, pcb); + var reqBytes = rdCleanPathReq.ToDer(); + var reqBytesArray = new byte[reqBytes.GetSize()]; + reqBytes.Fill(reqBytesArray); + await framed.Write(reqBytesArray); + + // Step 3: Read RDCleanPath Response + System.Diagnostics.Debug.WriteLine("Waiting for RDCleanPath response..."); + var respBytes = await framed.ReadByHint(new RDCleanPathHint()); + var rdCleanPathResp = RDCleanPathPdu.FromDer(respBytes); + + // Step 4: Determine response type and handle accordingly + var resultType = rdCleanPathResp.GetType(); + + if (resultType == RDCleanPathResultType.Response) + { + System.Diagnostics.Debug.WriteLine("RDCleanPath handshake successful!"); + + // Extract X.224 response + var x224Response = rdCleanPathResp.GetX224Response(); + var x224ResponseBytes = new byte[x224Response.GetSize()]; + x224Response.Fill(x224ResponseBytes); + + // Process X.224 response with connector + writeBuf.Clear(); + connector.Step(x224ResponseBytes, writeBuf); + + // Extract server public key from certificate chain + var certChain = rdCleanPathResp.GetServerCertChain(); + if (certChain.IsEmpty()) + { + throw new IronRdpLibException( + IronRdpLibExceptionType.ConnectionFailed, + "Server certificate chain is empty"); + } + + var firstCert = certChain.Next(); + if (firstCert == null) + { + throw new IronRdpLibException( + IronRdpLibExceptionType.ConnectionFailed, + "Failed to get first certificate from chain"); + } + + var certBytes = new byte[firstCert.GetSize()]; + firstCert.Fill(certBytes); + + var serverPublicKey = ExtractPublicKeyFromX509(certBytes); + + System.Diagnostics.Debug.WriteLine($"Extracted server public key (length: {serverPublicKey.Length})"); + + return (serverPublicKey, framed); + } + else if (resultType == RDCleanPathResultType.GeneralError) + { + var errorCode = rdCleanPathResp.GetErrorCode(); + var errorMessage = rdCleanPathResp.GetErrorMessage(); + throw new IronRdpLibException( + IronRdpLibExceptionType.ConnectionFailed, + $"RDCleanPath error (code {errorCode}): {errorMessage}"); + } + else if (resultType == RDCleanPathResultType.NegotiationError) + { + throw new IronRdpLibException( + IronRdpLibExceptionType.ConnectionFailed, + "RDCleanPath negotiation error: Server rejected connection parameters"); + } + else + { + throw new IronRdpLibException( + IronRdpLibExceptionType.ConnectionFailed, + $"Unexpected RDCleanPath response type: {resultType}"); + } + } + + /// + /// Extracts the public key from an X.509 certificate in DER format. + /// + private static byte[] ExtractPublicKeyFromX509(byte[] certDer) + { + try + { + var cert = new X509Certificate2(certDer); + return cert.GetPublicKey(); + } + catch (Exception ex) + { + throw new IronRdpLibException( + IronRdpLibExceptionType.ConnectionFailed, + $"Failed to extract public key from certificate: {ex.Message}"); + } + } +} + +/// +/// PDU hint for detecting RDCleanPath PDUs in the stream. +/// +public class RDCleanPathHint : IPduHint +{ + public (bool, int)? FindSize(byte[] bytes) + { + var detection = RDCleanPathPdu.Detect(bytes); + + if (detection.IsDetected()) + { + var totalLength = (int)detection.GetTotalLength(); + return (true, totalLength); + } + + if (detection.IsNotEnoughBytes()) + { + return null; // Need more bytes + } + + // Detection failed + throw new IronRdpLibException( + IronRdpLibExceptionType.ConnectionFailed, + "Invalid RDCleanPath PDU detected"); + } +} diff --git a/ffi/dotnet/Devolutions.IronRdp/src/WebsocketStream.cs b/ffi/dotnet/Devolutions.IronRdp/src/WebsocketStream.cs new file mode 100644 index 0000000000..5f52810118 --- /dev/null +++ b/ffi/dotnet/Devolutions.IronRdp/src/WebsocketStream.cs @@ -0,0 +1,212 @@ +using System; +using System.Buffers; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +public sealed class WebSocketStream : Stream +{ + private readonly ClientWebSocket _ws; + private readonly byte[] _recvBuf; + private int _recvPos; + private int _recvLen; + private bool _remoteClosed; + private bool _disposed; + private readonly string _clientAddr; + + private const int DefaultRecvBufferSize = 64 * 1024; + private const int MaxSendFrame = 16 * 1024; // send in chunks + + private WebSocketStream(ClientWebSocket ws, int receiveBufferSize, string clientAddr) + { + _ws = ws ?? throw new ArgumentNullException(nameof(ws)); + _recvBuf = ArrayPool.Shared.Rent(Math.Max(1024, receiveBufferSize)); + _clientAddr = clientAddr; + } + + public static async Task ConnectAsync( + Uri uri, + ClientWebSocket? ws = null, + int receiveBufferSize = DefaultRecvBufferSize, + CancellationToken ct = default) + { + // Capture the local endpoint from the socket using SocketsHttpHandler.ConnectCallback + // This follows the Rust approach: socket.local_addr() + IPEndPoint? localEndPoint = null; + + var handler = new SocketsHttpHandler + { + ConnectCallback = async (context, cancellationToken) => + { + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); + + // Set TCP_NODELAY (matching Rust: socket.set_nodelay(true)) + socket.NoDelay = true; + + // Connect to the endpoint + await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false); + + // Capture the local endpoint after connection + localEndPoint = socket.LocalEndPoint as IPEndPoint; + + return new NetworkStream(socket, ownsSocket: true); + } + }; + + var invoker = new HttpMessageInvoker(handler); + + ws ??= new ClientWebSocket(); + await ws.ConnectAsync(uri, invoker, ct).ConfigureAwait(false); + + string clientAddr = localEndPoint?.ToString() ?? "127.0.0.1:0"; + + return new WebSocketStream(ws, receiveBufferSize, clientAddr); + } + + public ClientWebSocket Socket => _ws; + + /// + /// Gets the local client address in "IP:port" format. + /// This is the address that was determined when establishing the TCP connection. + /// + public string ClientAddr => _clientAddr; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + public override void Flush() { /* no-op */ } + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public override int Read(byte[] buffer, int offset, int count) => + ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override void Write(byte[] buffer, int offset, int count) => + WriteAsync(buffer.AsMemory(offset, count)).GetAwaiter().GetResult(); + + public override async ValueTask ReadAsync( + Memory destination, CancellationToken cancellationToken = default) + { + if (_disposed) throw new ObjectDisposedException(nameof(WebSocketStream)); + if (_remoteClosed) return 0; + if (destination.Length == 0) return 0; + + // Fill local buffer if empty + if (_recvLen == 0) + { + var mem = _recvBuf.AsMemory(); + while (true) + { + var result = await _ws.ReceiveAsync(mem, cancellationToken).ConfigureAwait(false); + + // Close frame → signal EOF + if (result.MessageType == WebSocketMessageType.Close) + { + _remoteClosed = true; + try { await _ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "OK", cancellationToken).ConfigureAwait(false); } + catch { /* ignore */ } + return 0; + } + + if (result.MessageType == WebSocketMessageType.Text) + throw new InvalidOperationException("Received TEXT frame; this stream expects BINARY."); + + // Some data arrived + if (result.Count > 0) + { + _recvPos = 0; + _recvLen = result.Count; + break; + } + + // Keep looping if Count == 0 (can happen with pings/keepers) + } + } + + var toCopy = Math.Min(destination.Length, _recvLen); + new ReadOnlySpan(_recvBuf, _recvPos, toCopy).CopyTo(destination.Span); + _recvPos += toCopy; + _recvLen -= toCopy; + + // If we've drained local buffer, try to prefetch next chunk (non-blocking behavior not guaranteed) + if (_recvLen == 0 && _ws.State == WebSocketState.Open) + { + // optional prefetch: not strictly necessary—kept simple + } + + return toCopy; + } + + public override async Task WriteAsync( + byte[] buffer, int offset, int count, CancellationToken cancellationToken) + => await WriteAsync(buffer.AsMemory(offset, count), cancellationToken); + + public override async ValueTask WriteAsync( + ReadOnlyMemory source, CancellationToken cancellationToken = default) + { + if (_disposed) throw new ObjectDisposedException(nameof(WebSocketStream)); + if (_ws.State != WebSocketState.Open) throw new IOException("WebSocket is not open."); + + // Treat each Write* as one complete WebSocket message (Binary). + // Chunk large payloads as continuation frames and set EndOfMessage on the last chunk. + int sent = 0; + while (sent < source.Length) + { + var chunkLen = Math.Min(MaxSendFrame, source.Length - sent); + var chunk = source.Slice(sent, chunkLen); + sent += chunkLen; + + bool end = (sent == source.Length); + await _ws.SendAsync(chunk, WebSocketMessageType.Binary, end, cancellationToken).ConfigureAwait(false); + } + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (_disposed) return; + if (disposing) + { + try + { + if (_ws.State == WebSocketState.Open) + { + _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disposing", CancellationToken.None) + .GetAwaiter().GetResult(); + } + } + catch { /* ignore on dispose */ } + _ws.Dispose(); + ArrayPool.Shared.Return(_recvBuf); + } + _disposed = true; + base.Dispose(disposing); + } + +#if NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER + public override async ValueTask DisposeAsync() + { + if (!_disposed) + { + try + { + if (_ws.State == WebSocketState.Open) + await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Disposing", CancellationToken.None).ConfigureAwait(false); + } + catch { /* ignore */ } + _ws.Dispose(); + ArrayPool.Shared.Return(_recvBuf); + _disposed = true; + } + await base.DisposeAsync().ConfigureAwait(false); + } +#endif +} diff --git a/ffi/src/clipboard/message.rs b/ffi/src/clipboard/message.rs index 1ab5d003bf..129fddd7ef 100644 --- a/ffi/src/clipboard/message.rs +++ b/ffi/src/clipboard/message.rs @@ -1,6 +1,9 @@ #[diplomat::bridge] pub mod ffi { + use crate::error::ffi::IronRdpError; + use crate::utils::ffi::VecU8; + #[diplomat::opaque] pub struct ClipboardMessage(pub ironrdp::cliprdr::backend::ClipboardMessage); @@ -10,10 +13,19 @@ pub mod ffi { ironrdp::cliprdr::backend::ClipboardMessage::SendInitiateCopy(_) => { ClipboardMessageType::SendInitiateCopy } + ironrdp::cliprdr::backend::ClipboardMessage::SendInitiateFileCopy(_) => { + ClipboardMessageType::SendInitiateFileCopy + } ironrdp::cliprdr::backend::ClipboardMessage::SendFormatData(_) => ClipboardMessageType::SendFormatData, ironrdp::cliprdr::backend::ClipboardMessage::SendInitiatePaste(_) => { ClipboardMessageType::SendInitiatePaste } + ironrdp::cliprdr::backend::ClipboardMessage::SendFileContentsRequest(_) => { + ClipboardMessageType::SendFileContentsRequest + } + ironrdp::cliprdr::backend::ClipboardMessage::SendFileContentsResponse(_) => { + ClipboardMessageType::SendFileContentsResponse + } ironrdp::cliprdr::backend::ClipboardMessage::Error(_) => ClipboardMessageType::Error, } } @@ -45,12 +57,43 @@ pub mod ffi { .map(ClipboardFormatId) .map(Box::new) } + + pub fn get_send_file_contents_request(&self) -> Option> { + match &self.0 { + ironrdp::cliprdr::backend::ClipboardMessage::SendFileContentsRequest(val) => Some(val.clone()), + _ => None, + } + .map(FfiFileContentsRequest) + .map(Box::new) + } + + pub fn get_send_file_contents_response(&self) -> Option> { + match &self.0 { + ironrdp::cliprdr::backend::ClipboardMessage::SendFileContentsResponse(val) => Some(val.clone()), + _ => None, + } + .map(FfiFileContentsResponse) + .map(Box::new) + } + + pub fn get_error(&self) -> Option> { + match &self.0 { + ironrdp::cliprdr::backend::ClipboardMessage::Error(e) => { + let error_ref: &dyn ironrdp::cliprdr::backend::ClipboardError = e.as_ref(); + Some(error_ref.into()) + } + _ => None, + } + } } pub enum ClipboardMessageType { SendInitiateCopy, + SendInitiateFileCopy, SendFormatData, SendInitiatePaste, + SendFileContentsRequest, + SendFileContentsResponse, Error, } @@ -62,4 +105,60 @@ pub mod ffi { #[diplomat::opaque] pub struct ClipboardFormatId(pub ironrdp::cliprdr::pdu::ClipboardFormatId); + + #[diplomat::opaque] + pub struct FfiFileContentsRequest(pub ironrdp::cliprdr::pdu::FileContentsRequest); + + impl FfiFileContentsRequest { + pub fn stream_id(&self) -> u32 { + self.0.stream_id + } + + pub fn index(&self) -> i32 { + self.0.index + } + + pub fn is_size_request(&self) -> bool { + self.0.flags.contains(ironrdp::cliprdr::pdu::FileContentsFlags::SIZE) + } + + pub fn is_range_request(&self) -> bool { + self.0.flags.contains(ironrdp::cliprdr::pdu::FileContentsFlags::RANGE) + } + + pub fn position(&self) -> u64 { + self.0.position + } + + pub fn requested_size(&self) -> u32 { + self.0.requested_size + } + + pub fn has_data_id(&self) -> bool { + self.0.data_id.is_some() + } + + pub fn data_id(&self) -> Result> { + self.0.data_id.ok_or_else(|| "no data_id present in request".into()) + } + } + + /// Wraps `OwnedFileContentsResponse`, which is a type alias for + /// `FileContentsResponse<'static>` (generated by `impl_pdu_borrowing!`). + #[diplomat::opaque] + pub struct FfiFileContentsResponse(pub ironrdp::cliprdr::pdu::OwnedFileContentsResponse); + + impl FfiFileContentsResponse { + pub fn stream_id(&self) -> u32 { + self.0.stream_id() + } + + pub fn is_error(&self) -> bool { + self.0.is_error() + } + + pub fn data(&self) -> Box { + Box::new(VecU8(self.0.data().to_vec())) + } + } } diff --git a/ffi/src/clipboard/mod.rs b/ffi/src/clipboard/mod.rs index e50aa1fec4..0d0b679dac 100644 --- a/ffi/src/clipboard/mod.rs +++ b/ffi/src/clipboard/mod.rs @@ -24,18 +24,18 @@ pub mod ffi { pub struct Cliprdr(pub Option>); #[diplomat::opaque] - pub struct ClipboardSvgMessage(pub Option>); + pub struct ClipboardSvcMessage(pub Option>); } #[derive(Debug)] -pub struct FfiClipbarodMessageProxy { +pub struct FfiClipboardMessageProxy { pub sender: std::sync::mpsc::Sender, } -impl ironrdp::cliprdr::backend::ClipboardMessageProxy for FfiClipbarodMessageProxy { +impl ironrdp::cliprdr::backend::ClipboardMessageProxy for FfiClipboardMessageProxy { fn send_clipboard_message(&self, message: ironrdp::cliprdr::backend::ClipboardMessage) { - if let Err(err) = self.sender.send(message) { - error!("Failed to send clipboard message: {:?}", err); + if let Err(error) = self.sender.send(message) { + error!(?error, "Failed to send clipboard message"); } } } diff --git a/ffi/src/clipboard/windows.rs b/ffi/src/clipboard/windows.rs index 3ea9ff3037..993f25eeb8 100644 --- a/ffi/src/clipboard/windows.rs +++ b/ffi/src/clipboard/windows.rs @@ -4,9 +4,9 @@ use ironrdp_cliprdr_native as _; use super::ffi::CliprdrBackendFactory; -use crate::error::ffi::IronRdpError; #[cfg(not(windows))] -use crate::error::WrongOSError; // avoid linter error, stub clipboard will be used in later commit +use crate::error::WrongOSError; +use crate::error::ffi::IronRdpError; // avoid linter error, stub clipboard will be used in later commit /* Why are we creating a WinCliprdrInner struct and implement differently? @@ -94,7 +94,7 @@ impl WinCliprdrInner { fn new() -> Result> { let (sender, receiver) = std::sync::mpsc::channel(); - let proxy = crate::clipboard::FfiClipbarodMessageProxy { sender }; + let proxy = crate::clipboard::FfiClipboardMessageProxy { sender }; let clipboard = ironrdp_cliprdr_native::WinClipboard::new(proxy)?; @@ -102,7 +102,11 @@ impl WinCliprdrInner { } fn next_clipboard_message(&self) -> Result, Box> { - Ok(self.receiver.try_recv().ok()) + match self.receiver.try_recv() { + Ok(msg) => Ok(Some(msg)), + Err(std::sync::mpsc::TryRecvError::Empty) => Ok(None), + Err(std::sync::mpsc::TryRecvError::Disconnected) => Err("clipboard message channel disconnected".into()), + } } fn backend_factory(&self) -> Result> { diff --git a/ffi/src/connector/activation.rs b/ffi/src/connector/activation.rs index 076315c6a5..9a1b0c967e 100644 --- a/ffi/src/connector/activation.rs +++ b/ffi/src/connector/activation.rs @@ -5,8 +5,8 @@ pub mod ffi { use crate::connector::config::ffi::DesktopSize; use crate::connector::ffi::PduHint; use crate::connector::result::ffi::Written; - use crate::error::ffi::IronRdpError; use crate::error::IncorrectEnumTypeError; + use crate::error::ffi::IronRdpError; use crate::pdu::ffi::WriteBuf; #[diplomat::opaque] @@ -16,7 +16,9 @@ pub mod ffi { impl ConnectionActivationSequence { pub fn get_state(&self) -> Box { - Box::new(ConnectionActivationState(self.0.state.clone())) + Box::new(ConnectionActivationState { + state: self.0.connection_activation_state(), + }) } pub fn next_pdu_hint<'a>(&'a self) -> Result>>, Box> { @@ -33,10 +35,20 @@ pub mod ffi { let res = self.0.step_no_input(&mut buf.0).map(Written).map(Box::new)?; Ok(res) } + + pub fn get_io_channel_id(&self) -> u16 { + self.0.io_channel_id() + } + + pub fn get_user_channel_id(&self) -> u16 { + self.0.user_channel_id() + } } #[diplomat::opaque] - pub struct ConnectionActivationState(pub ironrdp::connector::connection_activation::ConnectionActivationState); + pub struct ConnectionActivationState { + pub state: ironrdp::connector::connection_activation::ConnectionActivationState, + } pub enum ConnectionActivationStateType { Consumed, @@ -47,13 +59,13 @@ pub mod ffi { impl ConnectionActivationState { pub fn get_type(&self) -> ConnectionActivationStateType { - match self.0 { + match self.state { ironrdp::connector::connection_activation::ConnectionActivationState::Consumed => { ConnectionActivationStateType::Consumed } - ironrdp::connector::connection_activation::ConnectionActivationState::CapabilitiesExchange { - .. - } => ConnectionActivationStateType::CapabilitiesExchange, + ironrdp::connector::connection_activation::ConnectionActivationState::CapabilitiesExchange => { + ConnectionActivationStateType::CapabilitiesExchange + } ironrdp::connector::connection_activation::ConnectionActivationState::ConnectionFinalization { .. } => ConnectionActivationStateType::ConnectionFinalization, @@ -63,37 +75,17 @@ pub mod ffi { } } - pub fn get_capabilities_exchange( - &self, - ) -> Result, Box> { - match &self.0 { - ironrdp::connector::connection_activation::ConnectionActivationState::CapabilitiesExchange { - io_channel_id, - user_channel_id, - } => Ok(Box::new(ConnectionActivationStateCapabilitiesExchange { - io_channel_id: *io_channel_id, - user_channel_id: *user_channel_id, - })), - _ => Err(IncorrectEnumTypeError::on_variant("CapabilitiesExchange") - .of_enum("ConnectionActivationState") - .into()), - } - } - pub fn get_connection_finalization( &self, ) -> Result, Box> { - match &self.0 { + match self.state { ironrdp::connector::connection_activation::ConnectionActivationState::ConnectionFinalization { - io_channel_id, - user_channel_id, desktop_size, + share_id: _, connection_finalization, } => Ok(Box::new(ConnectionActivationStateConnectionFinalization { - io_channel_id: *io_channel_id, - user_channel_id: *user_channel_id, - desktop_size: *desktop_size, - connection_finalization: connection_finalization.clone(), + desktop_size, + connection_finalization, })), _ => Err(IncorrectEnumTypeError::on_variant("ConnectionFinalization") .of_enum("ConnectionActivationState") @@ -102,16 +94,14 @@ pub mod ffi { } pub fn get_finalized(&self) -> Result, Box> { - match &self.0 { + match &self.state { ironrdp::connector::connection_activation::ConnectionActivationState::Finalized { - io_channel_id, - user_channel_id, desktop_size, + share_id, enable_server_pointer, pointer_software_rendering, } => Ok(Box::new(ConnectionActivationStateFinalized { - io_channel_id: *io_channel_id, - user_channel_id: *user_channel_id, + share_id: *share_id, desktop_size: *desktop_size, enable_server_pointer: *enable_server_pointer, pointer_software_rendering: *pointer_software_rendering, @@ -123,39 +113,13 @@ pub mod ffi { } } - #[diplomat::opaque] - pub struct ConnectionActivationStateCapabilitiesExchange { - pub io_channel_id: u16, - pub user_channel_id: u16, - } - - impl ConnectionActivationStateCapabilitiesExchange { - pub fn get_io_channel_id(&self) -> u16 { - self.io_channel_id - } - - pub fn get_user_channel_id(&self) -> u16 { - self.user_channel_id - } - } - #[diplomat::opaque] pub struct ConnectionActivationStateConnectionFinalization { - pub io_channel_id: u16, - pub user_channel_id: u16, pub desktop_size: ironrdp::connector::DesktopSize, pub connection_finalization: ironrdp::connector::ConnectionFinalizationSequence, } impl ConnectionActivationStateConnectionFinalization { - pub fn get_io_channel_id(&self) -> u16 { - self.io_channel_id - } - - pub fn get_user_channel_id(&self) -> u16 { - self.user_channel_id - } - pub fn get_desktop_size(&self) -> Box { Box::new(DesktopSize(self.desktop_size)) } @@ -163,20 +127,15 @@ pub mod ffi { #[diplomat::opaque] pub struct ConnectionActivationStateFinalized { - pub io_channel_id: u16, - pub user_channel_id: u16, + pub share_id: u32, pub desktop_size: ironrdp::connector::DesktopSize, pub enable_server_pointer: bool, pub pointer_software_rendering: bool, } impl ConnectionActivationStateFinalized { - pub fn get_io_channel_id(&self) -> u16 { - self.io_channel_id - } - - pub fn get_user_channel_id(&self) -> u16 { - self.user_channel_id + pub fn get_share_id(&self) -> u32 { + self.share_id } pub fn get_desktop_size(&self) -> Box { diff --git a/ffi/src/connector/config.rs b/ffi/src/connector/config.rs index 405075c8bf..3ec0e1777f 100644 --- a/ffi/src/connector/config.rs +++ b/ffi/src/connector/config.rs @@ -7,15 +7,23 @@ pub mod ffi { use ironrdp::connector::Credentials; use ironrdp::pdu::rdp::capability_sets::MajorPlatformType; + use crate::dvc::ffi::DvcPipeProxyConfig; use crate::error::ffi::IronRdpError; #[diplomat::opaque] - pub struct Config(pub ironrdp::connector::Config); + pub struct Config { + pub connector: ironrdp::connector::Config, + pub dvc_pipe_proxy: Option, + } impl Config { pub fn get_builder() -> Box { Box::::default() } + + pub fn get_dvc_pipe_proxy(&self) -> Option> { + self.dvc_pipe_proxy.as_ref().map(|dvc| Box::new(dvc.clone())) + } } #[derive(Default)] @@ -43,6 +51,7 @@ pub mod ffi { pub pointer_software_rendering: Option, pub performance_flags: Option, pub timezone_info: Option, + pub dvc_pipe_proxy: Option, } #[diplomat::enum_convert(ironrdp::pdu::gcc::KeyboardType)] @@ -157,8 +166,12 @@ pub mod ffi { self.pointer_software_rendering = Some(pointer_software_rendering); } + pub fn set_dvc_pipe_proxy(&mut self, dvc_pipe_proxy: &DvcPipeProxyConfig) { + self.dvc_pipe_proxy = Some(dvc_pipe_proxy.clone()); + } + pub fn build(&self) -> Result, Box> { - let inner_config = ironrdp::connector::Config { + let connector = ironrdp::connector::Config { credentials: self.credentials.clone().ok_or("credentials not set")?, domain: self.domain.clone(), enable_tls: self.enable_tls.unwrap_or(false), @@ -200,15 +213,25 @@ pub mod ffi { autologon: self.autologon.unwrap_or(false), enable_audio_playback: self.no_audio_playback.unwrap_or(true), request_data: None, + compression_type: None, pointer_software_rendering: self.pointer_software_rendering.unwrap_or(false), + multitransport_flags: None, performance_flags: self.performance_flags.ok_or("performance flag is missing")?, desktop_scale_factor: 0, hardware_id: None, license_cache: None, timezone_info: self.timezone_info.clone().unwrap_or_default(), + alternate_shell: String::new(), + work_dir: String::new(), }; - tracing::debug!(config=?inner_config, "Built config"); - Ok(Box::new(Config(inner_config))) + let dvc_pipe_proxy = self.dvc_pipe_proxy.clone(); + + tracing::debug!(config=?connector, "Built config"); + + Ok(Box::new(Config { + connector, + dvc_pipe_proxy, + })) } } diff --git a/ffi/src/connector/mod.rs b/ffi/src/connector/mod.rs index c41defec9a..ab2b7b8054 100644 --- a/ffi/src/connector/mod.rs +++ b/ffi/src/connector/mod.rs @@ -10,14 +10,18 @@ pub mod ffi { use diplomat_runtime::DiplomatWriteable; use ironrdp::connector::Sequence as _; use ironrdp::displaycontrol::client::DisplayControlClient; + use ironrdp::dvc::DvcProcessor; + use ironrdp_dvc_pipe_proxy::DvcNamedPipeProxy; use tracing::info; use super::config::ffi::Config; use super::result::ffi::Written; use super::state::ffi::ClientConnectorState; use crate::clipboard::ffi::Cliprdr; - use crate::error::ffi::{IronRdpError, IronRdpErrorKind}; + use crate::dvc::dvc_pipe_proxy_message_queue::DvcPipeProxyMessageInner; + use crate::dvc::ffi::DvcPipeProxyConfig; use crate::error::ValueConsumedError; + use crate::error::ffi::{IronRdpError, IronRdpErrorKind}; use crate::pdu::ffi::WriteBuf; #[diplomat::opaque] // We must use Option here, as ClientConnector is not Clone and have functions that consume it @@ -29,7 +33,7 @@ pub mod ffi { let client_addr = client_addr.parse().map_err(|_| IronRdpErrorKind::Generic)?; Ok(Box::new(ClientConnector(Some( - ironrdp::connector::ClientConnector::new(config.0.clone(), client_addr), + ironrdp::connector::ClientConnector::new(config.connector.clone(), client_addr), )))) } @@ -68,19 +72,52 @@ pub mod ffi { Ok(()) } - pub fn with_dynamic_channel_display_control(&mut self) -> Result<(), Box> { - let Some(connector) = self.0.take() else { + fn with_dvc(&mut self, processor: T) -> Result<(), Box> + where + T: DvcProcessor + 'static, + { + let Some(connector) = &mut self.0 else { return Err(ValueConsumedError::for_item("connector").into()); }; - self.0 = Some( - connector.with_static_channel(ironrdp::dvc::DrdynvcClient::new().with_dynamic_channel( - DisplayControlClient::new(|c| { - info!(DisplayCountrolCapabilities = ?c, "DisplayControl capabilities received"); - Ok(Vec::new()) - }), - )), - ); + let drdynvc = match connector.get_static_channel_processor_mut::() { + Some(processor) => processor, + None => { + connector.attach_static_channel(ironrdp::dvc::DrdynvcClient::new()); + connector + .get_static_channel_processor_mut::() + .expect("DrdynvcClient should be initialized above") + } + }; + + drdynvc.attach_dynamic_channel(processor); + + Ok(()) + } + + pub fn with_dynamic_channel_display_control(&mut self) -> Result<(), Box> { + self.with_dvc(DisplayControlClient::new(|c| { + info!(display_control_capabilities = ?c, "DisplayControl capabilities received"); + Ok(Vec::new()) + })) + } + + pub fn with_dynamic_channel_pipe_proxy( + &mut self, + config: &DvcPipeProxyConfig, + ) -> Result<(), Box> { + for descriptor in &config.descriptors { + let sink = config.message_sink.0.clone(); + let proxy = DvcNamedPipeProxy::new( + &descriptor.channel_name, + &descriptor.pipe_name, + move |channel_id, svc_message| { + let _ = sink.send(DvcPipeProxyMessageInner(channel_id, svc_message)); + Ok(()) + }, + ); + self.with_dvc(proxy)?; + } Ok(()) } @@ -143,6 +180,30 @@ pub mod ffi { connector.attach_static_channel(cliprdr); Ok(()) } + + pub fn next_pdu_hint(&self) -> Result>>, Box> { + let Some(connector) = self.0.as_ref() else { + return Err(ValueConsumedError::for_item("connector").into()); + }; + tracing::trace!(pdu_hint=?connector.next_pdu_hint(), "Reading next PDU hint"); + Ok(connector.next_pdu_hint().map(PduHint).map(Box::new)) + } + + pub fn get_dyn_state(&self) -> Result>, Box> { + let Some(connector) = self.0.as_ref() else { + return Err(ValueConsumedError::for_item("connector").into()); + }; + Ok(Box::new(DynState(connector.state()))) + } + + pub fn consume_and_cast_to_client_connector_state( + &mut self, + ) -> Result, Box> { + let Some(connector) = self.0.take() else { + return Err(ValueConsumedError::for_item("connector").into()); + }; + Ok(Box::new(ClientConnectorState(Some(connector.state)))) + } } #[diplomat::opaque] @@ -172,32 +233,6 @@ pub mod ffi { } } - impl ClientConnector { - pub fn next_pdu_hint(&self) -> Result>>, Box> { - let Some(connector) = self.0.as_ref() else { - return Err(ValueConsumedError::for_item("connector").into()); - }; - tracing::trace!(pduhint=?connector.next_pdu_hint(), "Reading next PDU hint"); - Ok(connector.next_pdu_hint().map(PduHint).map(Box::new)) - } - - pub fn get_dyn_state(&self) -> Result>, Box> { - let Some(connector) = self.0.as_ref() else { - return Err(ValueConsumedError::for_item("connector").into()); - }; - Ok(Box::new(DynState(connector.state()))) - } - - pub fn consume_and_cast_to_client_connector_state( - &mut self, - ) -> Result, Box> { - let Some(connector) = self.0.take() else { - return Err(ValueConsumedError::for_item("connector").into()); - }; - Ok(Box::new(ClientConnectorState(Some(connector.state)))) - } - } - #[diplomat::opaque] pub struct ChannelConnectionSequence(pub ironrdp::connector::ChannelConnectionSequence); diff --git a/ffi/src/connector/result.rs b/ffi/src/connector/result.rs index 9a11a6e278..4f016868f7 100644 --- a/ffi/src/connector/result.rs +++ b/ffi/src/connector/result.rs @@ -1,8 +1,8 @@ #[diplomat::bridge] pub mod ffi { use crate::connector::config::ffi::DesktopSize; - use crate::error::ffi::IronRdpError; use crate::error::ValueConsumedError; + use crate::error::ffi::IronRdpError; use crate::utils::ffi::OptionalUsize; #[diplomat::opaque] @@ -49,6 +49,14 @@ pub mod ffi { .user_channel_id) } + pub fn get_share_id(&self) -> Result> { + Ok(self + .0 + .as_ref() + .ok_or_else(|| ValueConsumedError::for_item("ConnectionResult"))? + .share_id) + } + pub fn get_desktop_size(&self) -> Result, Box> { Ok(Box::new(DesktopSize( self.0 diff --git a/ffi/src/credssp/mod.rs b/ffi/src/credssp/mod.rs index b912d26852..2282e86c6c 100644 --- a/ffi/src/credssp/mod.rs +++ b/ffi/src/credssp/mod.rs @@ -9,8 +9,8 @@ pub mod ffi { use super::network::ffi::{ClientState, CredsspProcessGenerator}; use crate::connector::ffi::{ClientConnector, PduHint}; use crate::connector::result::ffi::Written; - use crate::error::ffi::IronRdpError; use crate::error::ValueConsumedError; + use crate::error::ffi::IronRdpError; use crate::pdu::ffi::WriteBuf; #[diplomat::opaque] diff --git a/ffi/src/dvc.rs b/ffi/src/dvc.rs deleted file mode 100644 index 1618237a17..0000000000 --- a/ffi/src/dvc.rs +++ /dev/null @@ -1,6 +0,0 @@ -#[diplomat::bridge] -pub mod ffi { - - #[diplomat::opaque] - pub struct DrdynvcChannel(pub ironrdp::dvc::DrdynvcClient); -} diff --git a/ffi/src/dvc/dvc_pipe_proxy_message_queue.rs b/ffi/src/dvc/dvc_pipe_proxy_message_queue.rs new file mode 100644 index 0000000000..1ec4cc0feb --- /dev/null +++ b/ffi/src/dvc/dvc_pipe_proxy_message_queue.rs @@ -0,0 +1,72 @@ +use std::sync::mpsc; + +use ironrdp::svc::SvcMessage; + +#[diplomat::bridge] +pub mod ffi { + use std::sync::mpsc; + + use super::{DvcPipeProxyMessageInner, DvcPipeProxyMessageQueueInner}; + use crate::error::ffi::IronRdpError; + + #[diplomat::opaque] + pub struct DvcPipeProxyMessage(pub DvcPipeProxyMessageInner); + + impl DvcPipeProxyMessage { + pub fn get_channel_id(&self) -> u32 { + self.0.0 + } + } + + #[diplomat::opaque] + #[derive(Clone)] + pub struct DvcPipeProxyMessageSink(pub mpsc::SyncSender); + + #[diplomat::opaque] + pub struct DvcPipeProxyMessageQueue(DvcPipeProxyMessageQueueInner); + + impl DvcPipeProxyMessageQueue { + pub fn new(queue_size: u32) -> Box { + #[expect(clippy::missing_panics_doc, reason = "unreachable panic (integer upcast)")] + let queue_size = usize::try_from(queue_size).expect("invalid dvc pipe proxy message queue size"); + + Box::new(DvcPipeProxyMessageQueue(DvcPipeProxyMessageQueueInner::new(queue_size))) + } + + pub fn next_message(&self) -> Result>, Box> { + Ok(self.0.next_message().map(DvcPipeProxyMessage).map(Box::new)) + } + + pub fn next_message_blocking(&self) -> Result, Box> { + let message = self.0.next_message_blocking().map(DvcPipeProxyMessage).map(Box::new)?; + + Ok(message) + } + + pub fn get_sink(&self) -> Box { + Box::new(DvcPipeProxyMessageSink(self.0.tx.clone())) + } + } +} + +struct DvcPipeProxyMessageQueueInner { + tx: mpsc::SyncSender, + rx: mpsc::Receiver, +} + +impl DvcPipeProxyMessageQueueInner { + fn new(queue_size: usize) -> Self { + let (tx, rx) = mpsc::sync_channel(queue_size); + Self { tx, rx } + } + + fn next_message(&self) -> Option { + self.rx.try_recv().ok() + } + + fn next_message_blocking(&self) -> Result { + self.rx.recv().map_err(|_| "failed to receive dvc pipe proxy message") + } +} + +pub struct DvcPipeProxyMessageInner(pub u32, pub Vec); diff --git a/ffi/src/dvc/mod.rs b/ffi/src/dvc/mod.rs new file mode 100644 index 0000000000..16e7d2782c --- /dev/null +++ b/ffi/src/dvc/mod.rs @@ -0,0 +1,49 @@ +pub mod dvc_pipe_proxy_message_queue; + +#[diplomat::bridge] +pub mod ffi { + use crate::dvc::dvc_pipe_proxy_message_queue::ffi::DvcPipeProxyMessageSink; + + #[diplomat::opaque] + pub struct DrdynvcChannel(pub ironrdp::dvc::DrdynvcClient); + + #[diplomat::opaque] + #[derive(Clone)] + pub struct DvcPipeProxyDescriptor { + pub channel_name: String, + pub pipe_name: String, + } + + impl DvcPipeProxyDescriptor { + pub fn new(channel_name: &str, pipe_name: &str) -> Box { + Box::new(DvcPipeProxyDescriptor { + channel_name: channel_name.to_owned(), + pipe_name: pipe_name.to_owned(), + }) + } + } + + #[diplomat::opaque] + #[derive(Clone)] + pub struct DvcPipeProxyConfig { + pub message_sink: DvcPipeProxyMessageSink, + pub descriptors: Vec, + } + + impl DvcPipeProxyConfig { + pub fn new(message_sink: &DvcPipeProxyMessageSink) -> Box { + Box::new(DvcPipeProxyConfig { + message_sink: message_sink.clone(), + descriptors: Vec::new(), + }) + } + + pub fn add_pipe_proxy(&mut self, descriptor: &DvcPipeProxyDescriptor) { + self.descriptors.push(descriptor.clone()); + } + + pub fn get_message_sink(&self) -> Box { + Box::new(self.message_sink.clone()) + } + } +} diff --git a/ffi/src/error.rs b/ffi/src/error.rs index 9312ba45f2..11ae4e2be0 100644 --- a/ffi/src/error.rs +++ b/ffi/src/error.rs @@ -6,107 +6,178 @@ use ironrdp::connector::ConnectorError; use ironrdp::session::SessionError; #[cfg(target_os = "windows")] use ironrdp_cliprdr_native::WinCliprdrError; +use ironrdp_rdcleanpath::der; use self::ffi::IronRdpErrorKind; -impl From for IronRdpErrorKind { - fn from(val: ConnectorError) -> Self { - match val.kind { +pub struct GenericError(pub anyhow::Error); + +impl Display for GenericError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{:#}", self.0) + } +} + +struct IronRdpErrorInner { + repr: String, + kind: IronRdpErrorKind, +} + +// Helper function to create an IronRdpError +fn make_ffi_error(repr: String, kind: IronRdpErrorKind) -> Box { + Box::new(ffi::IronRdpError(IronRdpErrorInner { repr, kind })) +} + +// Direct conversion from IronRdpErrorKind (for cases with no underlying error) +impl From for Box { + fn from(kind: IronRdpErrorKind) -> Self { + make_ffi_error(kind.to_string(), kind) + } +} + +// IronRDP errors - use .report() to include full error chain with sources +impl From for Box { + fn from(value: ConnectorError) -> Self { + let kind = match value.kind() { ironrdp::connector::ConnectorErrorKind::Encode(_) => IronRdpErrorKind::EncodeError, ironrdp::connector::ConnectorErrorKind::Decode(_) => IronRdpErrorKind::DecodeError, ironrdp::connector::ConnectorErrorKind::Credssp(_) => IronRdpErrorKind::CredsspError, ironrdp::connector::ConnectorErrorKind::AccessDenied => IronRdpErrorKind::AccessDenied, _ => IronRdpErrorKind::Generic, - } + }; + let repr = value.report().to_string(); + make_ffi_error(repr, kind) } } -impl From<&str> for IronRdpErrorKind { - fn from(_val: &str) -> Self { - IronRdpErrorKind::Generic +impl From for Box { + fn from(value: SessionError) -> Self { + let kind = match value.kind() { + ironrdp::session::SessionErrorKind::Pdu(_) => IronRdpErrorKind::PduError, + ironrdp::session::SessionErrorKind::Encode(_) => IronRdpErrorKind::EncodeError, + ironrdp::session::SessionErrorKind::Decode(_) => IronRdpErrorKind::DecodeError, + _ => IronRdpErrorKind::Generic, + }; + let repr = value.report().to_string(); + make_ffi_error(repr, kind) } } -impl From for IronRdpErrorKind { - fn from(_val: sspi::Error) -> Self { - IronRdpErrorKind::CredsspError +impl From for Box { + fn from(value: ironrdp::pdu::PduError) -> Self { + let repr = value.report().to_string(); + make_ffi_error(repr, IronRdpErrorKind::PduError) } } -impl From for IronRdpErrorKind { - fn from(_val: ironrdp::pdu::PduError) -> Self { - IronRdpErrorKind::PduError +impl From for Box { + fn from(value: ironrdp::core::EncodeError) -> Self { + let repr = value.report().to_string(); + make_ffi_error(repr, IronRdpErrorKind::EncodeError) } } -impl From for IronRdpErrorKind { - fn from(_val: ironrdp::core::EncodeError) -> Self { - IronRdpErrorKind::EncodeError +impl From for Box { + fn from(value: ironrdp::core::DecodeError) -> Self { + let repr = value.report().to_string(); + make_ffi_error(repr, IronRdpErrorKind::DecodeError) } } -impl From for IronRdpErrorKind { - fn from(_val: ironrdp::core::DecodeError) -> Self { - IronRdpErrorKind::DecodeError +// std::io::Error - convert to anyhow::Error for proper source chain formatting +impl From for Box { + fn from(value: std::io::Error) -> Self { + let repr = format!("{:#}", anyhow::Error::new(value)); + make_ffi_error(repr, IronRdpErrorKind::IO) } } -impl From for IronRdpErrorKind { - fn from(_: std::io::Error) -> Self { - IronRdpErrorKind::IO +// sspi::Error - convert to anyhow::Error for proper source chain formatting +impl From for Box { + fn from(value: sspi::Error) -> Self { + let repr = format!("{:#}", anyhow::Error::new(value)); + make_ffi_error(repr, IronRdpErrorKind::CredsspError) } } -impl From for IronRdpErrorKind { - fn from(_val: core::fmt::Error) -> Self { - IronRdpErrorKind::Generic +// Simple string error +impl From<&str> for Box { + fn from(value: &str) -> Self { + make_ffi_error(value.to_owned(), IronRdpErrorKind::Generic) } } -impl From for IronRdpErrorKind { - fn from(value: SessionError) -> Self { - match value.kind() { - ironrdp::session::SessionErrorKind::Pdu(_) => IronRdpErrorKind::PduError, - ironrdp::session::SessionErrorKind::Encode(_) => IronRdpErrorKind::EncodeError, - ironrdp::session::SessionErrorKind::Decode(_) => IronRdpErrorKind::DecodeError, - _ => IronRdpErrorKind::Generic, - } +// core::fmt::Error - convert to anyhow::Error for consistency +impl From for Box { + fn from(value: core::fmt::Error) -> Self { + let repr = format!("{:#}", anyhow::Error::new(value)); + make_ffi_error(repr, IronRdpErrorKind::Generic) } } -impl From<&dyn ClipboardError> for IronRdpErrorKind { - fn from(_val: &dyn ClipboardError) -> Self { - IronRdpErrorKind::Clipboard +// Clipboard errors - manually format with full source chain +impl From<&dyn ClipboardError> for Box { + fn from(value: &dyn ClipboardError) -> Self { + use core::fmt::Write as _; + + // Manually build error chain since we have a trait object reference + let mut repr = value.to_string(); + let mut source = value.source(); + while let Some(e) = source { + let _ = write!(&mut repr, ", caused by: {e}"); + source = e.source(); + } + make_ffi_error(repr, IronRdpErrorKind::Clipboard) } } #[cfg(target_os = "windows")] -impl From for IronRdpErrorKind { - fn from(_val: WinCliprdrError) -> Self { - IronRdpErrorKind::Clipboard +impl From for Box { + fn from(value: WinCliprdrError) -> Self { + let repr = format!("{:#}", anyhow::Error::new(value)); + make_ffi_error(repr, IronRdpErrorKind::Clipboard) } } -impl From for IronRdpErrorKind { - fn from(_val: WrongOSError) -> Self { - IronRdpErrorKind::WrongOS +// DER errors - convert to anyhow::Error for proper source chain formatting +impl From for Box { + fn from(value: der::Error) -> Self { + let repr = format!("{:#}", anyhow::Error::new(value)); + make_ffi_error(repr, IronRdpErrorKind::DecodeError) } } -impl From for Box -where - T: Into + ToString, -{ - fn from(value: T) -> Self { - let repr = value.to_string(); - let kind = value.into(); - Box::new(ffi::IronRdpError(IronRdpErrorInner { repr, kind })) +impl From for Box { + fn from(value: ironrdp_rdcleanpath::MissingRDCleanPathField) -> Self { + let repr = format!("{:#}", anyhow::Error::new(value)); + make_ffi_error(repr, IronRdpErrorKind::Generic) } } -struct IronRdpErrorInner { - repr: String, - kind: IronRdpErrorKind, +// GenericError already has proper Display impl with {:#} +impl From for Box { + fn from(value: GenericError) -> Self { + make_ffi_error(value.to_string(), IronRdpErrorKind::Generic) + } +} + +// FFI-specific errors +impl From for Box { + fn from(value: ValueConsumedError) -> Self { + make_ffi_error(value.to_string(), IronRdpErrorKind::Consumed) + } +} + +impl From for Box { + fn from(value: IncorrectEnumTypeError) -> Self { + make_ffi_error(value.to_string(), IronRdpErrorKind::IncorrectEnumType) + } +} + +impl From for Box { + fn from(value: WrongOSError) -> Self { + make_ffi_error(value.to_string(), IronRdpErrorKind::WrongOS) + } } #[diplomat::bridge] @@ -221,11 +292,7 @@ impl IncorrectEnumTypeErrorBuilder { impl Display for IncorrectEnumTypeError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!( - f, - "expected enum variable {}, of enum {}", - self.expected, self.enum_name - ) + write!(f, "expected enum variable {} of enum {}", self.expected, self.enum_name) } } diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs index 407847ce4f..44009cd5ae 100644 --- a/ffi/src/lib.rs +++ b/ffi/src/lib.rs @@ -11,6 +11,7 @@ pub mod graphics; pub mod input; pub mod log; pub mod pdu; +pub mod rdcleanpath; pub mod session; pub mod svc; pub mod utils; diff --git a/ffi/src/log.rs b/ffi/src/log.rs index c1c453dfe0..8e2f8d0d74 100644 --- a/ffi/src/log.rs +++ b/ffi/src/log.rs @@ -8,29 +8,34 @@ const IRONRDP_LOG: &str = "IRONRDP_LOG"; #[diplomat::bridge] pub mod ffi { - use super::{setup_logging, INIT_LOG, IRONRDP_LOG_PATH}; + use super::{INIT_LOG, IRONRDP_LOG_PATH, setup_logging}; #[diplomat::opaque] pub struct Log; impl Log { + /// # Panics + /// + /// - Panics if log directory creation fails. + /// - Panics if tracing initialization fails. + // FIXME: We should return an error instead, because panicking at the FFI boundary is unsafe. pub fn init_with_env() { INIT_LOG.call_once(|| { let log_file = std::env::var(IRONRDP_LOG_PATH).ok(); let log_file = log_file.as_deref(); - setup_logging(log_file).expect("Failed to setup logging"); + setup_logging(log_file).expect("failed to setup logging"); }); } } } fn setup_logging(log_file_path: Option<&str>) -> Result<(), Box> { - use std::fs::{create_dir_all, OpenOptions}; + use std::fs::{OpenOptions, create_dir_all}; use std::path::PathBuf; use tracing::metadata::LevelFilter; - use tracing_subscriber::prelude::*; use tracing_subscriber::EnvFilter; + use tracing_subscriber::prelude::*; let env_filter = EnvFilter::builder() .with_default_directive(LevelFilter::WARN.into()) diff --git a/ffi/src/rdcleanpath.rs b/ffi/src/rdcleanpath.rs new file mode 100644 index 0000000000..2990336c94 --- /dev/null +++ b/ffi/src/rdcleanpath.rs @@ -0,0 +1,268 @@ +#[diplomat::bridge] +pub mod ffi { + use core::fmt::Write as _; + + use anyhow::Context as _; + use diplomat_runtime::DiplomatWriteable; + + use crate::error::GenericError; + use crate::error::ffi::IronRdpError; + use crate::utils::ffi::VecU8; + + #[diplomat::opaque] + pub struct RDCleanPathPdu(pub ironrdp_rdcleanpath::RDCleanPathPdu); + + impl RDCleanPathPdu { + /// Creates a new RDCleanPath request PDU + /// + /// # Arguments + /// * `x224_pdu` - The X.224 Connection Request PDU bytes + /// * `destination` - The destination RDP server address (e.g., "10.10.0.3:3389") + /// * `proxy_auth` - The JWT authentication token + /// * `pcb` - Optional preconnection blob (for Hyper-V VM connections, empty string if not needed) + pub fn new_request( + x224_pdu: &[u8], + destination: &str, + proxy_auth: &str, + pcb: &str, + ) -> Result, Box> { + let pcb_opt = if pcb.is_empty() { None } else { Some(pcb.to_owned()) }; + + let pdu = ironrdp_rdcleanpath::RDCleanPathPdu::new_request( + x224_pdu.to_vec(), + destination.to_owned(), + proxy_auth.to_owned(), + pcb_opt, + ) + .context("failed to create RDCleanPath request") + .map_err(GenericError)?; + + Ok(Box::new(RDCleanPathPdu(pdu))) + } + + /// Decodes a RDCleanPath PDU from DER-encoded bytes + pub fn from_der(bytes: &[u8]) -> Result, Box> { + let pdu = ironrdp_rdcleanpath::RDCleanPathPdu::from_der(bytes) + .context("failed to decode RDCleanPath PDU") + .map_err(GenericError)?; + + Ok(Box::new(RDCleanPathPdu(pdu))) + } + + /// Encodes the RDCleanPath PDU to DER-encoded bytes + pub fn to_der(&self) -> Result, Box> { + let bytes = self + .0 + .to_der() + .context("failed to encode RDCleanPath PDU") + .map_err(GenericError)?; + + Ok(Box::new(VecU8(bytes))) + } + + /// Detects if the bytes contain a valid RDCleanPath PDU and returns detection result + pub fn detect(bytes: &[u8]) -> Box { + let result = ironrdp_rdcleanpath::RDCleanPathPdu::detect(bytes); + Box::new(RDCleanPathDetectionResult(result)) + } + + /// Gets the type of this RDCleanPath PDU + pub fn get_type(&self) -> Result> { + if self.0.destination.is_some() { + if self.0.proxy_auth.is_none() { + return Err(Self::missing_field("proxy_auth")); + } + + if self.0.x224_connection_pdu.is_none() { + return Err(Self::missing_field("x224_connection_pdu")); + } + + Ok(RDCleanPathResultType::Request) + } else if self.0.server_addr.is_some() { + if self.0.x224_connection_pdu.is_none() { + return Err(Self::missing_field("x224_connection_pdu")); + } + + if self.0.server_cert_chain.is_none() { + return Err(Self::missing_field("server_cert_chain")); + } + + Ok(RDCleanPathResultType::Response) + } else if let Some(error) = &self.0.error { + if error.error_code == ironrdp_rdcleanpath::NEGOTIATION_ERROR_CODE { + if self.0.x224_connection_pdu.is_none() { + return Err(Self::missing_field("x224_connection_pdu")); + } + + Ok(RDCleanPathResultType::NegotiationError) + } else { + Ok(RDCleanPathResultType::GeneralError) + } + } else { + Err(Self::missing_field("error")) + } + } + + /// Gets the X.224 connection response bytes (for Response or NegotiationError variants) + pub fn get_x224_response(&self) -> Result, Box> { + if self.0.server_addr.is_some() { + let x224 = self + .0 + .x224_connection_pdu + .as_ref() + .ok_or_else(|| Self::missing_field("x224_connection_pdu"))?; + self.0 + .server_cert_chain + .as_ref() + .ok_or_else(|| Self::missing_field("server_cert_chain"))?; + + Ok(Box::new(VecU8(x224.as_bytes().to_vec()))) + } else if let Some(error) = &self.0.error { + if error.error_code == ironrdp_rdcleanpath::NEGOTIATION_ERROR_CODE { + let x224 = self + .0 + .x224_connection_pdu + .as_ref() + .ok_or_else(|| Self::missing_field("x224_connection_pdu"))?; + + Ok(Box::new(VecU8(x224.as_bytes().to_vec()))) + } else { + Err(GenericError(anyhow::anyhow!("RDCleanPath variant does not contain X.224 response")).into()) + } + } else { + Err(GenericError(anyhow::anyhow!("RDCleanPath variant does not contain X.224 response")).into()) + } + } + + /// Gets the server certificate chain (for Response variant) + /// Returns a vector iterator of certificate bytes + pub fn get_server_cert_chain(&self) -> Result, Box> { + if self.0.server_addr.is_some() { + self.0 + .x224_connection_pdu + .as_ref() + .ok_or_else(|| Self::missing_field("x224_connection_pdu"))?; + let certs = self + .0 + .server_cert_chain + .as_ref() + .ok_or_else(|| Self::missing_field("server_cert_chain"))?; + + let certs: Vec> = certs.iter().map(|cert| cert.as_bytes().to_vec()).collect(); + Ok(Box::new(CertificateChainIterator { certs, index: 0 })) + } else { + Err(GenericError(anyhow::anyhow!( + "RDCleanPath variant does not contain certificate chain" + )) + .into()) + } + } + + /// Gets the server address string (for Response variant) + pub fn get_server_addr<'a>(&'a self, writeable: &'a mut DiplomatWriteable) { + if self.0.server_addr.is_some() + && self.0.server_cert_chain.is_some() + && self.0.x224_connection_pdu.is_some() + { + if let Some(server_addr) = &self.0.server_addr { + let _ = write!(writeable, "{server_addr}"); + } + } + } + + /// Gets error message (for GeneralError variant) + pub fn get_error_message<'a>(&'a self, writeable: &'a mut DiplomatWriteable) { + if let Ok(err) = self.general_error() { + let _ = write!(writeable, "{err}"); + } + } + + /// Gets the error code (for GeneralError variant) + pub fn get_error_code(&self) -> Result> { + let err = self.general_error()?; + Ok(err.error_code) + } + + /// Gets the HTTP status code if present (for GeneralError variant) + /// Returns error if not present or not a GeneralError variant + pub fn get_http_status_code(&self) -> Result> { + let err = self.general_error()?; + + err.http_status_code + .ok_or_else(|| GenericError(anyhow::anyhow!("HTTP status code not present")).into()) + } + + fn missing_field(field: &'static str) -> Box { + GenericError(anyhow::anyhow!("RDCleanPath is missing {field} field")).into() + } + + fn general_error(&self) -> Result<&ironrdp_rdcleanpath::RDCleanPathErr, Box> { + let error = self.0.error.as_ref().ok_or_else(|| Self::missing_field("error"))?; + + if error.error_code == ironrdp_rdcleanpath::NEGOTIATION_ERROR_CODE { + Err(GenericError(anyhow::anyhow!("not a GeneralError variant")).into()) + } else { + Ok(error) + } + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum RDCleanPathResultType { + Request, + Response, + GeneralError, + NegotiationError, + } + + #[diplomat::opaque] + pub struct RDCleanPathDetectionResult(pub ironrdp_rdcleanpath::DetectionResult); + + impl RDCleanPathDetectionResult { + pub fn is_detected(&self) -> bool { + matches!(self.0, ironrdp_rdcleanpath::DetectionResult::Detected { .. }) + } + + pub fn is_not_enough_bytes(&self) -> bool { + matches!(self.0, ironrdp_rdcleanpath::DetectionResult::NotEnoughBytes) + } + + pub fn is_failed(&self) -> bool { + matches!(self.0, ironrdp_rdcleanpath::DetectionResult::Failed) + } + + pub fn get_total_length(&self) -> Result> { + if let ironrdp_rdcleanpath::DetectionResult::Detected { total_length, .. } = self.0 { + Ok(total_length) + } else { + Err(GenericError(anyhow::anyhow!("detection result is not Detected variant")).into()) + } + } + } + + #[diplomat::opaque] + pub struct CertificateChainIterator { + certs: Vec>, + index: usize, + } + + impl CertificateChainIterator { + pub fn next(&mut self) -> Option> { + if self.index < self.certs.len() { + let cert = self.certs[self.index].clone(); + self.index += 1; + Some(Box::new(VecU8(cert))) + } else { + None + } + } + + pub fn len(&self) -> usize { + self.certs.len() + } + + pub fn is_empty(&self) -> bool { + self.certs.is_empty() + } + } +} diff --git a/ffi/src/session/mod.rs b/ffi/src/session/mod.rs index 6a6b1fa5bd..29659e9dfd 100644 --- a/ffi/src/session/mod.rs +++ b/ffi/src/session/mod.rs @@ -2,11 +2,11 @@ pub mod image; #[diplomat::bridge] pub mod ffi { - use super::image::ffi::DecodedImage; use crate::clipboard::message::ffi::{ClipboardFormatId, ClipboardFormatIterator, FormatDataResponse}; use crate::connector::activation::ffi::ConnectionActivationSequence; use crate::connector::result::ffi::ConnectionResult; + use crate::dvc::dvc_pipe_proxy_message_queue::ffi::DvcPipeProxyMessage; use crate::error::ffi::IronRdpError; use crate::error::{IncorrectEnumTypeError, ValueConsumedError}; use crate::graphics::ffi::DecodedPointer; @@ -14,7 +14,10 @@ pub mod ffi { use crate::utils::ffi::{BytesSlice, Position, VecU8}; #[diplomat::opaque] - pub struct ActiveStage(pub ironrdp::session::ActiveStage); + pub struct ActiveStage( + pub ironrdp::session::ActiveStage, + pub ironrdp::connector::connection_activation::ConnectionActivationFactory, + ); #[diplomat::opaque] pub struct ActiveStageOutput(pub ironrdp::session::ActiveStageOutput); @@ -38,12 +41,36 @@ pub mod ffi { impl ActiveStage { pub fn new(connection_result: &mut ConnectionResult) -> Result, Box> { - Ok(Box::new(ActiveStage(ironrdp::session::ActiveStage::new( - connection_result - .0 - .take() - .ok_or_else(|| ValueConsumedError::for_item("connection_result"))?, - )))) + let connection_result = connection_result + .0 + .take() + .ok_or_else(|| ValueConsumedError::for_item("connection_result"))?; + + // Retain the factory to drive the Deactivation-Reactivation Sequence. + let activation_factory = connection_result.activation_factory; + + let stage = ironrdp::session::ActiveStageBuilder { + static_channels: connection_result.static_channels, + user_channel_id: connection_result.user_channel_id, + io_channel_id: connection_result.io_channel_id, + message_channel_id: connection_result.message_channel_id, + share_id: connection_result.share_id, + compression_type: connection_result.compression_type, + enable_server_pointer: connection_result.enable_server_pointer, + pointer_software_rendering: connection_result.pointer_software_rendering, + } + .build(); + + Ok(Box::new(ActiveStage(stage, activation_factory))) + } + + /// Produces a fresh connection activation sequence to drive the Deactivation-Reactivation + /// Sequence. + /// + /// Call this upon receiving a [`ActiveStageOutputType::DeactivateAll`] output, drive the + /// returned sequence until it is finalized, then discard it. + pub fn create_connection_activation(&self) -> Box { + Box::new(ConnectionActivationSequence(Box::new(self.1.create()))) } pub fn process( @@ -74,7 +101,7 @@ pub mod ffi { let formats = formats.0.clone(); let clipboard = self .0 - .get_svc_processor::() + .get_svc_processor_mut::() .ok_or("clipboard svc processor not found in active stage")?; let result = clipboard.initiate_copy(&formats)?; @@ -91,7 +118,7 @@ pub mod ffi { let format_id = format_id.0; let clipboard = self .0 - .get_svc_processor::() + .get_svc_processor_mut::() .ok_or("clipboard svc processor not found in active stage")?; let result = clipboard.initiate_paste(format_id)?; @@ -121,6 +148,20 @@ pub mod ffi { Ok(Box::new(VecU8(frame))) } + pub fn send_dvc_pipe_proxy_message( + &mut self, + message: &mut DvcPipeProxyMessage, + ) -> Result, Box> { + let messages = core::mem::take(&mut message.0.1); + + if messages.is_empty() { + return Err("no dvc messages to send (message sent twice?)".into()); + } + + let frame = self.0.encode_dvc_messages(messages)?; + Ok(Box::new(VecU8(frame))) + } + pub fn graceful_shutdown(&mut self) -> Result, Box> { let outputs = self.0.graceful_shutdown()?; Ok(Box::new(ActiveStageOutputIterator(outputs))) @@ -149,6 +190,7 @@ pub mod ffi { &mut self, io_channel_id: u16, user_channel_id: u16, + share_id: u32, enable_server_pointer: bool, pointer_software_rendering: bool, ) { @@ -156,11 +198,14 @@ pub mod ffi { ironrdp::session::fast_path::ProcessorBuilder { io_channel_id, user_channel_id, + share_id, enable_server_pointer, pointer_software_rendering, + bulk_decompressor: None, } .build(), ); + self.0.set_share_id(share_id); } pub fn set_enable_server_pointer(&mut self, enable_server_pointer: bool) { @@ -177,6 +222,11 @@ pub mod ffi { PointerBitmap, Terminate, DeactivateAll, + MultitransportRequest, + /// Auto-detect network characteristics from server. + /// Use `get_autodetect_network_characteristics()` to retrieve + /// RTT and bandwidth values for connection quality monitoring. + AutoDetect, } impl ActiveStageOutput { @@ -189,7 +239,11 @@ pub mod ffi { ironrdp::session::ActiveStageOutput::PointerPosition { .. } => ActiveStageOutputType::PointerPosition, ironrdp::session::ActiveStageOutput::PointerBitmap { .. } => ActiveStageOutputType::PointerBitmap, ironrdp::session::ActiveStageOutput::Terminate { .. } => ActiveStageOutputType::Terminate, - ironrdp::session::ActiveStageOutput::DeactivateAll { .. } => ActiveStageOutputType::DeactivateAll, + ironrdp::session::ActiveStageOutput::DeactivateAll => ActiveStageOutputType::DeactivateAll, + ironrdp::session::ActiveStageOutput::MultitransportRequest { .. } => { + ActiveStageOutputType::MultitransportRequest + } + ironrdp::session::ActiveStageOutput::AutoDetect { .. } => ActiveStageOutputType::AutoDetect, } } @@ -244,17 +298,70 @@ pub mod ffi { .map(Box::new) } - pub fn get_deactivate_all(&self) -> Result, Box> { + /// Returns the multitransport request ID and requested protocol. + /// + /// The security cookie is intentionally not exposed — it is sensitive + /// and only needed internally for transport binding. + #[expect( + clippy::as_conversions, + reason = "RequestedProtocol is #[repr(u16)], cast is lossless" + )] + pub fn get_multitransport_request(&self) -> Result> { match &self.0 { - ironrdp::session::ActiveStageOutput::DeactivateAll(cas) => { - Ok(ConnectionActivationSequence(cas.clone())) - } - _ => Err(IncorrectEnumTypeError::on_variant("DeactivateAll") + ironrdp::session::ActiveStageOutput::MultitransportRequest(pdu) => Ok(MultitransportRequest { + request_id: pdu.request_id, + requested_protocol: pdu.requested_protocol as u16, + }), + _ => Err(IncorrectEnumTypeError::on_variant("MultitransportRequest") .of_enum("ActiveStageOutput") .into()), } - .map(Box::new) } + + /// Connection quality signals from the server's auto-detect mechanism. + /// Returns RTT and bandwidth measurements for health monitoring. + /// These values will feed into FramePacingFeedback when the + /// library-level health observer traits from #1158 land. + pub fn get_autodetect_network_characteristics(&self) -> Result> { + match &self.0 { + ironrdp::session::ActiveStageOutput::AutoDetect( + ironrdp::pdu::rdp::autodetect::AutoDetectRequest::NetworkCharacteristicsResult { + base_rtt_ms, + bandwidth_kbps, + average_rtt_ms, + .. + }, + ) => Ok(NetworkCharacteristics { + base_rtt_ms: base_rtt_ms.unwrap_or(0), + has_base_rtt: base_rtt_ms.is_some(), + average_rtt_ms: *average_rtt_ms, + bandwidth_kbps: bandwidth_kbps.unwrap_or(0), + has_bandwidth: bandwidth_kbps.is_some(), + }), + _ => Err(IncorrectEnumTypeError::on_variant("AutoDetect") + .of_enum("ActiveStageOutput") + .into()), + } + } + } + + /// Connection quality measurements from server auto-detect (MS-RDPBCGR 2.2.14). + pub struct NetworkCharacteristics { + /// Lowest detected round-trip time in milliseconds. + /// Only valid when `has_base_rtt` is true. + pub base_rtt_ms: u32, + pub has_base_rtt: bool, + /// Current average round-trip time in milliseconds. + pub average_rtt_ms: u32, + /// Estimated bandwidth in kilobits per second. + /// Only valid when `has_bandwidth` is true. + pub bandwidth_kbps: u32, + pub has_bandwidth: bool, + } + + pub struct MultitransportRequest { + pub request_id: u32, + pub requested_protocol: u16, } #[diplomat::opaque] diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 8211bebd70..988a0de636 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -19,9 +19,9 @@ dependencies = [ [[package]] name = "asn1-rs" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -57,39 +57,33 @@ dependencies = [ [[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 = "base64ct" -version = "1.8.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bit_field" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" - -[[package]] -name = "bitflags" -version = "1.3.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -114,10 +108,11 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cc" -version = "1.2.29" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -125,9 +120,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "const-oid" @@ -146,18 +141,18 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -201,9 +196,9 @@ dependencies = [ [[package]] name = "derive_arbitrary" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", @@ -222,9 +217,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -240,6 +235,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flagset" version = "0.4.7" @@ -248,9 +249,9 @@ checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" [[package]] name = "flate2" -version = "1.1.2" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -274,21 +275,24 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.3" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi", - "wasi", ] +[[package]] +name = "ironrdp-bulk" +version = "0.1.1" + [[package]] name = "ironrdp-cliprdr" -version = "0.3.0" +version = "0.7.0" dependencies = [ - "bitflags 2.9.1", + "bitflags", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", @@ -297,7 +301,7 @@ dependencies = [ [[package]] name = "ironrdp-cliprdr-format" -version = "0.1.3" +version = "0.2.0" dependencies = [ "ironrdp-core", "png", @@ -305,14 +309,14 @@ dependencies = [ [[package]] name = "ironrdp-core" -version = "0.1.5" +version = "0.2.1" dependencies = [ "ironrdp-error", ] [[package]] name = "ironrdp-displaycontrol" -version = "0.3.0" +version = "0.8.0" dependencies = [ "ironrdp-core", "ironrdp-dvc", @@ -323,18 +327,30 @@ dependencies = [ [[package]] name = "ironrdp-dvc" -version = "0.3.1" +version = "0.8.0" dependencies = [ "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", - "slab", + "tracing", +] + +[[package]] +name = "ironrdp-egfx" +version = "0.3.0" +dependencies = [ + "bit_field", + "bitflags", + "ironrdp-core", + "ironrdp-dvc", + "ironrdp-graphics", + "ironrdp-pdu", "tracing", ] [[package]] name = "ironrdp-error" -version = "0.1.3" +version = "0.2.0" [[package]] name = "ironrdp-fuzz" @@ -349,10 +365,12 @@ name = "ironrdp-fuzzing" version = "0.0.0" dependencies = [ "arbitrary", + "ironrdp-bulk", "ironrdp-cliprdr", "ironrdp-cliprdr-format", "ironrdp-core", "ironrdp-displaycontrol", + "ironrdp-egfx", "ironrdp-graphics", "ironrdp-pdu", "ironrdp-rdpdr", @@ -362,15 +380,14 @@ dependencies = [ [[package]] name = "ironrdp-graphics" -version = "0.4.1" +version = "0.9.0" dependencies = [ "bit_field", - "bitflags 2.9.1", + "bitflags", "bitvec", "byteorder", "ironrdp-core", "ironrdp-pdu", - "lazy_static", "num-derive", "num-traits", "yuv", @@ -378,10 +395,10 @@ dependencies = [ [[package]] name = "ironrdp-pdu" -version = "0.5.0" +version = "0.9.0" dependencies = [ "bit_field", - "bitflags 2.9.1", + "bitflags", "byteorder", "der-parser", "ironrdp-core", @@ -394,15 +411,14 @@ dependencies = [ "pkcs1", "sha1", "tap", - "thiserror", "x509-cert", ] [[package]] name = "ironrdp-rdpdr" -version = "0.3.0" +version = "0.7.0" dependencies = [ - "bitflags 2.9.1", + "bitflags", "ironrdp-core", "ironrdp-error", "ironrdp-pdu", @@ -412,9 +428,9 @@ dependencies = [ [[package]] name = "ironrdp-rdpsnd" -version = "0.5.0" +version = "0.9.0" dependencies = [ - "bitflags 2.9.1", + "bitflags", "ironrdp-core", "ironrdp-pdu", "ironrdp-svc", @@ -423,40 +439,34 @@ dependencies = [ [[package]] name = "ironrdp-svc" -version = "0.4.1" +version = "0.8.0" dependencies = [ - "bitflags 2.9.1", + "bitflags", "ironrdp-core", "ironrdp-pdu", ] [[package]] name = "jobserver" -version = "0.1.33" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ "getrandom", "libc", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" -version = "0.2.174" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libfuzzer-sys" -version = "0.4.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" dependencies = [ "arbitrary", "cc", @@ -464,9 +474,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "md-5" @@ -480,9 +490,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.5" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "minimal-lexical" @@ -512,9 +522,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", @@ -551,15 +561,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[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 = "pkcs1" @@ -573,11 +583,11 @@ dependencies = [ [[package]] name = "png" -version = "0.17.16" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 1.3.2", + "bitflags", "crc32fast", "fdeflate", "flate2", @@ -586,27 +596,27 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.3.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "radium" @@ -625,9 +635,9 @@ dependencies = [ [[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", @@ -636,21 +646,15 @@ dependencies = [ [[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 = "simd-adler32" -version = "0.3.7" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" - -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "spki" @@ -664,9 +668,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -692,18 +696,18 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "thiserror" -version = "2.0.16" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.16" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -733,9 +737,9 @@ dependencies = [ [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -745,9 +749,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -756,24 +760,24 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] [[package]] name = "typenum" -version = "1.18.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "version_check" @@ -781,24 +785,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" -dependencies = [ - "wit-bindgen-rt", -] - -[[package]] -name = "wit-bindgen-rt" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.1", -] - [[package]] name = "wyz" version = "0.5.1" @@ -822,27 +808,27 @@ dependencies = [ [[package]] name = "yuv" -version = "0.8.6" +version = "0.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b08262a503468e0123115a872ac2fd250f965e0178489d393686e9dd19b47e6" +checksum = "5d85a782d94ee43f078bcfd6fa82d4e6a5b2d1cfbbad168e4df5a9f7b39ef48c" dependencies = [ "num-traits", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 12fdd2aa67..74ba4c0f6c 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -48,3 +48,52 @@ test = false doc = false bench = false +[[bin]] +name = "cliprdr_channel_processing" +path = "fuzz_targets/cliprdr_channel_processing.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "bulk_mppc" +path = "fuzz_targets/bulk_mppc.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "bulk_ncrush" +path = "fuzz_targets/bulk_ncrush.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "bulk_xcrush" +path = "fuzz_targets/bulk_xcrush.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "bulk_round_trip" +path = "fuzz_targets/bulk_round_trip.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "pdu_round_trip" +path = "fuzz_targets/pdu_round_trip.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "egfx_round_trip" +path = "fuzz_targets/egfx_round_trip.rs" +test = false +doc = false +bench = false + diff --git a/fuzz/README.md b/fuzz/README.md index 07fc11a6e5..7f1369dc1e 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -34,3 +34,59 @@ Feeds random inputs to the RDP6 bitmap decoder. ### `rle_decompression` Feeds random inputs to the interleaved Run-Length Encoding (RLE) bitmap decoder. + +### `bulk_mppc` + +Feeds random inputs to the MPPC bulk decompressor (`ironrdp-bulk`). The first +input byte selects between RDP4 mode (low bit clear, 8 KB history) and RDP5 +mode (low bit set, 64 KB history); remaining bytes are the compressed payload. + +### `bulk_ncrush` + +Feeds random inputs to the NCRUSH bulk decompressor (RDP6.0, `ironrdp-bulk`). + +### `bulk_xcrush` + +Feeds random inputs to the XCRUSH bulk decompressor (RDP6.1, `ironrdp-bulk`). +XCRUSH has the largest sliding-window history of the bulk family (2 MB). + +### `bulk_round_trip` + +Compresses arbitrary input via `BulkCompressor::compress`, then decompresses +the result via `BulkCompressor::decompress`, and asserts byte-equality with +the original input. First input byte selects the algorithm (RDP4 / RDP5 / +RDP6 / RDP6.1). Catches asymmetric bugs where the compressor produces output +the decompressor cannot consume, or the decompressor produces output that +does not equal the original input. + +## Building crates with the `arbitrary` feature + +Several crates expose an optional `arbitrary` feature that enables +[`arbitrary::Arbitrary`](https://docs.rs/arbitrary) implementations on their +PDU types. This is the foundation for structure-aware fuzzing harnesses that +generate valid-looking inputs rather than raw bytes. + +To verify the feature compiles cleanly for a single crate: + +```shell +cargo check -p ironrdp-pdu --features arbitrary +``` + +The feature is also compatible with the `no_std + alloc` build path: + +```shell +cargo check -p ironrdp-pdu --no-default-features --features arbitrary,alloc +``` + +A handful of PDU types do not implement `Arbitrary`. They fall into two categories: + +- **Types with non-derivable fields** (e.g., `StaticChannelSet` keyed by `TypeId`). + These are either skipped via `#[arbitrary(default)]` on the containing struct's + field, or hand-rolled with a placeholder. +- **Error types that are not part of the wire-protocol surface** (e.g., + `ServerLicenseError`). Most error enums fall here: they are constructed locally + rather than decoded from the wire, so the fuzzer has no reason to generate them. + The exception is wire-protocol error PDUs such as `DisconnectProviderUltimatum` + in `mcs.rs`, which do implement `Arbitrary` because they are decoded from the wire. + +Inline source comments mark each skip with the rationale. diff --git a/fuzz/fuzz_targets/bulk_mppc.rs b/fuzz/fuzz_targets/bulk_mppc.rs new file mode 100644 index 0000000000..1bdfeb0162 --- /dev/null +++ b/fuzz/fuzz_targets/bulk_mppc.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + ironrdp_fuzzing::oracles::bulk_decompress_mppc(data); +}); diff --git a/fuzz/fuzz_targets/bulk_ncrush.rs b/fuzz/fuzz_targets/bulk_ncrush.rs new file mode 100644 index 0000000000..ba4a9150df --- /dev/null +++ b/fuzz/fuzz_targets/bulk_ncrush.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + ironrdp_fuzzing::oracles::bulk_decompress_ncrush(data); +}); diff --git a/fuzz/fuzz_targets/bulk_round_trip.rs b/fuzz/fuzz_targets/bulk_round_trip.rs new file mode 100644 index 0000000000..720796183f --- /dev/null +++ b/fuzz/fuzz_targets/bulk_round_trip.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + ironrdp_fuzzing::oracles::bulk_round_trip(data); +}); diff --git a/fuzz/fuzz_targets/bulk_xcrush.rs b/fuzz/fuzz_targets/bulk_xcrush.rs new file mode 100644 index 0000000000..786de1f462 --- /dev/null +++ b/fuzz/fuzz_targets/bulk_xcrush.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + ironrdp_fuzzing::oracles::bulk_decompress_xcrush(data); +}); diff --git a/fuzz/fuzz_targets/cliprdr_channel_processing.rs b/fuzz/fuzz_targets/cliprdr_channel_processing.rs new file mode 100644 index 0000000000..61aba27485 --- /dev/null +++ b/fuzz/fuzz_targets/cliprdr_channel_processing.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + ironrdp_fuzzing::oracles::cliprdr_channel_process(data); +}); diff --git a/fuzz/fuzz_targets/egfx_round_trip.rs b/fuzz/fuzz_targets/egfx_round_trip.rs new file mode 100644 index 0000000000..ae3e3f7feb --- /dev/null +++ b/fuzz/fuzz_targets/egfx_round_trip.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + ironrdp_fuzzing::oracles::egfx_round_trip(data); +}); diff --git a/fuzz/fuzz_targets/pdu_round_trip.rs b/fuzz/fuzz_targets/pdu_round_trip.rs new file mode 100644 index 0000000000..9e9e545323 --- /dev/null +++ b/fuzz/fuzz_targets/pdu_round_trip.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + ironrdp_fuzzing::oracles::pdu_round_trip(data); +}); diff --git a/release-plz.toml b/release-plz.toml index 79409f23c9..fc831fff74 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -7,16 +7,27 @@ pr_name = "chore(release): prepare for publishing" changelog_config = "cliff.toml" release_commits = "^(feat|docs|fix|build|perf)" -# Flagship crate for which we push a GitHub release. +# Executable crates for which we push a GitHub release (and attach prebuilt binaries via +# release-binaries.yml). Each is released under its own tag (ironrdp-agent-v*, ironrdp-viewer-v*). [[package]] -name = "ironrdp-client" +name = "ironrdp-agent" +git_release_enable = true +[[package]] +name = "ironrdp-viewer" git_release_enable = true -# ironrdp-tls does not compile if no backend is specified. -# rustls is the most common backend, so we let cargo publish check with it. +# ironrdp-tls does not compile if no backend is specified, and the following crates depend on it +# without enabling a backend by default. rustls is the most common backend, so we let cargo publish +# check with it. [[package]] name = "ironrdp-tls" publish_features = ["rustls"] +[[package]] +name = "ironrdp-client" +publish_features = ["rustls"] +[[package]] +name = "ironrdp-mstsgu" +publish_features = ["rustls"] # *-native crates may have all kinds of system requirements depending on the platform. # We can only check for the current platform when cargo publish is invoked, all the others are effectively unverified. diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 7855e6d557..908d2ecba8 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.88.0" +channel = "1.89.0" components = ["rustfmt", "clippy"] diff --git a/typos.toml b/typos.toml index 20a4f9e1fd..238792c57a 100644 --- a/typos.toml +++ b/typos.toml @@ -20,6 +20,14 @@ [files] extend-exclude = ["*.bin", "*.bmp", "package-lock.json"] +[default.extend-words] +# "look-alikes" is a legitimate hyphenated word +alikes = "alikes" +# Windows PostScript printer driver name. +Imagesetter = "Imagesetter" +# Windows Plug and Play field names use this abbreviation. +Pn = "Pn" + [default] extend-ignore-re = [ # Ignore false-positives for auth tokens in the following file: @@ -39,6 +47,14 @@ extend-ignore-re = [ "cll", "CLL", + # False-positive fix for compression length variable in ironrdp-bulk benchmarks: + "\\bclen\\b", + + # False-positive fix for literal test data "BA" in ironrdp-bulk tests: + "\\bBA\\b", + "\\bba\\b", + "hash_ba", + # Commit hashes in CHANGELOG.md files. "\\b[0-9a-f]{7,40}\\b", ] diff --git a/web-client/README.md b/web-client/README.md index ce80ea75b7..69fd58648b 100644 --- a/web-client/README.md +++ b/web-client/README.md @@ -2,6 +2,12 @@ IronRDP also supports the web browser as a first class target. +## Prerequisites + +- Node.js >= 24 LTS (type definitions target `@types/node` ^24.0.0) + +## Overview + See the [iron-remote-desktop](./iron-remote-desktop) for the reusable Web Component, and [iron-svelte-client](./iron-svelte-client) for a demonstration. Note that the demonstration client is not intended to be used in production as-is. diff --git a/web-client/iron-remote-desktop-rdp/.prettierignore b/web-client/iron-remote-desktop-rdp/.prettierignore index f4ae8393fd..ea8bb5f411 100644 --- a/web-client/iron-remote-desktop-rdp/.prettierignore +++ b/web-client/iron-remote-desktop-rdp/.prettierignore @@ -13,3 +13,6 @@ node_modules/ pnpm-lock.yaml package-lock.json yarn.lock + +# Auto-generated by git-cliff +/public/CHANGELOG.md diff --git a/web-client/iron-remote-desktop-rdp/README.md b/web-client/iron-remote-desktop-rdp/README.md index c00407be81..4a12b908df 100644 --- a/web-client/iron-remote-desktop-rdp/README.md +++ b/web-client/iron-remote-desktop-rdp/README.md @@ -21,3 +21,16 @@ $ npm install @devolutions/iron-remote-desktop-rdp Otherwise, you can run `npm install` targeting the `dist/` folder directly. Import the `iron-remote-desktop-rdp.umd.cjs` from `node_modules/` folder. + +## Virtual Printer + +Register `printJobStreamCallbacks` before connecting to enable the browser-side +RDPDR virtual printer. The RDPDR backend forwards write chunks as they arrive +instead of buffering the completed job in Rust. + +By default, the web connector follows FreeRDP's macOS heuristic where possible: +browser-reported macOS 14+ uses `Microsoft Print to PDF`, and other clients use +`MS Publisher Imagesetter` for PostScript data. Pass +`printerDriverName(PrinterDriverName.PostScript)` or another explicit driver if +your target host requires a different installed driver. Jobs larger than 128 MiB +are rejected, and queued write chunks are bounded to protect browser memory. diff --git a/web-client/iron-remote-desktop-rdp/package-lock.json b/web-client/iron-remote-desktop-rdp/package-lock.json index b1c170a06d..a60db559a2 100644 --- a/web-client/iron-remote-desktop-rdp/package-lock.json +++ b/web-client/iron-remote-desktop-rdp/package-lock.json @@ -10,12 +10,14 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.0", "@eslint/js": "^9.21.0", + "@types/node": "^24.0.0", "@types/ua-parser-js": "^0.7.36", "@typescript-eslint/eslint-plugin": "^8.25.0", "eslint": "^9.21.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.1", "globals": "^16.0.0", + "jsdom": "^26.0.0", "prettier": "^3.1.0", "tslib": "^2.4.1", "typescript": "~5.7.2", @@ -23,13 +25,28 @@ "vite": "^6.2.0", "vite-plugin-dts": "^4.5.0", "vite-plugin-top-level-await": "^1.2.2", - "vite-plugin-wasm": "^3.1.0" + "vite-plugin-wasm": "^3.1.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", "engines": { @@ -37,9 +54,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -47,13 +64,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", - "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.26.9" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -63,23 +80,138 @@ } }, "node_modules/@babel/types": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", - "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -94,9 +226,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -111,9 +243,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -128,9 +260,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -145,9 +277,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -162,9 +294,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -179,9 +311,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -196,9 +328,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -213,9 +345,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -230,9 +362,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -247,9 +379,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -264,9 +396,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -281,9 +413,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -298,9 +430,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -315,9 +447,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -332,9 +464,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -349,9 +481,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -366,9 +498,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], @@ -383,9 +515,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -400,9 +532,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -417,9 +549,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -433,10 +565,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -451,9 +600,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -468,9 +617,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -485,9 +634,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -502,9 +651,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -521,9 +670,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -531,48 +680,37 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", - "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, - "license": "ISC", + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^1.1.7" + "@eslint/core": "^0.17.0" }, "engines": { - "node": "*" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -583,20 +721,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", - "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -606,17 +744,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -630,33 +757,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@eslint/js": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.21.0.tgz", - "integrity": "sha512-BqStZ3HX8Yz6LvsF5ByXYrtigrV5AXADWLAGc7PH/1SxOb7/FIYYMszZZWiUou/GB9P2lXWk2SV4d+Z8h0nknw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -664,13 +781,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", - "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.12.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -688,33 +805,19 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -730,9 +833,9 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", - "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -744,71 +847,100 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@microsoft/api-extractor": { - "version": "7.51.1", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.51.1.tgz", - "integrity": "sha512-VoFvIeYXme8QctXDkixy1KIn750kZaFy2snAEOB3nhDFfbBcJNEcvBrpCIQIV09MqI4g9egKUkg+/12WMRC77w==", + "version": "7.58.1", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.58.1.tgz", + "integrity": "sha512-kF3GFME4lN22O5zbnXk2RP4y/4PDQdps0xKiYTipMYprkwCmmpsWLZt/N2Fkbil540cSLfJX0BW7LkHzgMVUYg==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/api-extractor-model": "7.30.3", - "@microsoft/tsdoc": "~0.15.1", - "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.11.0", - "@rushstack/rig-package": "0.5.3", - "@rushstack/terminal": "0.15.0", - "@rushstack/ts-command-line": "4.23.5", - "lodash": "~4.17.15", - "minimatch": "~3.0.3", + "@microsoft/api-extractor-model": "7.33.5", + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.1", + "@rushstack/node-core-library": "5.21.0", + "@rushstack/rig-package": "0.7.2", + "@rushstack/terminal": "0.22.4", + "@rushstack/ts-command-line": "5.3.4", + "diff": "~8.0.2", + "lodash": "~4.18.1", + "minimatch": "10.2.3", "resolve": "~1.22.1", "semver": "~7.5.4", "source-map": "~0.6.1", - "typescript": "5.7.3" + "typescript": "5.9.3" }, "bin": { "api-extractor": "bin/api-extractor" } }, "node_modules/@microsoft/api-extractor-model": { - "version": "7.30.3", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.30.3.tgz", - "integrity": "sha512-yEAvq0F78MmStXdqz9TTT4PZ05Xu5R8nqgwI5xmUmQjWBQ9E6R2n8HB/iZMRciG4rf9iwI2mtuQwIzDXBvHn1w==", + "version": "7.33.5", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.33.5.tgz", + "integrity": "sha512-Xh4dXuusndVQqVz4nEN9xOp0DyzsKxeD2FFJkSPg4arAjDSKPcy6cAc7CaeBPA7kF2wV1fuDlo2p/bNMpVr8yg==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "~0.15.1", - "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.11.0" + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.1", + "@rushstack/node-core-library": "5.21.0" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@microsoft/api-extractor/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/@microsoft/api-extractor/node_modules/minimatch": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", - "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", + "node_modules/@microsoft/api-extractor/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "yallist": "^4.0.0" }, "engines": { - "node": "*" + "node": ">=10" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/minimatch": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", + "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@microsoft/api-extractor/node_modules/semver": { @@ -827,37 +959,51 @@ "node": ">=10" } }, + "node_modules/@microsoft/api-extractor/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@microsoft/tsdoc": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", - "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "dev": true, "license": "MIT" }, "node_modules/@microsoft/tsdoc-config": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.17.1.tgz", - "integrity": "sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.1.tgz", + "integrity": "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "0.15.1", - "ajv": "~8.12.0", + "@microsoft/tsdoc": "0.16.0", + "ajv": "~8.18.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "node_modules/@microsoft/tsdoc-config/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -871,55 +1017,17 @@ "dev": true, "license": "MIT" }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@pkgr/core": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", - "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/unts" + "url": "https://opencollective.com/pkgr" } }, "node_modules/@rollup/plugin-virtual": { @@ -941,9 +1049,9 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -963,23 +1071,10 @@ } } }, - "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.9.tgz", - "integrity": "sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", "cpu": [ "arm" ], @@ -991,9 +1086,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.9.tgz", - "integrity": "sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], @@ -1005,9 +1100,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.9.tgz", - "integrity": "sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", "cpu": [ "arm64" ], @@ -1019,9 +1114,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz", - "integrity": "sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], @@ -1033,9 +1128,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.9.tgz", - "integrity": "sha512-2lzjQPJbN5UnHm7bHIUKFMulGTQwdvOkouJDpPysJS+QFBGDJqcfh+CxxtG23Ik/9tEvnebQiylYoazFMAgrYw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", "cpu": [ "arm64" ], @@ -1047,9 +1142,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.9.tgz", - "integrity": "sha512-SLl0hi2Ah2H7xQYd6Qaiu01kFPzQ+hqvdYSoOtHYg/zCIFs6t8sV95kaoqjzjFwuYQLtOI0RZre/Ke0nPaQV+g==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", "cpu": [ "x64" ], @@ -1061,9 +1156,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.9.tgz", - "integrity": "sha512-88I+D3TeKItrw+Y/2ud4Tw0+3CxQ2kLgu3QvrogZ0OfkmX/DEppehus7L3TS2Q4lpB+hYyxhkQiYPJ6Mf5/dPg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", "cpu": [ "arm" ], @@ -1075,9 +1170,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.9.tgz", - "integrity": "sha512-3qyfWljSFHi9zH0KgtEPG4cBXHDFhwD8kwg6xLfHQ0IWuH9crp005GfoUUh/6w9/FWGBwEHg3lxK1iHRN1MFlA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", "cpu": [ "arm" ], @@ -1089,9 +1184,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz", - "integrity": "sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", "cpu": [ "arm64" ], @@ -1103,9 +1198,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz", - "integrity": "sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", "cpu": [ "arm64" ], @@ -1116,10 +1211,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.9.tgz", - "integrity": "sha512-dRAgTfDsn0TE0HI6cmo13hemKpVHOEyeciGtvlBTkpx/F65kTvShtY/EVyZEIfxFkV5JJTuQ9tP5HGBS0hfxIg==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", "cpu": [ "loong64" ], @@ -1130,12 +1225,12 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.9.tgz", - "integrity": "sha512-PHcNOAEhkoMSQtMf+rJofwisZqaU8iQ8EaSps58f5HYll9EAY5BSErCZ8qBDMVbq88h4UxaNPlbrKqfWP8RfJA==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", "cpu": [ - "ppc64" + "loong64" ], "dev": true, "license": "MIT", @@ -1144,12 +1239,12 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.9.tgz", - "integrity": "sha512-Z2i0Uy5G96KBYKjeQFKbbsB54xFOL5/y1P5wNBsbXB8yE+At3oh0DVMjQVzCJRJSfReiB2tX8T6HUFZ2k8iaKg==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", "cpu": [ - "riscv64" + "ppc64" ], "dev": true, "license": "MIT", @@ -1158,12 +1253,12 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.9.tgz", - "integrity": "sha512-U+5SwTMoeYXoDzJX5dhDTxRltSrIax8KWwfaaYcynuJw8mT33W7oOgz0a+AaXtGuvhzTr2tVKh5UO8GVANTxyQ==", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", "cpu": [ - "s390x" + "ppc64" ], "dev": true, "license": "MIT", @@ -1172,12 +1267,12 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz", - "integrity": "sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==", + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", "cpu": [ - "x64" + "riscv64" ], "dev": true, "license": "MIT", @@ -1186,12 +1281,12 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz", - "integrity": "sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==", + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", "cpu": [ - "x64" + "riscv64" ], "dev": true, "license": "MIT", @@ -1200,38 +1295,38 @@ "linux" ] }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz", - "integrity": "sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==", + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", "cpu": [ - "arm64" + "s390x" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ] }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.9.tgz", - "integrity": "sha512-KB48mPtaoHy1AwDNkAJfHXvHp24H0ryZog28spEs0V48l3H1fr4i37tiyHsgKZJnCmvxsbATdZGBpbmxTE3a9w==", + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", "cpu": [ - "ia32" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ] }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.9.tgz", - "integrity": "sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", "cpu": [ "x64" ], @@ -1239,22 +1334,106 @@ "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ] }, - "node_modules/@rushstack/node-core-library": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.11.0.tgz", - "integrity": "sha512-I8+VzG9A0F3nH2rLpPd7hF8F7l5Xb7D+ldrWVZYegXM6CsKkvWc670RlgK3WX8/AseZfXA/vVrh0bpXe2Y2UDQ==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ajv": "~8.13.0", - "ajv-draft-04": "~1.0.0", - "ajv-formats": "~3.0.1", - "fs-extra": "~11.3.0", - "import-lazy": "~4.0.0", - "jju": "~1.4.0", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rushstack/node-core-library": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.21.0.tgz", + "integrity": "sha512-LFzN+1lyWROit/P8Md6yxAth7lLYKn37oCKJHirEE2TQB25NDUM7bALf0ar+JAtwFfRCH+D+DGOA7DAzIi2r+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "~8.18.0", + "ajv-draft-04": "~1.0.0", + "ajv-formats": "~3.0.1", + "fs-extra": "~11.3.0", + "import-lazy": "~4.0.0", + "jju": "~1.4.0", "resolve": "~1.22.1", "semver": "~7.5.4" }, @@ -1268,16 +1447,16 @@ } }, "node_modules/@rushstack/node-core-library/node_modules/ajv": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz", - "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.4.1" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -1306,6 +1485,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@rushstack/node-core-library/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rushstack/node-core-library/node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -1322,10 +1514,25 @@ "node": ">=10" } }, + "node_modules/@rushstack/problem-matcher": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@rushstack/problem-matcher/-/problem-matcher-0.2.1.tgz", + "integrity": "sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@rushstack/rig-package": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.5.3.tgz", - "integrity": "sha512-olzSSjYrvCNxUFZowevC3uz8gvKr3WTpHQ7BkpjtRpA3wK+T0ybep/SRUMfr195gBzJm5gaXw0ZMgjIyHqJUow==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.7.2.tgz", + "integrity": "sha512-9XbFWuqMYcHUso4mnETfhGVUSaADBRj6HUAAEYk50nMPn8WRICmBuCphycQGNB3duIR6EEZX3Xj3SYc2XiP+9A==", "dev": true, "license": "MIT", "dependencies": { @@ -1334,13 +1541,14 @@ } }, "node_modules/@rushstack/terminal": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.15.0.tgz", - "integrity": "sha512-vXQPRQ+vJJn4GVqxkwRe+UGgzNxdV8xuJZY2zem46Y0p3tlahucH9/hPmLGj2i9dQnUBFiRnoM9/KW7PYw8F4Q==", + "version": "0.22.4", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.22.4.tgz", + "integrity": "sha512-fhtLjnXCc/4WleVbVl6aoc7jcWnU6yqjS1S8WoaNREG3ycu/viZ9R/9QM7Y/b4CDvcXoiDyMNIay7JMwBptM3g==", "dev": true, "license": "MIT", "dependencies": { - "@rushstack/node-core-library": "5.11.0", + "@rushstack/node-core-library": "5.21.0", + "@rushstack/problem-matcher": "0.2.1", "supports-color": "~8.1.1" }, "peerDependencies": { @@ -1369,13 +1577,13 @@ } }, "node_modules/@rushstack/ts-command-line": { - "version": "4.23.5", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.23.5.tgz", - "integrity": "sha512-jg70HfoK44KfSP3MTiL5rxsZH7X1ktX3cZs9Sl8eDu1/LxJSbPsh0MOFRC710lIuYYSgxWjI5AjbCBAl7u3RxA==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.3.4.tgz", + "integrity": "sha512-MLkVKVEN6/2clKTrjN2B2KqKCuPxRwnNsWY7a+FCAq2EMdkj10cM8YgiBSMeGFfzM0mDMzargpHNnNzaBi9Whg==", "dev": true, "license": "MIT", "dependencies": { - "@rushstack/terminal": "0.15.0", + "@rushstack/terminal": "0.22.4", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" @@ -1392,15 +1600,15 @@ } }, "node_modules/@swc/core": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.5.tgz", - "integrity": "sha512-EVY7zfpehxhTZXOfy508gb3D78ihoGGmvyiTWtlBPjgIaidP1Xw0naHMD78CWiFlZmeDjKXJufGtsEGOnZdmNA==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.24.tgz", + "integrity": "sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.19" + "@swc/types": "^0.1.26" }, "engines": { "node": ">=10" @@ -1410,19 +1618,21 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.11.5", - "@swc/core-darwin-x64": "1.11.5", - "@swc/core-linux-arm-gnueabihf": "1.11.5", - "@swc/core-linux-arm64-gnu": "1.11.5", - "@swc/core-linux-arm64-musl": "1.11.5", - "@swc/core-linux-x64-gnu": "1.11.5", - "@swc/core-linux-x64-musl": "1.11.5", - "@swc/core-win32-arm64-msvc": "1.11.5", - "@swc/core-win32-ia32-msvc": "1.11.5", - "@swc/core-win32-x64-msvc": "1.11.5" + "@swc/core-darwin-arm64": "1.15.24", + "@swc/core-darwin-x64": "1.15.24", + "@swc/core-linux-arm-gnueabihf": "1.15.24", + "@swc/core-linux-arm64-gnu": "1.15.24", + "@swc/core-linux-arm64-musl": "1.15.24", + "@swc/core-linux-ppc64-gnu": "1.15.24", + "@swc/core-linux-s390x-gnu": "1.15.24", + "@swc/core-linux-x64-gnu": "1.15.24", + "@swc/core-linux-x64-musl": "1.15.24", + "@swc/core-win32-arm64-msvc": "1.15.24", + "@swc/core-win32-ia32-msvc": "1.15.24", + "@swc/core-win32-x64-msvc": "1.15.24" }, "peerDependencies": { - "@swc/helpers": "*" + "@swc/helpers": ">=0.5.17" }, "peerDependenciesMeta": { "@swc/helpers": { @@ -1431,9 +1641,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.5.tgz", - "integrity": "sha512-GEd1hzEx0mSGkJYMFMGLnrGgjL2rOsOsuYWyjyiA3WLmhD7o+n/EWBDo6mzD/9aeF8dzSPC0TnW216gJbvrNzA==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.24.tgz", + "integrity": "sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==", "cpu": [ "arm64" ], @@ -1448,9 +1658,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.5.tgz", - "integrity": "sha512-toz04z9wAClVvQSEY3xzrgyyeWBAfMWcKG4K0ugNvO56h/wczi2ZHRlnAXZW1tghKBk3z6MXqa/srfXgNhffKw==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.24.tgz", + "integrity": "sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==", "cpu": [ "x64" ], @@ -1465,9 +1675,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.5.tgz", - "integrity": "sha512-5SjmKxXdwbBpsYGTpgeXOXMIjS563/ntRGn8Zc12H/c4VfPrRLGhgbJ/48z2XVFyBLcw7BCHZyFuVX1+ZI3W0Q==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.24.tgz", + "integrity": "sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==", "cpu": [ "arm" ], @@ -1482,9 +1692,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.5.tgz", - "integrity": "sha512-pydIlInHRzRIwB0NHblz3Dx58H/bsi0I5F2deLf9iOmwPNuOGcEEZF1Qatc7YIjP5DFbXK+Dcz+pMUZb2cc2MQ==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.24.tgz", + "integrity": "sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==", "cpu": [ "arm64" ], @@ -1499,9 +1709,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.5.tgz", - "integrity": "sha512-LhBHKjkZq5tJF1Lh0NJFpx7ROnCWLckrlIAIdSt9XfOV+zuEXJQOj+NFcM1eNk17GFfFyUMOZyGZxzYq5dveEQ==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.24.tgz", + "integrity": "sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==", "cpu": [ "arm64" ], @@ -1515,10 +1725,44 @@ "node": ">=10" } }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.24.tgz", + "integrity": "sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.24.tgz", + "integrity": "sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.5.tgz", - "integrity": "sha512-dCi4xkxXlsk5sQYb3i413Cfh7+wMJeBYTvBZTD5xh+/DgRtIcIJLYJ2tNjWC4/C2i5fj+Ze9bKNSdd8weRWZ3A==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.24.tgz", + "integrity": "sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==", "cpu": [ "x64" ], @@ -1533,9 +1777,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.5.tgz", - "integrity": "sha512-K0AC4TreM5Oo/tXNXnE/Gf5+5y/HwUdd7xvUjOpZddcX/RlsbYOKWLgOtA3fdFIuta7XC+vrGKmIhm5l70DSVQ==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.24.tgz", + "integrity": "sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==", "cpu": [ "x64" ], @@ -1550,9 +1794,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.5.tgz", - "integrity": "sha512-wzum8sYUsvPY7kgUfuqVYTgIPYmBC8KPksoNM1fz5UfhudU0ciQuYvUBD47GIGOevaoxhLkjPH4CB95vh1mJ9w==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.24.tgz", + "integrity": "sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==", "cpu": [ "arm64" ], @@ -1567,9 +1811,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.5.tgz", - "integrity": "sha512-lco7mw0TPRTpVPR6NwggJpjdUkAboGRkLrDHjIsUaR+Y5+0m5FMMkHOMxWXAbrBS5c4ph7QErp4Lma4r9Mn5og==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.24.tgz", + "integrity": "sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==", "cpu": [ "ia32" ], @@ -1584,9 +1828,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.5.tgz", - "integrity": "sha512-E+DApLSC6JRK8VkDa4bNsBdD7Qoomx1HvKVZpOXl9v94hUZI5GMExl4vU5isvb+hPWL7rZ0NeI7ITnVLgLJRbA==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.24.tgz", + "integrity": "sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==", "cpu": [ "x64" ], @@ -1608,15 +1852,22 @@ "license": "Apache-2.0" }, "node_modules/@swc/types": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.19.tgz", - "integrity": "sha512-WkAZaAfj44kh/UFdAQcrMP1I0nwRqpt27u+08LMBYMqmQfwwMofYoMh/48NGkMMRfC4ynpfwRbJuu8ErfNloeA==", + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" } }, + "node_modules/@swc/wasm": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.24.tgz", + "integrity": "sha512-vFjzOE8dhJcfeTbM4+HO9Qy58IINV0ysqStAgw81uds+KqCeUDM9huN+SZ5lWZ6U+5nf8VcZoEw5N81xMtAidg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@types/argparse": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", @@ -1624,10 +1875,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -1638,6 +1907,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@types/ua-parser-js": { "version": "0.7.39", "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz", @@ -1646,21 +1925,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.25.0.tgz", - "integrity": "sha512-VM7bpzAe7JO/BFf40pIT1lJqS/z1F8OaSsUB3rpFJucQA4cOSuH2RVVVkFULN+En0Djgr29/jb4EQnedUo95KA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.25.0", - "@typescript-eslint/type-utils": "8.25.0", - "@typescript-eslint/utils": "8.25.0", - "@typescript-eslint/visitor-keys": "8.25.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1670,24 +1948,34 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.25.0.tgz", - "integrity": "sha512-4gbs64bnbSzu4FpgMiQ1A+D+urxkoJk/kqlDJ2W//5SygaEiAP2B4GoS7TEdxgwol2el03gckFV9lJ4QOMiiHg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.25.0", - "@typescript-eslint/types": "8.25.0", - "@typescript-eslint/typescript-estree": "8.25.0", - "@typescript-eslint/visitor-keys": "8.25.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1697,19 +1985,41 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.25.0.tgz", - "integrity": "sha512-6PPeiKIGbgStEyt4NNXa2ru5pMzQ8OYKO1hX1z53HMomrmiSB+R5FmChgQAP1ro8jMtNawz+TRQo/cSXrauTpg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.25.0", - "@typescript-eslint/visitor-keys": "8.25.0" + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1719,17 +2029,35 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.25.0.tgz", - "integrity": "sha512-d77dHgHWnxmXOPJuDWO4FDWADmGQkN5+tt6SFRZz/RtCWl4pHgFl3+WdYCn16+3teG09DY6XtEpf3gGD0a186g==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.25.0", - "@typescript-eslint/utils": "8.25.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1739,14 +2067,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.25.0.tgz", - "integrity": "sha512-+vUe0Zb4tkNgznQwicsvLUJgZIRs6ITeWSCclX1q85pR1iOiaj+4uZJIUp//Z27QWu5Cseiw3O3AR8hVpax7Aw==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", "dev": true, "license": "MIT", "engines": { @@ -1758,20 +2086,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.25.0.tgz", - "integrity": "sha512-ZPaiAKEZ6Blt/TPAx5Ot0EIB/yGtLI2EsGoY6F7XKklfMxYQyvtL+gT/UCqkMzO0BVFHLDlzvFqQzurYahxv9Q==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.25.0", - "@typescript-eslint/visitor-keys": "8.25.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1781,20 +2110,59 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.25.0.tgz", - "integrity": "sha512-syqRbrEv0J1wywiLsK60XzHnQe/kRViI3zwFALrNEgnntn1l24Ra2KvOAWwWbWZ1lBZxZljPDGOq967dsl6fkA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.25.0", - "@typescript-eslint/types": "8.25.0", - "@typescript-eslint/typescript-estree": "8.25.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1804,19 +2172,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.25.0.tgz", - "integrity": "sha512-kCYXKAum9CecGVHGij7muybDfTS2sD3t0L4bJsEZLkyrXUImiCTq1M3LG2SRtOhiHFwMR9wAFplpT6XHYjTkwQ==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.25.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1827,70 +2195,208 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.11.tgz", - "integrity": "sha512-lN2C1+ByfW9/JRPpqScuZt/4OrUUse57GLI6TbLgTIqBVemdl1wNcZ1qYGEo2+Gw8coYLgCy7SuKqn6IrQcQgg==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", "dev": true, "license": "MIT", "dependencies": { - "@volar/source-map": "2.4.11" + "@volar/source-map": "2.4.28" } }, "node_modules/@volar/source-map": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.11.tgz", - "integrity": "sha512-ZQpmafIGvaZMn/8iuvCFGrW3smeqkq/IIh9F1SdSx9aUl0J4Iurzd6/FhmjNO5g2ejF3rT45dKskgXWiofqlZQ==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", "dev": true, "license": "MIT" }, "node_modules/@volar/typescript": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.11.tgz", - "integrity": "sha512-2DT+Tdh88Spp5PyPbqhyoYavYCPDsqbHLFwcUI9K1NlY1YgUJvujGdrqUp0zWxnW7KWNTr3xSpMuv2WnaTKDAw==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.11", + "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "node_modules/@vue/compiler-core": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", - "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.3", - "@vue/shared": "3.5.13", - "entities": "^4.5.0", + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", "estree-walker": "^2.0.2", - "source-map-js": "^1.2.0" + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", - "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" } }, "node_modules/@vue/compiler-vue2": { @@ -1920,26 +2426,52 @@ "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, - "peerDependencies": { - "typescript": "*" + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vue/language-core/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@vue/shared": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", - "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", "dev": true, "license": "MIT" }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -1959,10 +2491,20 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1995,9 +2537,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -2048,6 +2590,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2056,24 +2608,22 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, "engines": { "node": ">=8" } @@ -2088,6 +2638,23 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2105,6 +2672,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2140,9 +2717,9 @@ "license": "MIT" }, "node_modules/confbox": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.1.tgz", - "integrity": "sha512-hkT3yDPFbs95mNCy1+7qNKC6Pro+/ibzYxtM2iqEigpf0sVw+bg4Zh9/snjsBcf990vfIsg5+1U7VyiyBb3etg==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "dev": true, "license": "MIT" }, @@ -2161,6 +2738,34 @@ "node": ">= 8" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -2169,9 +2774,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -2186,6 +2791,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2193,10 +2815,20 @@ "dev": true, "license": "MIT" }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -2206,10 +2838,17 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2220,31 +2859,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/escape-string-regexp": { @@ -2261,32 +2901,32 @@ } }, "node_modules/eslint": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.21.0.tgz", - "integrity": "sha512-KjeihdFqTPhOMXTt7StsDxriV4n66ueuF/jfPNC3j/lduHwr/ijDwJMsF+wyMJethgiKi5wniIE243vi07d3pg==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.2", - "@eslint/core": "^0.12.0", - "@eslint/eslintrc": "^3.3.0", - "@eslint/js": "9.21.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -2298,7 +2938,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -2321,9 +2961,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", - "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", "bin": { @@ -2334,14 +2974,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", - "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", "dev": true, "license": "MIT", "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.9.1" + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -2352,7 +2992,7 @@ "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", - "eslint-config-prettier": "*", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "peerDependenciesMeta": { @@ -2365,9 +3005,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", - "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2394,21 +3034,10 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2418,29 +3047,16 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2450,9 +3066,9 @@ } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2463,9 +3079,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2515,10 +3131,20 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.1.tgz", - "integrity": "sha512-Smf0iQtkQVJLaph8r/qS8C8SWfQkaq9Q/dFcD44MLbJj6DNhlWefVuaS21SjfqOsBbjVlKtbCj6L9ekXK6EZUg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "dev": true, "license": "MIT" }, @@ -2536,36 +3162,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2581,9 +3177,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "dev": true, "funding": [ { @@ -2597,14 +3193,22 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, "node_modules/file-entry-cache": { @@ -2620,19 +3224,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2665,16 +3256,16 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/fs-extra": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", - "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", "dev": true, "license": "MIT", "dependencies": { @@ -2725,9 +3316,9 @@ } }, "node_modules/globals": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.0.0.tgz", - "integrity": "sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", "engines": { @@ -2744,13 +3335,6 @@ "dev": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2784,6 +3368,60 @@ "he": "bin/he" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2870,15 +3508,12 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } + "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", @@ -2894,10 +3529,17 @@ "dev": true, "license": "MIT" }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -2907,6 +3549,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2929,9 +3611,9 @@ "license": "MIT" }, "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "dependencies": { @@ -2973,15 +3655,15 @@ } }, "node_modules/local-pkg": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.1.tgz", - "integrity": "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", "dev": true, "license": "MIT", "dependencies": { "mlly": "^1.7.4", - "pkg-types": "^2.0.1", - "quansync": "^0.2.8" + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" }, "engines": { "node": ">=14" @@ -3007,9 +3689,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -3020,80 +3702,54 @@ "dev": true, "license": "MIT" }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } + "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } + "license": "ISC" }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "*" } }, "node_modules/mlly": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", - "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", "dev": true, "license": "MIT", "dependencies": { - "acorn": "^8.14.0", - "pathe": "^2.0.1", - "pkg-types": "^1.3.0", - "ufo": "^1.5.4" + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" } }, "node_modules/mlly/node_modules/confbox": { @@ -3130,9 +3786,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -3155,6 +3811,13 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3218,6 +3881,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -3259,6 +3935,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3267,34 +3953,34 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/pkg-types": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.1.0.tgz", - "integrity": "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", "dev": true, "license": "MIT", "dependencies": { - "confbox": "^0.2.1", - "exsolve": "^1.0.1", + "confbox": "^0.2.2", + "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -3312,7 +3998,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3331,9 +4017,9 @@ } }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -3347,9 +4033,9 @@ } }, "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", "dev": true, "license": "MIT", "dependencies": { @@ -3370,9 +4056,9 @@ } }, "node_modules/quansync": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.8.tgz", - "integrity": "sha512-4+saucphJMazjt7iOM27mbFCk+D9dd/zmgMDCzRZ8MEoBfYp7lAvoN38et/phRQF6wOPMy/OROBGgoWeSKyluA==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", "dev": true, "funding": [ { @@ -3386,27 +4072,6 @@ ], "license": "MIT" }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3418,13 +4083,13 @@ } }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -3448,25 +4113,14 @@ "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.9.tgz", - "integrity": "sha512-nF5XYqWWp9hx/LrpC8sZvvvmq0TeTjQgaZHYmAgwysT9nh8sWnZhBnM8ZyVbbJFIQBLwHDNoMqsBZBbUo4U8sQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -3476,56 +4130,65 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.34.9", - "@rollup/rollup-android-arm64": "4.34.9", - "@rollup/rollup-darwin-arm64": "4.34.9", - "@rollup/rollup-darwin-x64": "4.34.9", - "@rollup/rollup-freebsd-arm64": "4.34.9", - "@rollup/rollup-freebsd-x64": "4.34.9", - "@rollup/rollup-linux-arm-gnueabihf": "4.34.9", - "@rollup/rollup-linux-arm-musleabihf": "4.34.9", - "@rollup/rollup-linux-arm64-gnu": "4.34.9", - "@rollup/rollup-linux-arm64-musl": "4.34.9", - "@rollup/rollup-linux-loongarch64-gnu": "4.34.9", - "@rollup/rollup-linux-powerpc64le-gnu": "4.34.9", - "@rollup/rollup-linux-riscv64-gnu": "4.34.9", - "@rollup/rollup-linux-s390x-gnu": "4.34.9", - "@rollup/rollup-linux-x64-gnu": "4.34.9", - "@rollup/rollup-linux-x64-musl": "4.34.9", - "@rollup/rollup-win32-arm64-msvc": "4.34.9", - "@rollup/rollup-win32-ia32-msvc": "4.34.9", - "@rollup/rollup-win32-x64-msvc": "4.34.9", + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", "dependencies": { - "queue-microtask": "^1.2.2" + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" } }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -3558,6 +4221,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -3585,6 +4255,20 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -3608,6 +4292,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3634,40 +4331,140 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", - "integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.1.0", - "tslib": "^2.6.2" + "@pkgr/core": "^0.2.9" }, "engines": { "node": "^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/unts" + "url": "https://opencollective.com/synckit" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", "dependencies": { - "is-number": "^7.0.0" + "punycode": "^2.3.1" }, "engines": { - "node": ">=8.0" + "node": ">=18" } }, "node_modules/ts-api-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", - "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -3712,9 +4509,9 @@ } }, "node_modules/ua-parser-js": { - "version": "1.0.40", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz", - "integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==", + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", "dev": true, "funding": [ { @@ -3739,9 +4536,16 @@ } }, "node_modules/ufo": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", - "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, @@ -3780,15 +4584,18 @@ } }, "node_modules/vite": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", - "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", "postcss": "^8.5.3", - "rollup": "^4.30.1" + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -3851,10 +4658,33 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-plugin-dts": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-4.5.3.tgz", - "integrity": "sha512-P64VnD00dR+e8S26ESoFELqc17+w7pKkwlBpgXteOljFyT0zDwD8hH4zXp49M/kciy//7ZbVXIwQCekBJjfWzA==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-4.5.4.tgz", + "integrity": "sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==", "dev": true, "license": "MIT", "dependencies": { @@ -3879,28 +4709,102 @@ } }, "node_modules/vite-plugin-top-level-await": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.5.0.tgz", - "integrity": "sha512-r/DtuvHrSqUVk23XpG2cl8gjt1aATMG5cjExXL1BUTcSNab6CzkcPua9BPEc9fuTP5UpwClCxUe3+dNGL0yrgQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.6.0.tgz", + "integrity": "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww==", "dev": true, "license": "MIT", "dependencies": { "@rollup/plugin-virtual": "^3.0.2", - "@swc/core": "^1.10.16", - "uuid": "^10.0.0" + "@swc/core": "^1.12.14", + "@swc/wasm": "^1.12.14", + "uuid": "10.0.0" }, "peerDependencies": { "vite": ">=2.8" } }, "node_modules/vite-plugin-wasm": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.4.1.tgz", - "integrity": "sha512-ja3nSo2UCkVeitltJGkS3pfQHAanHv/DqGatdI39ja6McgABlpsZ5hVgl6wuR8Qx5etY3T5qgDQhOWzc5RReZA==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz", + "integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, "peerDependencies": { - "vite": "^2 || ^3 || ^4 || ^5 || ^6" + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } } }, "node_modules/vscode-uri": { @@ -3910,6 +4814,67 @@ "dev": true, "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3926,6 +4891,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3936,6 +4918,45 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -3943,21 +4964,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/web-client/iron-remote-desktop-rdp/package.json b/web-client/iron-remote-desktop-rdp/package.json index 11ae53cda4..13622dd9cb 100644 --- a/web-client/iron-remote-desktop-rdp/package.json +++ b/web-client/iron-remote-desktop-rdp/package.json @@ -15,6 +15,7 @@ "build-alone": "vite build", "pre-build": "node ./pre-build.js", "preview": "vite preview", + "test": "vitest run", "check": "tsc --noEmit", "check:dist": "tsc ./dist/index.d.ts --noEmit", "check:watch": "tsc --watch --noEmit", @@ -26,19 +27,22 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.0", "@eslint/js": "^9.21.0", + "@types/node": "^24.0.0", "@types/ua-parser-js": "^0.7.36", "@typescript-eslint/eslint-plugin": "^8.25.0", "eslint": "^9.21.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.1", "globals": "^16.0.0", + "jsdom": "^26.0.0", + "vitest": "^3.0.0", "prettier": "^3.1.0", "tslib": "^2.4.1", "typescript": "~5.7.2", + "ua-parser-js": "^1.0.33", "vite": "^6.2.0", "vite-plugin-dts": "^4.5.0", "vite-plugin-top-level-await": "^1.2.2", - "vite-plugin-wasm": "^3.1.0", - "ua-parser-js": "^1.0.33" + "vite-plugin-wasm": "^3.1.0" } } diff --git a/web-client/iron-remote-desktop-rdp/public/CHANGELOG.md b/web-client/iron-remote-desktop-rdp/public/CHANGELOG.md new file mode 100644 index 0000000000..296468d3ae --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/public/CHANGELOG.md @@ -0,0 +1,67 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [0.7.0] - 2026-05-26 + +### Features + +- [**breaking**] Extend `DeviceEvent.wheelRotations` event to support passing rotation units other than pixels ([#952](https://github.com/Devolutions/IronRDP/issues/952)) ([23c0cc2c36](https://github.com/Devolutions/IronRDP/commit/23c0cc2c365159d24330a89ec4015121b67bccb6)) + +- Human-readable descriptions for RDCleanPath errors ([#999](https://github.com/Devolutions/IronRDP/issues/999)) ([18c81ed5d8](https://github.com/Devolutions/IronRDP/commit/18c81ed5d8d3bf13b3d10fe15209233c0c10bb62)) + + Web-side error strings for RDCleanPath general/negotiation + failures, including HTTP, WSA, and TLS error conditions. + +- Configurable `alternate_shell` and `work_dir` ([#1095](https://github.com/Devolutions/IronRDP/issues/1095)) ([a33d27fe67](https://github.com/Devolutions/IronRDP/commit/a33d27fe6771a5a155161ef40a04de88803dd84c)) + + Expose `ClientInfoPdu` `alternate_shell` and `work_dir` fields for + RemoteApp, custom shells, and PSM session tokens. + +- Negotiate bulk compression with the server ([ebf5da5f33](https://github.com/Devolutions/IronRDP/commit/ebf5da5f3380a3355f6c95814d669f8190425ded)) + + Advertise compression in Client Info and decode compressed + FastPath and ShareData updates (MPPC/NCRUSH/XCRUSH). + +- Decode multitransport request PDUs ([#1092](https://github.com/Devolutions/IronRDP/issues/1092), [#1096](https://github.com/Devolutions/IronRDP/issues/1096)) ([4f5fdd3628](https://github.com/Devolutions/IronRDP/commit/4f5fdd3628f4d0d2c2a4116e4e45269d802740f1)) + + Advertise the multitransport channel in GCC blocks and dispatch + `MultitransportRequestPdu` from the IO channel. The web client + logs the request; UDP transport is not yet wired up. + +- Expose granular RDCleanPath error details ([#1117](https://github.com/Devolutions/IronRDP/issues/1117)) ([2911124e8f](https://github.com/Devolutions/IronRDP/commit/2911124e8fe6160bc8ba03a574b67077e6d2cca9)) + + Forward HTTP status, WSA, and TLS alert codes from RDCleanPath + errors so the web client can distinguish specific network + failures. + +- Clipboard file transfer support ([#1064](https://github.com/Devolutions/IronRDP/issues/1064), [#1065](https://github.com/Devolutions/IronRDP/issues/1065), [#1066](https://github.com/Devolutions/IronRDP/issues/1066), [#1166](https://github.com/Devolutions/IronRDP/issues/1166)) ([c98a8fb774](https://github.com/Devolutions/IronRDP/commit/c98a8fb7741986e9afef00cb5615250c963a7fa9)) + + End-to-end clipboard file transfer (upload and download) across + the CLIPRDR channel per MS-RDPECLIP. + +- Web RDPDR virtual printer support ([#1230](https://github.com/Devolutions/IronRDP/issues/1230)) ([14b1cef9cb](https://github.com/Devolutions/IronRDP/commit/14b1cef9cbbd0d8ef5e1fc8c73a3003a5e9f9bc2)) + + Announce a redirected printer over RDPDR, receive server print + jobs, and deliver completed PostScript jobs to a browser + callback. + +### Bug Fixes + +- Fix `this.lastSentClipboardData` being nulled ([#992](https://github.com/Devolutions/IronRDP/issues/992)) ([6127e13c83](https://github.com/Devolutions/IronRDP/commit/6127e13c836d06764d483b6b55188fd23a4314a2)) + +- Handle Auto-Detect Request PDUs from the server ([#1178](https://github.com/Devolutions/IronRDP/issues/1178)) ([4dcad09980](https://github.com/Devolutions/IronRDP/commit/4dcad09980e4f5354e4e435a134cc0956e2fcf9e)) + + Fix a session-terminating "unhandled PDU: Auto-Detect Request + PDU" error when servers send auto-detect requests during the + active phase. + +- Propagate negotiated `share_id` to all outgoing `ShareDataPdu` ([#1147](https://github.com/Devolutions/IronRDP/issues/1147)) ([2b24e9664d](https://github.com/Devolutions/IronRDP/commit/2b24e9664dd05620ff63a24d092377477fdde863)) + +### Build + +- Upgrade sspi and fix NTLM fallback ([#1188](https://github.com/Devolutions/IronRDP/issues/1188)) ([c70d38a9f1](https://github.com/Devolutions/IronRDP/commit/c70d38a9f190d6ad6c84bd9027a388b5db3296ba)) diff --git a/web-client/iron-remote-desktop-rdp/public/package.json b/web-client/iron-remote-desktop-rdp/public/package.json index 32d7ef6e00..0db5a4f255 100644 --- a/web-client/iron-remote-desktop-rdp/public/package.json +++ b/web-client/iron-remote-desktop-rdp/public/package.json @@ -6,7 +6,16 @@ "Benoit Cortier" ], "description": "RDP backend for iron-remote-desktop", - "version": "0.5.2", + "version": "0.7.0", + "repository": { + "type": "git", + "url": "git+https://github.com/Devolutions/IronRDP.git" + }, + "homepage": "https://github.com/Devolutions/IronRDP", + "bugs": { + "url": "https://github.com/Devolutions/IronRDP/issues" + }, + "license": "MIT OR Apache-2.0", "main": "iron-remote-desktop-rdp.js", "types": "index.d.ts", "files": [ diff --git a/web-client/iron-remote-desktop-rdp/src/FileContentsFlags.ts b/web-client/iron-remote-desktop-rdp/src/FileContentsFlags.ts new file mode 100644 index 0000000000..050bf86635 --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/FileContentsFlags.ts @@ -0,0 +1,20 @@ +/** + * FileContentsFlags values per [MS-RDPECLIP] 2.2.5.3 + * + * Used in FileContentsRequest to specify the type of operation. + */ +export const FileContentsFlags = { + /** + * Request file size (8-byte unsigned integer). + * When set: position must be 0, size must be 8. + */ + SIZE: 0x1, + + /** + * Request byte range from file. + * When set: position and size define the requested range. + */ + RANGE: 0x2, +} as const; + +export type FileContentsFlags = (typeof FileContentsFlags)[keyof typeof FileContentsFlags]; diff --git a/web-client/iron-remote-desktop-rdp/src/FileTransfer.ts b/web-client/iron-remote-desktop-rdp/src/FileTransfer.ts new file mode 100644 index 0000000000..f66748c712 --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/FileTransfer.ts @@ -0,0 +1,83 @@ +/** + * File metadata for file transfer operations. + * + * When remote copies files, this metadata is provided to the client. + * File names are limited to 259 characters per MS-RDPECLIP spec. + */ +export interface FileInfo { + /** File basename (including extension, without directory path) */ + name: string; + /** + * Relative directory path within the copied collection. + * Uses `\` as separator (matching the Windows wire protocol convention). + * Absent or undefined for root-level files. + * + * Per MS-RDPECLIP 3.1.1.2, file lists use relative paths to describe + * directory structure (e.g., `"temp\\subdir"`). + * + * @example + * // File at root level: + * { name: "readme.txt", size: 100, lastModified: 0 } + * // File inside "docs" folder: + * { name: "report.pdf", path: "docs", size: 2048, lastModified: 0 } + * // File inside nested folder: + * { name: "image.png", path: "docs\\images", size: 4096, lastModified: 0 } + */ + path?: string; + /** File size in bytes (0 for empty files or unknown size) */ + size: number; + /** + * Last write time as a JavaScript timestamp (milliseconds since Unix epoch, + * same as `File.lastModified`). The WASM layer converts to Windows FILETIME + * for the RDP protocol. 0 indicates unknown or not applicable. + */ + lastModified: number; + /** + * Whether this entry represents a directory rather than a file. + * Directory entries are used to describe the folder structure of a copied + * collection and typically have size 0. + */ + isDirectory?: boolean; +} + +/** + * File contents request from remote (when remote requests file upload from client). + * + * The client should read the requested file chunk and respond via CLIPRDR. + */ +export interface FileContentsRequest { + /** Stream identifier for this file transfer */ + streamId: number; + /** File index in the file list (0-based) */ + index: number; + /** + * FileContentsFlags bitmask - use FileContentsFlags.SIZE or FileContentsFlags.RANGE + * - FileContentsFlags.SIZE (0x1): Request file size + * - FileContentsFlags.RANGE (0x2): Request byte range + */ + flags: number; + /** Byte offset for RANGE requests */ + position: number; + /** Number of bytes requested for RANGE requests */ + size: number; + /** Optional clipboard lock ID from LockClipData PDU */ + dataId?: number; +} + +/** + * File contents response from remote (when remote sends file download to client). + * + * This is the response to a client's file contents request. + */ +export interface FileContentsResponse { + /** Stream identifier for this file transfer */ + streamId: number; + /** If true, the request failed (data unavailable/access denied) */ + isError: boolean; + /** + * Response data: + * - For SIZE requests: 8-byte little-endian u64 + * - For RANGE requests: requested byte range + */ + data: Uint8Array; +} diff --git a/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.test.ts b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.test.ts new file mode 100644 index 0000000000..a3e7045749 --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.test.ts @@ -0,0 +1,1751 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import type { FileTransferError } from './RdpFileTransferProvider'; +import type { FileInfo } from './FileTransfer'; + +// Mock the extensions module so tests don't need the real WASM Extension class. +// Each factory returns a plain object with an ident for identification. +vi.mock('./extensions', () => ({ + filesAvailableCallback: (cb: unknown) => ({ ident: 'files_available_callback', value: cb }), + fileContentsRequestCallback: (cb: unknown) => ({ ident: 'file_contents_request_callback', value: cb }), + fileContentsResponseCallback: (cb: unknown) => ({ ident: 'file_contents_response_callback', value: cb }), + lockCallback: (cb: unknown) => ({ ident: 'lock_callback', value: cb }), + unlockCallback: (cb: unknown) => ({ ident: 'unlock_callback', value: cb }), + locksExpiredCallback: (cb: unknown) => ({ ident: 'locks_expired_callback', value: cb }), + formatListResponseCallback: (cb: unknown) => ({ ident: 'format_list_response_callback', value: cb }), + requestFileContents: (params: unknown) => ({ ident: 'request_file_contents', value: params }), + submitFileContents: (params: unknown) => ({ ident: 'submit_file_contents', value: params }), + initiateFileCopy: (files: unknown) => ({ ident: 'initiate_file_copy', value: files }), +})); + +// Import after mock registration +const { RdpFileTransferProvider } = await import('./RdpFileTransferProvider'); + +/** + * RdpFileTransferProvider Unit Tests + * + * Testing Strategy: + * ---------------- + * These tests cover the JavaScript layer of RdpFileTransferProvider including: + * - Setup and initialization logic + * - Event system (registration, emission, removal) + * - Browser API helpers (drag/drop, file picker) + * - Cleanup and disposal + * - Edge case handling + * + * What is NOT tested here: + * - Full async download/upload flows (require WASM integration) + * - Protocol sequencing and lock coordination (tested in Rust: ironrdp-cliprdr) + * - WASM callback orchestration (requires real WASM runtime) + */ + +// Mock session that captures invokeExtension calls +class MockSession { + invokeExtension = vi.fn(); +} + +/** + * Helper: create a provider, set its session, and return both. + */ +function setupProvider(options?: { chunkSize?: number; onUploadStarted?: () => void; onUploadFinished?: () => void }) { + const provider = new RdpFileTransferProvider(options); + const session = new MockSession(); + provider.setSession(session); + return { provider, session }; +} + +type RdpFileTransferProviderInstance = InstanceType; + +describe('RdpFileTransferProvider', () => { + let provider: RdpFileTransferProviderInstance; + + beforeEach(() => { + const setup = setupProvider(); + provider = setup.provider; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('setup and initialization', () => { + it('should create provider instance', () => { + expect(provider).toBeInstanceOf(RdpFileTransferProvider); + }); + + it('should return builder extensions', () => { + // getBuilderExtensions() would return Extension objects. + // Without WASM, the factory functions will throw, so we + // just verify the method exists and the provider is functional. + expect(provider.getBuilderExtensions).toBeDefined(); + }); + + it('should use custom chunk size', () => { + const custom = new RdpFileTransferProvider({ chunkSize: 32768 }); + expect(custom).toBeDefined(); + }); + + it('should throw error when session not available', () => { + const noSession = new RdpFileTransferProvider(); + expect(() => { + // @ts-expect-error - accessing private method for testing + noSession.ensureSession(); + }).toThrow('Session not available'); + }); + + it('should accept session via setSession', () => { + const p = new RdpFileTransferProvider(); + const s = new MockSession(); + p.setSession(s); + // Should not throw after setSession + // @ts-expect-error - accessing private method for testing + expect(() => p.ensureSession()).not.toThrow(); + }); + }); + + describe('event system', () => { + it('should register event handlers', () => { + const handler = vi.fn(); + provider.on('files-available', handler); + // No error should be thrown + }); + + it('should emit files-available event via handleFilesAvailable', () => { + const handler = vi.fn(); + provider.on('files-available', handler); + + const files: FileInfo[] = [{ name: 'test.txt', size: 1024, lastModified: Date.now() }]; + + // Call the handler directly (in production, the WASM layer would call this) + // @ts-expect-error - accessing private method for testing + provider.handleFilesAvailable(files); + expect(handler).toHaveBeenCalledWith(files); + }); + + it('should remove event handlers with off()', () => { + const handler = vi.fn(); + provider.on('files-available', handler); + provider.off('files-available', handler); + + // @ts-expect-error - accessing private method for testing + provider.handleFilesAvailable([]); + expect(handler).not.toHaveBeenCalled(); + }); + + it('should support multiple handlers for same event', () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + provider.on('files-available', handler1); + provider.on('files-available', handler2); + + const files: FileInfo[] = [{ name: 'test.txt', size: 100, lastModified: Date.now() }]; + // @ts-expect-error - accessing private method for testing + provider.handleFilesAvailable(files); + + expect(handler1).toHaveBeenCalledWith(files); + expect(handler2).toHaveBeenCalledWith(files); + }); + }); + + describe('browser integration helpers', () => { + it('should have showFilePicker method', () => { + expect(provider.showFilePicker).toBeDefined(); + }); + + it('should have handleDrop method', () => { + expect(provider.handleDrop).toBeDefined(); + }); + + it('should have handleDragOver method', () => { + expect(provider.handleDragOver).toBeDefined(); + }); + + it('should extract files from drop event (files fallback)', async () => { + const mockDataTransfer = { + files: [new File(['test'], 'test.txt')], + }; + + const mockEvent = { + dataTransfer: mockDataTransfer, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as DragEvent; + + const files = await provider.handleDrop(mockEvent); + expect(files).toHaveLength(1); + expect(files[0].name).toBe('test.txt'); + expect(files[0].file).toBeInstanceOf(File); + expect(files[0].isDirectory).toBeUndefined(); + expect(mockEvent.preventDefault).toHaveBeenCalled(); + }); + + it('should extract files from drop event (items with webkitGetAsEntry)', async () => { + const testFile = new File(['hello'], 'hello.txt'); + + const mockEntry: Partial = { + isFile: true, + isDirectory: false, + name: 'hello.txt', + file: (cb: (file: File) => void) => cb(testFile), + }; + + const mockItem = { + kind: 'file', + webkitGetAsEntry: () => mockEntry as FileSystemEntry, + }; + + const mockDataTransfer = { + items: [mockItem], + }; + + const mockEvent = { + dataTransfer: mockDataTransfer, + preventDefault: vi.fn(), + } as unknown as DragEvent; + + const result = await provider.handleDrop(mockEvent); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('hello.txt'); + expect(result[0].file).toBe(testFile); + expect(result[0].path).toBeUndefined(); + expect(result[0].isDirectory).toBeUndefined(); + }); + }); + + describe('disposal', () => { + it('should mark as disposed', () => { + provider.dispose(); + // @ts-expect-error - accessing private field for testing + expect(provider.disposed).toBe(true); + }); + + it('should clear event handlers on dispose', () => { + const handler = vi.fn(); + provider.on('files-available', handler); + provider.dispose(); + // @ts-expect-error - accessing private method for testing + provider.handleFilesAvailable([]); + expect(handler).not.toHaveBeenCalled(); + }); + + it('should reject pending download on dispose', async () => { + const fileInfo: FileInfo = { name: 'data.bin', size: 2048, lastModified: Date.now() }; + + const { completion } = provider.downloadFile(fileInfo, 0); + provider.dispose(); + + await expect(completion).rejects.toThrow(); + }); + }); + + describe('download error handling', () => { + it('should reject with error when session not available for download', async () => { + const noSession = new RdpFileTransferProvider(); + const fileInfo: FileInfo = { name: 'data.bin', size: 2048, lastModified: Date.now() }; + + const errorHandler = vi.fn(); + noSession.on('error', errorHandler); + + const { completion } = noSession.downloadFile(fileInfo, 0); + await expect(completion).rejects.toThrow('Failed to request file size'); + + expect(errorHandler).toHaveBeenCalledTimes(1); + const emittedError: FileTransferError = errorHandler.mock.calls[0][0]; + expect(emittedError.direction).toBe('download'); + expect(emittedError.fileName).toBe('data.bin'); + }); + }); + + describe('upload lifecycle callbacks', () => { + it('suppresses monitoring on advertise and defers the resume past the wire send', async () => { + const onUploadStarted = vi.fn(); + const onUploadFinished = vi.fn(); + + const { provider: p, session: s } = setupProvider({ + onUploadStarted, + onUploadFinished, + }); + + const files = [new File(['x'], 'x.txt', { type: 'text/plain' })]; + const { completion: uploadPromise } = p.uploadFiles(files); + + // Monitoring is suppressed before the FormatList goes on the wire... + expect(onUploadStarted).toHaveBeenCalledTimes(1); + expect(s.invokeExtension).toHaveBeenCalledTimes(1); + expect(onUploadStarted.mock.invocationCallOrder[0]).toBeLessThan( + s.invokeExtension.mock.invocationCallOrder[0], + ); + // ...but the resume is now DEFERRED (held until the paste is pulled or we + // give up), so the 100ms monitor poll cannot clobber the file FormatList. + expect(onUploadFinished).not.toHaveBeenCalled(); + + // Dispose resumes monitoring exactly once and rejects the pending upload. + p.dispose(); + expect(onUploadFinished).toHaveBeenCalledTimes(1); + await expect(uploadPromise).rejects.toThrow('RdpFileTransferProvider disposed'); + }); + + it('resumes monitoring on the first FileContentsRequest (remote pulled the files)', async () => { + const onUploadFinished = vi.fn(); + const { provider: p } = setupProvider({ onUploadFinished }); + + const files = [new File(['data'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + expect(onUploadFinished).not.toHaveBeenCalled(); + + // Remote requests file size for index 0 -> paste was pulled -> resume. + // @ts-expect-error - exercising the private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 1, position: 0, size: 8 }); + expect(onUploadFinished).toHaveBeenCalledTimes(1); + + p.dispose(); + // Already resumed on the pull, so dispose does not resume again. + expect(onUploadFinished).toHaveBeenCalledTimes(1); + await expect(completion).rejects.toThrow('RdpFileTransferProvider disposed'); + }); + + it('fails the upload and resumes monitoring if the remote never pulls (watchdog)', async () => { + vi.useFakeTimers(); + try { + const onUploadFinished = vi.fn(); + const { provider: p } = setupProvider({ onUploadFinished }); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['x'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + const rejected = expect(completion).rejects.toThrow(/did not request the files/i); + + // No FileContentsRequest ever arrives; after the 60s lock window the + // watchdog fires: resume monitoring + fail the upload. + await vi.advanceTimersByTimeAsync(60_000); + await rejected; + + expect(onUploadFinished).toHaveBeenCalledTimes(1); + const err: FileTransferError = errorHandler.mock.calls.at(-1)![0]; + expect(err.direction).toBe('upload'); + + // uploadState was cleared, so a fresh upload starts instead of throwing + // "Upload already in progress" -- the wedge is gone, no reload needed. + const second = p.uploadFiles(files); + p.dispose(); + await expect(second.completion).rejects.toThrow('RdpFileTransferProvider disposed'); + } finally { + vi.useRealTimers(); + } + }); + + it('does NOT fail a pulled upload that keeps making progress', async () => { + // Regression guard for normal uploads: a slow-but-progressing transfer resets + // the inactivity watchdog on every request, so it must never be killed even + // long past the window. + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['data'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + let settled = false; + void completion.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + + // Remote keeps pulling, each request well inside the 60s window: the + // watchdog keeps resetting and never fires (total elapsed well past 60s). + for (let i = 0; i < 5; i++) { + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 1, position: 0, size: 8 }); + await vi.advanceTimersByTimeAsync(50_000); + } + + expect(errorHandler).not.toHaveBeenCalled(); + expect(settled).toBe(false); + + p.dispose(); + await expect(completion).rejects.toThrow('RdpFileTransferProvider disposed'); + } finally { + vi.useRealTimers(); + } + }); + + it('fails a pulled-then-idle upload after the inactivity window, releasing the wedge', async () => { + // Once pulling starts, if the remote goes silent (e.g. it grabbed the clipboard + // with a text/image copy that never reaches handleFilesAvailable), the + // inactivity watchdog releases uploadState so later uploads aren't wedged. + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['data'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + const rejected = expect(completion).rejects.toThrow(/stopped requesting the files/i); + + // Remote pulls once (arms the inactivity watchdog), then goes silent. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 1, position: 0, size: 8 }); + await vi.advanceTimersByTimeAsync(60_000); + await rejected; + + expect(errorHandler).toHaveBeenCalled(); + expect((errorHandler.mock.calls.at(-1)![0] as FileTransferError).direction).toBe('upload'); + + // uploadState released -> a fresh upload doesn't throw "Upload already in progress". + const second = p.uploadFiles(files); + p.dispose(); + await expect(second.completion).rejects.toThrow('RdpFileTransferProvider disposed'); + } finally { + vi.useRealTimers(); + } + }); + + it('stops the inactivity watchdog once the upload completes (no lingering timer)', async () => { + // The final FileContentsRequest arms the inactivity watchdog; completion must + // disarm it (finishUploadBatch). Otherwise a stray 60s timer lingers after every + // completed upload -- harmless today thanks to the uploadState guard, but it + // should not exist, and a future change could let it fire against fresh state. + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['data'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + let resolved = false; + void completion.then(() => { + resolved = true; + }); + + // Remote pulls the whole 4-byte file in one RANGE. jsdom schedules the + // FileReader on a timer, so flushing it completes the batch. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 2, position: 0, size: 4 }); + await vi.advanceTimersByTimeAsync(100); + expect(resolved).toBe(true); + + // Completion ran finishUploadBatch, which cleared the inactivity watchdog: + // no upload timer is left pending (the lingering-timer bug this guards). + expect(vi.getTimerCount()).toBe(0); + + // ...and well past the window nothing fails. + await vi.advanceTimersByTimeAsync(60_000); + expect(errorHandler).not.toHaveBeenCalled(); + + p.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('does NOT fail a re-paste that goes idle (isRePaste guard)', async () => { + // After an upload completes, the DroppedFile metadata is retained so the remote + // can re-paste. A re-paste rebuilds uploadState with isRePaste=true and carries + // no external promise, so the inactivity watchdog must leave it alone rather than + // emit a bogus upload error / try to reject a promise nobody is awaiting. + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['data'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + let resolved = false; + void completion.then(() => { + resolved = true; + }); + + // First paste: remote pulls the whole file -> upload completes, metadata retained. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 2, position: 0, size: 4 }); + await vi.advanceTimersByTimeAsync(100); + expect(resolved).toBe(true); + + // Re-paste: no uploadState but retainedFiles present -> the next request + // rebuilds an isRePaste state and re-arms the inactivity watchdog. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 2, index: 0, flags: 1, position: 0, size: 8 }); + + // The window elapses with no further pulls. A fresh upload would be failed + // here; a re-paste must NOT be (no promise to reject, no error to surface). + await vi.advanceTimersByTimeAsync(60_000); + expect(errorHandler).not.toHaveBeenCalled(); + + p.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('releases the in-flight upload when the remote replaces the clipboard', async () => { + // A remote FormatList (the remote copied its own files) supersedes our advertise; + // the upload can no longer complete, so uploadState must be released or it wedges + // every later upload. Symmetric to the rejected-advertise recovery. + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['data'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + const rejected = expect(completion).rejects.toThrow(/remote clipboard changed/i); + + // Remote copies its own files mid-upload. + // @ts-expect-error - accessing private method for testing + p.handleFilesAvailable([{ name: 'remote.txt', size: 10, lastModified: 0 }] as FileInfo[]); + await rejected; + + expect(errorHandler).toHaveBeenCalled(); + expect((errorHandler.mock.calls.at(-1)![0] as FileTransferError).direction).toBe('upload'); + + // uploadState released -> a fresh upload doesn't throw "Upload already in progress". + const second = p.uploadFiles(files); + p.dispose(); + await expect(second.completion).rejects.toThrow('RdpFileTransferProvider disposed'); + }); + + it('does NOT fail an in-flight upload on an Unlock (Unlock is not a paste-failure signal)', async () => { + // The peer/cliprdr emit Unlock routinely (snapshot of the FormatList, and the + // 60s timeout), often before any FileContentsRequest. Treating that as failure + // tore down uploads that would still be pulled, so Unlock must be ignored here. + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['x'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + + // @ts-expect-error - private callback the WASM layer drives via unlockCallback + p.handleUnlock(0); + + // No error, and the upload state is intact (Unlock is ignored). + expect(errorHandler).not.toHaveBeenCalled(); + expect(p.isUploadInProgress()).toBe(true); + + p.dispose(); + await expect(completion).rejects.toThrow('RdpFileTransferProvider disposed'); + }); + + it('completes a 0-byte file from its SIZE request alone (no RANGE follows)', async () => { + // A 0-byte file has no bytes, so the remote asks for its size and never + // sends a RANGE -- the only path that otherwise marks a file complete. + // Without explicit handling the batch never reaches expectedFileCount, + // finishUploadBatch never runs, and uploadState wedges every later upload. + const { provider: p, session: s } = setupProvider(); + const errorHandler = vi.fn(); + const completeHandler = vi.fn(); + p.on('error', errorHandler); + p.on('upload-complete', completeHandler); + + const files = [new File([], 'empty.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + + // Remote requests only the size (8 bytes) for the lone empty file. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 1, position: 0, size: 8 }); + + await expect(completion).resolves.toBeUndefined(); + expect(completeHandler).toHaveBeenCalledTimes(1); + expect(errorHandler).not.toHaveBeenCalled(); + + // finishUploadBatch released uploadState: a fresh upload is not wedged + // ("Upload already in progress"). Reaching a handle plus the + // initiateFileCopy call proves it restarted. + s.invokeExtension.mockClear(); + const second = p.uploadFiles([new File(['x'], 'y.txt', { type: 'text/plain' })]); + expect(s.invokeExtension).toHaveBeenCalledTimes(1); + p.dispose(); + await expect(second.completion).rejects.toThrow('RdpFileTransferProvider disposed'); + }); + + it('lets a 0-byte file finish a mixed batch after the data files are served', async () => { + // The realistic case: a folder of tiny files where the empty ones are the + // last to "complete". The empty file's SIZE request must finish the batch + // once every data file has been fully served. + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + const completeHandler = vi.fn(); + p.on('error', errorHandler); + p.on('upload-complete', completeHandler); + + const files = [ + new File(['data'], 'a.txt', { type: 'text/plain' }), + new File([], 'empty.txt', { type: 'text/plain' }), + ]; + const { completion } = p.uploadFiles(files); + let resolved = false; + void completion.then(() => { + resolved = true; + }); + + // Data file: SIZE then RANGE. jsdom runs the FileReader on a timer, so + // flush it -- index 0 completes but the batch is not done (1 of 2). + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 1, position: 0, size: 8 }); + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 2, index: 0, flags: 2, position: 0, size: 4 }); + await vi.advanceTimersByTimeAsync(100); + expect(resolved).toBe(false); + + // Empty file: SIZE only. This completes the second (and last) counted + // file, so the batch finishes. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 3, index: 1, flags: 1, position: 0, size: 8 }); + await Promise.resolve(); + expect(resolved).toBe(true); + + expect(completeHandler).toHaveBeenCalledTimes(2); + const completedNames = completeHandler.mock.calls.map((c) => (c[0] as File).name); + expect(completedNames).toContain('empty.txt'); + expect(errorHandler).not.toHaveBeenCalled(); + + // finishUploadBatch disarmed the inactivity watchdog: no timer lingers. + expect(vi.getTimerCount()).toBe(0); + + p.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('completes a directory-only batch on paste-ack without waiting for the inactivity watchdog', async () => { + // A directory entry carries no data: the remote requests SIZE (answered with 0) but + // never a RANGE, so no file is ever marked complete and expectedFileCount is 0. Without + // explicit handling, finishUploadBatch never runs, the inactivity watchdog fires after + // 60s, and an empty-folder paste that actually succeeded is reported as a failure. + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const { completion } = p.uploadFiles([ + { file: null, name: 'folder', size: 0, lastModified: 0, isDirectory: true }, + ]); + let resolved = false; + void completion.then(() => { + resolved = true; + }); + expect(p.isUploadInProgress()).toBe(true); + + // Remote acknowledges the paste by requesting the directory's size. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 1, position: 0, size: 8 }); + await Promise.resolve(); + expect(resolved).toBe(true); + expect(errorHandler).not.toHaveBeenCalled(); + + // The batch finished on acknowledgment, so the inactivity watchdog never fires: + // letting the full window elapse must not surface an error. + await vi.advanceTimersByTimeAsync(60_000); + expect(errorHandler).not.toHaveBeenCalled(); + + p.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('reports isUploadInProgress across an upload lifecycle (idle -> advertised -> complete)', async () => { + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + expect(p.isUploadInProgress()).toBe(false); + + const { completion } = p.uploadFiles([new File(['data'], 'x.txt', { type: 'text/plain' })]); + expect(p.isUploadInProgress()).toBe(true); + + // Remote pulls the whole file -> batch completes -> upload state released. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 2, position: 0, size: 4 }); + await vi.advanceTimersByTimeAsync(100); + await completion; + expect(p.isUploadInProgress()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('clears isUploadInProgress once a stalled upload is failed', async () => { + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const { completion } = p.uploadFiles([new File(['x'], 'x.txt', { type: 'text/plain' })]); + const rejected = expect(completion).rejects.toThrow(); + expect(p.isUploadInProgress()).toBe(true); + + // Remote never pulls -> the watchdog fails the upload and releases the state. + await vi.advanceTimersByTimeAsync(60_000); + await rejected; + expect(p.isUploadInProgress()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('reports isUploadInProgress again during a remote re-paste', async () => { + vi.useFakeTimers(); + try { + const { provider: p } = setupProvider(); + const { completion } = p.uploadFiles([new File(['data'], 'x.txt', { type: 'text/plain' })]); + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 1, index: 0, flags: 2, position: 0, size: 4 }); + await vi.advanceTimersByTimeAsync(100); + await completion; + expect(p.isUploadInProgress()).toBe(false); + + // The remote re-pastes the retained file: state is rebuilt, so it's in progress again. + // @ts-expect-error - private callback the WASM layer drives + p.handleFileContentsRequest({ streamId: 2, index: 0, flags: 1, position: 0, size: 8 }); + expect(p.isUploadInProgress()).toBe(true); + + p.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('supersedes an in-flight upload when a new one starts (old completion resolves, new batch advertised)', async () => { + const { provider: p, session: s } = setupProvider(); + const { completion: firstCompletion } = p.uploadFiles([ + new File(['data'], 'first.txt', { type: 'text/plain' }), + ]); + expect(p.isUploadInProgress()).toBe(true); + + // A new paste arrives while the first is still advertised. The old completion resolves + // cleanly and the new batch is advertised in its place. + s.invokeExtension.mockClear(); + const { completion: secondCompletion } = p.uploadFiles([ + new File(['more'], 'second.txt', { type: 'text/plain' }), + ]); + + await expect(firstCompletion).resolves.toBeUndefined(); + expect(p.isUploadInProgress()).toBe(true); // now the second batch + expect(s.invokeExtension).toHaveBeenCalledTimes(1); // a fresh initiateFileCopy + + p.dispose(); + await expect(secondCompletion).rejects.toThrow('RdpFileTransferProvider disposed'); + }); + + it('should call onUploadFinished even on initiateFileCopy failure', async () => { + const onUploadStarted = vi.fn(); + const onUploadFinished = vi.fn(); + + const { provider: p, session: s } = setupProvider({ + onUploadStarted, + onUploadFinished, + }); + s.invokeExtension.mockImplementation(() => { + throw new Error('Copy failed'); + }); + + const files = [new File(['x'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + await expect(completion).rejects.toThrow('Failed to initiate file upload'); + + expect(onUploadStarted).toHaveBeenCalledTimes(1); + // The failure path resumes monitoring (resumeUploadMonitoring), so it fires once. + expect(onUploadFinished).toHaveBeenCalledTimes(1); + }); + }); + + describe('format list response (paste accept / reject)', () => { + // Pull the registered format_list_response_callback out of the builder + // extensions and invoke it, exercising the real wiring + handler together. + function fireFormatListResponse(p: RdpFileTransferProviderInstance, ok: boolean): void { + const exts = p.getBuilderExtensions() as unknown as Array<{ ident: string; value: (ok: boolean) => void }>; + const ext = exts.find((e) => e.ident === 'format_list_response_callback'); + if (ext === undefined) { + throw new Error('format_list_response_callback was not registered'); + } + ext.value(ok); + } + + it('registers a format_list_response_callback builder extension', () => { + const idents = (provider.getBuilderExtensions() as unknown as Array<{ ident: string }>).map((e) => e.ident); + expect(idents).toContain('format_list_response_callback'); + }); + + it('emits a format-list-response event carrying the ok flag', () => { + const handler = vi.fn(); + provider.on('format-list-response', handler); + + fireFormatListResponse(provider, true); + fireFormatListResponse(provider, false); + + expect(handler).toHaveBeenNthCalledWith(1, true); + expect(handler).toHaveBeenNthCalledWith(2, false); + }); + + it('fails an in-flight upload cleanly when the remote rejects the advertise', async () => { + const { provider: p, session: s } = setupProvider(); + const errorHandler = vi.fn(); + p.on('error', errorHandler); + + const files = [new File(['x'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + + // Remote rejects the file list before requesting any contents. + fireFormatListResponse(p, false); + + await expect(completion).rejects.toThrow(/rejected the file list/i); + const err: FileTransferError = errorHandler.mock.calls.at(-1)![0]; + expect(err.direction).toBe('upload'); + + // uploadState is cleared, so a fresh upload starts instead of throwing + // "Upload already in progress" (the wedge this fixes). Reaching a handle + // (not a throw) plus the initiateFileCopy call proves it restarted. + s.invokeExtension.mockClear(); + const second = p.uploadFiles(files); + expect(s.invokeExtension).toHaveBeenCalledTimes(1); + + p.dispose(); + await expect(second.completion).rejects.toThrow('RdpFileTransferProvider disposed'); + }); + + it('leaves an accepted advertise (ok=true) untouched', async () => { + const { provider: p } = setupProvider(); + const files = [new File(['x'], 'x.txt', { type: 'text/plain' })]; + const { completion } = p.uploadFiles(files); + + fireFormatListResponse(p, true); + + // Upload is still in progress. + expect(p.isUploadInProgress()).toBe(true); + + p.dispose(); + await expect(completion).rejects.toThrow('RdpFileTransferProvider disposed'); + }); + + it('surfaces a reject with no upload in progress without erroring', () => { + const errorHandler = vi.fn(); + const eventHandler = vi.fn(); + provider.on('error', errorHandler); + provider.on('format-list-response', eventHandler); + + expect(() => fireFormatListResponse(provider, false)).not.toThrow(); + expect(eventHandler).toHaveBeenCalledWith(false); + expect(errorHandler).not.toHaveBeenCalled(); + }); + }); + + describe('sanitizeFileName', () => { + it('should return a plain filename as-is', () => { + expect(RdpFileTransferProvider.sanitizeFileName('file.txt')).toBe('file.txt'); + }); + + it('should strip Unix path traversal', () => { + expect(RdpFileTransferProvider.sanitizeFileName('../../../etc/passwd')).toBe('passwd'); + }); + + it('should strip Windows path traversal', () => { + expect(RdpFileTransferProvider.sanitizeFileName('..\\..\\system32\\config\\SAM')).toBe('SAM'); + }); + + it('should extract basename from Windows absolute path', () => { + expect(RdpFileTransferProvider.sanitizeFileName('C:\\Users\\victim\\Desktop\\file.txt')).toBe('file.txt'); + }); + + it('should extract basename from Unix absolute path', () => { + expect(RdpFileTransferProvider.sanitizeFileName('/home/user/file.txt')).toBe('file.txt'); + }); + + it('should return fallback for empty string', () => { + expect(RdpFileTransferProvider.sanitizeFileName('')).toBe('unnamed_file'); + }); + + it('should return fallback for traversal-only input', () => { + expect(RdpFileTransferProvider.sanitizeFileName('../..')).toBe('unnamed_file'); + }); + + it('should handle trailing separator', () => { + expect(RdpFileTransferProvider.sanitizeFileName('path/to/file/')).toBe('file'); + }); + + it('should handle mixed separators', () => { + expect(RdpFileTransferProvider.sanitizeFileName('path/to\\file.txt')).toBe('file.txt'); + }); + + it('should keep triple-dot filename (not traversal)', () => { + expect(RdpFileTransferProvider.sanitizeFileName('...')).toBe('...'); + }); + }); + + describe('sanitizePath', () => { + it('should return undefined for empty string', () => { + expect(RdpFileTransferProvider.sanitizePath('')).toBeUndefined(); + }); + + it('should return undefined for traversal-only path', () => { + expect(RdpFileTransferProvider.sanitizePath('../..')).toBeUndefined(); + expect(RdpFileTransferProvider.sanitizePath('.')).toBeUndefined(); + }); + + it('should preserve a simple relative path', () => { + expect(RdpFileTransferProvider.sanitizePath('temp')).toBe('temp'); + }); + + it('should preserve a multi-level relative path', () => { + expect(RdpFileTransferProvider.sanitizePath('folder\\sub')).toBe('folder\\sub'); + }); + + it('should strip traversal components from path', () => { + expect(RdpFileTransferProvider.sanitizePath('..\\..\\etc')).toBe('etc'); + }); + + it('should strip drive letter prefix', () => { + expect(RdpFileTransferProvider.sanitizePath('C:\\Users\\Desktop')).toBe('Users\\Desktop'); + }); + + it('should normalize Unix separators to backslash', () => { + expect(RdpFileTransferProvider.sanitizePath('folder/sub')).toBe('folder\\sub'); + }); + + it('should handle mixed separators', () => { + expect(RdpFileTransferProvider.sanitizePath('folder/sub\\dir')).toBe('folder\\sub\\dir'); + }); + + it('should return undefined if only drive letter remains', () => { + expect(RdpFileTransferProvider.sanitizePath('C:')).toBeUndefined(); + }); + + it('should strip UNC long path prefix with drive letter', () => { + expect(RdpFileTransferProvider.sanitizePath('?\\C:\\Users\\Desktop')).toBe('Users\\Desktop'); + }); + + it('should strip UNC device prefix', () => { + expect(RdpFileTransferProvider.sanitizePath('.\\device\\path')).toBe('device\\path'); + }); + + it('should return undefined if only UNC prefix remains', () => { + expect(RdpFileTransferProvider.sanitizePath('?\\C:')).toBeUndefined(); + }); + }); + + describe('directory drag-and-drop traversal', () => { + function mockDirEntry(name: string, children: FileSystemEntry[]): FileSystemDirectoryEntry { + return { + isFile: false, + isDirectory: true, + name, + fullPath: `/${name}`, + filesystem: {} as FileSystem, + getParent: vi.fn(), + createReader: () => { + let read = false; + return { + readEntries: (cb: (entries: FileSystemEntry[]) => void) => { + if (!read) { + read = true; + cb(children); + } else { + cb([]); + } + }, + } as unknown as FileSystemDirectoryReader; + }, + getFile: vi.fn(), + getDirectory: vi.fn(), + } as unknown as FileSystemDirectoryEntry; + } + + function mockFileEntry(name: string, content: string): FileSystemFileEntry { + const file = new File([content], name); + return { + isFile: true, + isDirectory: false, + name, + fullPath: `/${name}`, + filesystem: {} as FileSystem, + getParent: vi.fn(), + file: (cb: (f: File) => void) => cb(file), + createWriter: vi.fn(), + } as unknown as FileSystemFileEntry; + } + + function dropEventWithEntries(entries: FileSystemEntry[]): DragEvent { + const items = entries.map((entry) => ({ + kind: 'file', + webkitGetAsEntry: () => entry, + })); + + return { + dataTransfer: { items }, + preventDefault: vi.fn(), + } as unknown as DragEvent; + } + + it('should recursively traverse a dropped folder', async () => { + const child1 = mockFileEntry('a.txt', 'aaa'); + const child2 = mockFileEntry('b.txt', 'bbb'); + const folder = mockDirEntry('myFolder', [child1, child2]); + + const result = await provider.handleDrop(dropEventWithEntries([folder])); + + expect(result).toHaveLength(3); + + expect(result[0].name).toBe('myFolder'); + expect(result[0].isDirectory).toBe(true); + expect(result[0].file).toBeNull(); + expect(result[0].path).toBeUndefined(); + + expect(result[1].name).toBe('a.txt'); + expect(result[1].path).toBe('myFolder'); + expect(result[1].file).toBeInstanceOf(File); + + expect(result[2].name).toBe('b.txt'); + expect(result[2].path).toBe('myFolder'); + }); + + it('should handle nested directories with correct paths', async () => { + const deepFile = mockFileEntry('deep.txt', 'deep'); + const subDir = mockDirEntry('sub', [deepFile]); + const topDir = mockDirEntry('top', [subDir]); + + const result = await provider.handleDrop(dropEventWithEntries([topDir])); + + expect(result).toHaveLength(3); + expect(result[0]).toMatchObject({ name: 'top', isDirectory: true, path: undefined }); + expect(result[1]).toMatchObject({ name: 'sub', isDirectory: true, path: 'top' }); + expect(result[2]).toMatchObject({ name: 'deep.txt', path: 'top\\sub' }); + expect(result[2].file).toBeInstanceOf(File); + }); + + it('should handle mixed files and folders at root level', async () => { + const rootFile = mockFileEntry('root.txt', 'r'); + const folderFile = mockFileEntry('inside.txt', 'i'); + const folder = mockDirEntry('dir', [folderFile]); + + const result = await provider.handleDrop(dropEventWithEntries([rootFile, folder])); + + expect(result).toHaveLength(3); + expect(result[0]).toMatchObject({ name: 'root.txt', path: undefined }); + expect(result[1]).toMatchObject({ name: 'dir', isDirectory: true }); + expect(result[2]).toMatchObject({ name: 'inside.txt', path: 'dir' }); + }); + + it('should respect the max entry count limit', async () => { + const children: FileSystemEntry[] = []; + for (let i = 0; i < 1001; i++) { + children.push(mockFileEntry(`file${i}.txt`, `${i}`)); + } + const bigDir = mockDirEntry('big', children); + + const result = await provider.handleDrop(dropEventWithEntries([bigDir])); + expect(result.length).toBeLessThanOrEqual(1000); + }); + + it('should handle empty directories', async () => { + const emptyDir = mockDirEntry('empty', []); + + const result = await provider.handleDrop(dropEventWithEntries([emptyDir])); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: 'empty', + isDirectory: true, + file: null, + size: 0, + }); + }); + }); + + describe('handleFilesAvailable preserves path and isDirectory', () => { + it('should pass through path and isDirectory from files', () => { + const receivedFiles: FileInfo[][] = []; + provider.on('files-available', (files: FileInfo[]) => receivedFiles.push(files)); + + // @ts-expect-error - accessing private method for testing + provider.handleFilesAvailable([ + { name: 'readme.txt', size: 100, lastModified: 0 }, + { name: 'report.pdf', path: 'docs', size: 2048, lastModified: 0 }, + { name: 'images', path: 'docs', size: 0, lastModified: 0, isDirectory: true }, + { name: 'photo.png', path: 'docs\\images', size: 4096, lastModified: 0 }, + ]); + + expect(receivedFiles).toHaveLength(1); + const files = receivedFiles[0]; + expect(files).toHaveLength(4); + + expect(files[0].name).toBe('readme.txt'); + expect(files[0].path).toBeUndefined(); + expect(files[0].isDirectory).toBeUndefined(); + + expect(files[1].name).toBe('report.pdf'); + expect(files[1].path).toBe('docs'); + + expect(files[2].name).toBe('images'); + expect(files[2].path).toBe('docs'); + expect(files[2].isDirectory).toBe(true); + + expect(files[3].name).toBe('photo.png'); + expect(files[3].path).toBe('docs\\images'); + + provider.dispose(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Constructor validation for storageBackend option +// --------------------------------------------------------------------------- + +describe('RdpFileTransferProvider storageBackend validation', () => { + it('rejects an object missing createWriteHandle', () => { + expect( + () => + new RdpFileTransferProvider({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + storageBackend: { dispose: async () => {} } as any, + }), + ).toThrow(/createWriteHandle\(\) and dispose\(\)/); + }); + + it('rejects an object missing dispose', () => { + expect( + () => + new RdpFileTransferProvider({ + storageBackend: { + createWriteHandle: async () => ({}), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }), + ).toThrow(/createWriteHandle\(\) and dispose\(\)/); + }); + + it('rejects an invalid preference string', () => { + expect( + () => + new RdpFileTransferProvider({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + storageBackend: 'opfs' as any, + }), + ).toThrow(/invalid preference 'opfs'/); + }); + + it('accepts a valid FileStorageBackend object', () => { + expect( + () => + new RdpFileTransferProvider({ + storageBackend: { + name: 'test', + createWriteHandle: async () => ({}) as never, + dispose: async () => {}, + }, + }), + ).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Download flow with injected storage backend +// --------------------------------------------------------------------------- + +import type { FileStorageBackend, FileWriteHandle } from './storage'; + +/** + * Helper to build an 8-byte little-endian SIZE response for a given file size. + */ +function makeSizeResponse(streamId: number, size: number): { streamId: number; isError: boolean; data: Uint8Array } { + const buf = new ArrayBuffer(8); + new DataView(buf).setBigUint64(0, BigInt(size), true); + return { streamId, isError: false, data: new Uint8Array(buf) }; +} + +function makeDataResponse(streamId: number, bytes: number[]): { streamId: number; isError: boolean; data: Uint8Array } { + return { streamId, isError: false, data: new Uint8Array(bytes) }; +} + +function makeErrorResponse(streamId: number): { streamId: number; isError: boolean; data: Uint8Array } { + return { streamId, isError: true, data: new Uint8Array(0) }; +} + +/** + * Create a mock FileStorageBackend that records all operations. + */ +function createMockStorageBackend(options?: { + createWriteHandleError?: Error; + writeError?: Error; + finalizeError?: Error; +}) { + const writes: Uint8Array[] = []; + let finalized = false; + let aborted = false; + let bytesWritten = 0; + + const writeHandle: FileWriteHandle = { + get bytesWritten() { + return bytesWritten; + }, + async write(chunk: Uint8Array) { + if (options?.writeError) throw options.writeError; + writes.push(new Uint8Array(chunk)); + bytesWritten += chunk.length; + }, + async finalize() { + if (options?.finalizeError) throw options.finalizeError; + finalized = true; + return new Blob(writes); + }, + async abort() { + aborted = true; + }, + }; + + const backend: FileStorageBackend = { + name: 'mock', + async createWriteHandle(_fileName: string, _expectedSize: number) { + if (options?.createWriteHandleError) throw options.createWriteHandleError; + return writeHandle; + }, + async dispose() {}, + }; + + return { + backend, + writeHandle, + get writes() { + return writes; + }, + get finalized() { + return finalized; + }, + get aborted() { + return aborted; + }, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type CallbackMap = Record void>; + +/** + * Set up a provider with an injected storage backend and extract the + * protocol callbacks from getBuilderExtensions(). + */ +function setupDownloadTest(backendOrOptions?: FileStorageBackend | Parameters[0]) { + let mockStorage: ReturnType; + let backend: FileStorageBackend; + + if (backendOrOptions !== undefined && 'name' in backendOrOptions) { + backend = backendOrOptions; + mockStorage = undefined as unknown as ReturnType; + } else { + mockStorage = createMockStorageBackend(backendOrOptions); + backend = mockStorage.backend; + } + + const provider = new RdpFileTransferProvider({ + chunkSize: 4, // small chunks for testing + storageBackend: backend, + }); + + const session = new MockSession(); + provider.setSession(session); + + // Extract callback functions from the mocked extensions + const extensions = provider.getBuilderExtensions(); + const callbacks: CallbackMap = {}; + for (const ext of extensions) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const e = ext as any; + if (typeof e.value === 'function') { + callbacks[e.ident] = e.value; + } + } + + return { provider, session, callbacks, mockStorage }; +} + +describe('download flow with storage backend', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('happy path: SIZE + DATA -> write -> finalize -> resolve', async () => { + const { provider, session, callbacks, mockStorage } = setupDownloadTest(); + const fileInfo: FileInfo = { name: 'test.bin', size: 6, lastModified: 0 }; + + // Announce files available + callbacks['files_available_callback']([fileInfo]); + + // Start download + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + expect(transferId).toBeGreaterThan(0); + + // Session should have received the SIZE request + expect(session.invokeExtension).toHaveBeenCalledTimes(1); + + // Feed SIZE response (6 bytes) + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 6)); + + // Wait a tick for the async write handle init to complete + await new Promise((r) => setTimeout(r, 10)); + + // Session should now have received a RANGE request + expect(session.invokeExtension.mock.calls.length).toBeGreaterThanOrEqual(2); + + // Feed DATA responses (chunkSize=4, so two chunks: 4+2) + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + await new Promise((r) => setTimeout(r, 10)); + + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [5, 6])); + await new Promise((r) => setTimeout(r, 10)); + + // Download should complete + const blob = await completion; + expect(blob.size).toBe(6); + expect(mockStorage.finalized).toBe(true); + expect(mockStorage.writes).toHaveLength(2); + + provider.dispose(); + }); + + it('empty file (size=0) resolves without creating a write handle', async () => { + const { provider, callbacks, mockStorage } = setupDownloadTest(); + const fileInfo: FileInfo = { name: 'empty.bin', size: 0, lastModified: 0 }; + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Feed SIZE response: 0 bytes + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 0)); + + const blob = await completion; + expect(blob.size).toBe(0); + // Write handle should NOT have been created for empty files + expect(mockStorage.finalized).toBe(false); + expect(mockStorage.writes).toHaveLength(0); + + provider.dispose(); + }); + + it('remote error response rejects the download', async () => { + const { provider, callbacks } = setupDownloadTest(); + const fileInfo: FileInfo = { name: 'fail.bin', size: 100, lastModified: 0 }; + + const errorHandler = vi.fn(); + provider.on('error', errorHandler); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Feed error response + callbacks['file_contents_response_callback'](makeErrorResponse(transferId)); + + await expect(completion).rejects.toThrow('Remote failed to provide file contents'); + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler.mock.calls[0][0].direction).toBe('download'); + + provider.dispose(); + }); + + it('storage backend init failure emits error', async () => { + const { provider, callbacks } = setupDownloadTest({ + createWriteHandleError: new Error('disk full'), + }); + const fileInfo: FileInfo = { name: 'fail.bin', size: 100, lastModified: 0 }; + + const errorHandler = vi.fn(); + provider.on('error', errorHandler); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Feed SIZE response to trigger write handle creation + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 100)); + + // Wait for the async init to fail + await expect(completion).rejects.toThrow('Failed to initialize storage for download'); + expect(errorHandler).toHaveBeenCalledTimes(1); + + provider.dispose(); + }); + + it('write failure emits error and aborts', async () => { + const { provider, callbacks, mockStorage } = setupDownloadTest({ + writeError: new Error('I/O error'), + }); + const fileInfo: FileInfo = { name: 'fail.bin', size: 4, lastModified: 0 }; + + const errorHandler = vi.fn(); + provider.on('error', errorHandler); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Attach a rejection handler immediately to prevent unhandled rejection + const completionResult = completion.catch((e: unknown) => e); + + // Feed SIZE response + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 4)); + await new Promise((r) => setTimeout(r, 10)); + + // Feed DATA response -- the write will fail + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + await new Promise((r) => setTimeout(r, 10)); + + const error = await completionResult; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Failed to write download chunk to storage'); + expect(mockStorage.aborted).toBe(true); + + provider.dispose(); + }); + + it('QuotaExceededError produces a specific error message', async () => { + const quotaError = new DOMException('quota exceeded', 'QuotaExceededError'); + const { provider, callbacks } = setupDownloadTest({ + writeError: quotaError, + }); + const fileInfo: FileInfo = { name: 'big.bin', size: 4, lastModified: 0 }; + + const errorHandler = vi.fn(); + provider.on('error', errorHandler); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Attach a rejection handler immediately to prevent unhandled rejection + const completionResult = completion.catch((e: unknown) => e); + + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 4)); + await new Promise((r) => setTimeout(r, 10)); + + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + await new Promise((r) => setTimeout(r, 10)); + + const error = await completionResult; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/[Ss]torage quota exceeded/); + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler.mock.calls[0][0].message).toMatch(/[Ss]torage quota exceeded/); + + provider.dispose(); + }); + + it('dispose during download aborts write handle', async () => { + const { provider, callbacks, mockStorage } = setupDownloadTest(); + const fileInfo: FileInfo = { name: 'partial.bin', size: 100, lastModified: 0 }; + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Feed SIZE response and wait for write handle init + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 100)); + await new Promise((r) => setTimeout(r, 10)); + + // Feed one chunk + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + await new Promise((r) => setTimeout(r, 10)); + + // Dispose mid-download + provider.dispose(); + + await expect(completion).rejects.toThrow('disposed'); + expect(mockStorage.aborted).toBe(true); + }); + + it('emits download-progress during transfer', async () => { + const { provider, callbacks } = setupDownloadTest(); + const fileInfo: FileInfo = { name: 'progress.bin', size: 8, lastModified: 0 }; + const progressEvents: Array<{ bytesTransferred: number; percentage: number }> = []; + + provider.on('download-progress', (p) => progressEvents.push(p)); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 8)); + await new Promise((r) => setTimeout(r, 10)); + + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + await new Promise((r) => setTimeout(r, 10)); + + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [5, 6, 7, 8])); + await new Promise((r) => setTimeout(r, 10)); + + await completion; + + expect(progressEvents.length).toBeGreaterThanOrEqual(2); + expect(progressEvents[0].bytesTransferred).toBe(4); + expect(progressEvents[0].percentage).toBe(50); + expect(progressEvents[progressEvents.length - 1].percentage).toBe(100); + + provider.dispose(); + }); + + it('emits download-complete on success', async () => { + const { provider, callbacks } = setupDownloadTest(); + const fileInfo: FileInfo = { name: 'done.bin', size: 2, lastModified: 0 }; + const completeEvents: Array<{ fileInfo: FileInfo; blob: Blob }> = []; + + provider.on('download-complete', (fi, blob) => completeEvents.push({ fileInfo: fi, blob })); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 2)); + await new Promise((r) => setTimeout(r, 10)); + + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [0xca, 0xfe])); + await new Promise((r) => setTimeout(r, 10)); + + await completion; + + expect(completeEvents).toHaveLength(1); + expect(completeEvents[0].fileInfo.name).toBe('done.bin'); + expect(completeEvents[0].blob.size).toBe(2); + + provider.dispose(); + }); +}); + +// --------------------------------------------------------------------------- +// Write-handle-ready race path tests +// --------------------------------------------------------------------------- + +describe('download flow with delayed write handle init', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + /** + * Create a mock backend whose createWriteHandle resolves after an + * explicit trigger, simulating slow OPFS init. + */ + function createDelayedBackend() { + const writes: Uint8Array[] = []; + let finalized = false; + let aborted = false; + let bytesWritten = 0; + let resolveInit!: () => void; + let rejectInit!: (err: Error) => void; + + const initPromise = new Promise((resolve, reject) => { + resolveInit = resolve; + rejectInit = reject; + }); + + const writeHandle: FileWriteHandle = { + get bytesWritten() { + return bytesWritten; + }, + async write(chunk: Uint8Array) { + writes.push(new Uint8Array(chunk)); + bytesWritten += chunk.length; + }, + async finalize() { + finalized = true; + return new Blob(writes); + }, + async abort() { + aborted = true; + }, + }; + + const backend: FileStorageBackend = { + name: 'delayed-mock', + async createWriteHandle(_fileName: string, _expectedSize: number) { + await initPromise; + return writeHandle; + }, + async dispose() {}, + }; + + return { + backend, + /** Call to let createWriteHandle resolve. */ + resolveInit, + /** Call to make createWriteHandle fail. */ + rejectInit, + get writes() { + return writes; + }, + get finalized() { + return finalized; + }, + get aborted() { + return aborted; + }, + }; + } + + it('DATA arriving before write handle is ready awaits init then writes', async () => { + const delayed = createDelayedBackend(); + const { provider, callbacks } = setupDownloadTest(delayed.backend); + const fileInfo: FileInfo = { name: 'race.bin', size: 4, lastModified: 0 }; + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // SIZE response triggers write handle init (which blocks on initPromise) + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 4)); + + // DATA arrives while write handle init is still pending + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + + // Let microtasks settle -- handleDataChunk should be awaiting writeHandleReady + await new Promise((r) => setTimeout(r, 10)); + + // Writes should NOT have happened yet + expect(delayed.writes).toHaveLength(0); + + // Now release the init + delayed.resolveInit(); + await new Promise((r) => setTimeout(r, 10)); + + // Download should complete + const blob = await completion; + expect(blob.size).toBe(4); + expect(delayed.writes).toHaveLength(1); + expect(delayed.finalized).toBe(true); + + provider.dispose(); + }); + + it('init failure while DATA is waiting does not hang', async () => { + const delayed = createDelayedBackend(); + const { provider, callbacks } = setupDownloadTest(delayed.backend); + const fileInfo: FileInfo = { name: 'fail-race.bin', size: 4, lastModified: 0 }; + + const errorHandler = vi.fn(); + provider.on('error', errorHandler); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Attach rejection handler early to prevent unhandled rejection warning. + const completionResult = completion.catch((e: unknown) => e); + + // SIZE response triggers write handle init + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 4)); + + // DATA arrives while init is pending + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + + // Fail the init + delayed.rejectInit(new Error('OPFS broken')); + await new Promise((r) => setTimeout(r, 10)); + + // Download should reject (not hang) + const error = await completionResult; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Failed to initialize storage for download'); + expect(delayed.writes).toHaveLength(0); + expect(errorHandler).toHaveBeenCalledTimes(1); + + provider.dispose(); + }); + + it('dispose during pending write-handle init does not leak', async () => { + const delayed = createDelayedBackend(); + const { provider, callbacks } = setupDownloadTest(delayed.backend); + const fileInfo: FileInfo = { name: 'dispose-race.bin', size: 100, lastModified: 0 }; + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // SIZE response triggers write handle init (blocked) + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 100)); + await new Promise((r) => setTimeout(r, 5)); + + // Dispose before init completes + provider.dispose(); + + await expect(completion).rejects.toThrow('disposed'); + + // Resolve init after dispose -- should not cause errors + delayed.resolveInit(); + await new Promise((r) => setTimeout(r, 10)); + + // No writes should have occurred + expect(delayed.writes).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Edge case error path tests +// --------------------------------------------------------------------------- + +describe('download flow edge cases', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('finalize() failure emits error and rejects', async () => { + const { provider, callbacks, mockStorage } = setupDownloadTest({ + finalizeError: new Error('disk corruption'), + }); + const fileInfo: FileInfo = { name: 'corrupt.bin', size: 4, lastModified: 0 }; + + const errorHandler = vi.fn(); + provider.on('error', errorHandler); + + callbacks['files_available_callback']([fileInfo]); + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Attach rejection handler early. + const completionResult = completion.catch((e: unknown) => e); + + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 4)); + await new Promise((r) => setTimeout(r, 10)); + + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + await new Promise((r) => setTimeout(r, 10)); + + const error = await completionResult; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Failed to finalize downloaded file'); + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler.mock.calls[0][0].message).toBe('Failed to finalize downloaded file'); + expect(mockStorage.aborted).toBe(true); + + provider.dispose(); + }); + + it('lock expiration aborts affected downloads', async () => { + const { provider, callbacks, mockStorage } = setupDownloadTest(); + const fileInfo: FileInfo = { name: 'locked.bin', size: 100, lastModified: 0 }; + + const errorHandler = vi.fn(); + provider.on('error', errorHandler); + + // Announce files with a clipDataId (lock ID). + callbacks['files_available_callback']([fileInfo], 42); + + const { transferId, completion } = provider.downloadFile(fileInfo, 0); + + // Attach rejection handler early. + const completionResult = completion.catch((e: unknown) => e); + + // Feed SIZE and first chunk. + callbacks['file_contents_response_callback'](makeSizeResponse(transferId, 100)); + await new Promise((r) => setTimeout(r, 10)); + + callbacks['file_contents_response_callback'](makeDataResponse(transferId, [1, 2, 3, 4])); + await new Promise((r) => setTimeout(r, 10)); + + // Expire the lock. + callbacks['locks_expired_callback'](new Uint32Array([42])); + + const error = await completionResult; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toMatch(/timed out/i); + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler.mock.calls[0][0].message).toMatch(/timed out/i); + expect(mockStorage.aborted).toBe(true); + + provider.dispose(); + }); +}); diff --git a/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.ts b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.ts new file mode 100644 index 0000000000..6a08162f0d --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/RdpFileTransferProvider.ts @@ -0,0 +1,2017 @@ +import { Extension } from '../../../crates/ironrdp-web/pkg/ironrdp_web'; +import type { FileInfo, FileContentsRequest, FileContentsResponse } from './FileTransfer'; +import { FileContentsFlags } from './FileContentsFlags'; +import { + filesAvailableCallback, + fileContentsRequestCallback, + fileContentsResponseCallback, + lockCallback, + unlockCallback, + locksExpiredCallback, + formatListResponseCallback, + requestFileContents, + submitFileContents, + initiateFileCopy, +} from './extensions'; +import type { FileStorageBackend, FileWriteHandle } from './storage'; +import { detectStorageBackend } from './storage'; +import type { StorageBackendPreference } from './storage'; + +/** + * Minimal session interface for extension-based file transfer. + * Protocol-agnostic - only requires invokeExtension(). + */ +interface ExtensionSession { + invokeExtension(ext: Extension): unknown; +} + +/** + * Configuration options for RdpFileTransferProvider. + */ +export interface RdpFileTransferProviderOptions { + /** + * Chunk size in bytes for file transfers. + * Default: 65536 (64KB) + */ + chunkSize?: number; + + /** + * Called when an upload begins (before the FormatList is sent to the remote). + * Use this to suppress clipboard monitoring so the 100ms polling loop does + * not clobber the file upload's FormatList with a text/image update. + */ + onUploadStarted?: () => void; + + /** + * Called when an upload finishes (success, failure, or dispose). + * Use this to resume clipboard monitoring after {@link onUploadStarted}. + */ + onUploadFinished?: () => void; + + /** + * Storage backend for downloads. + * + * Accepts either a preference string or a pre-constructed backend + * instance (useful for testing or custom backends): + * + * - `'auto'` (default): use OPFS when available, fall back to + * in-memory Blob. OPFS reduces peak RAM from ~2x file size to + * ~chunk size. + * - `'blob'`: force in-memory Blob storage. + * - `FileStorageBackend`: use the provided backend instance directly, + * bypassing auto-detection. + */ + storageBackend?: StorageBackendPreference | FileStorageBackend; +} + +/** + * Progress information for file transfer operations. + */ +export interface TransferProgress { + /** Unique identifier for this transfer operation, stable across the lifetime of the transfer */ + transferId: number; + /** File index in the transfer list */ + fileIndex: number; + /** File name */ + fileName: string; + /** Number of bytes transferred so far */ + bytesTransferred: number; + /** Total file size in bytes */ + totalBytes: number; + /** Transfer progress as percentage (0-100) */ + percentage: number; +} + +/** + * Error information for file transfer operations. + */ +export interface FileTransferError { + /** Error message */ + message: string; + /** Unique transfer identifier (if applicable) */ + transferId?: number; + /** File index that failed (if applicable) */ + fileIndex?: number; + /** File name that failed (if applicable) */ + fileName?: string; + /** Transfer direction that caused the error (if applicable) */ + direction?: 'download' | 'upload'; + /** Underlying error cause */ + cause?: unknown; +} + +/** + * Result of initiating a file download via {@link RdpFileTransferProvider.downloadFile}. + */ +export interface DownloadHandle { + /** Unique identifier for this download, available synchronously before the download completes */ + transferId: number; + /** Promise that resolves with the downloaded file blob */ + completion: Promise; +} + +/** + * A file extracted from a drag-and-drop event, with optional path metadata + * from directory traversal via the File and Directory Entries API. + * + * When a folder is dropped, its contents are recursively enumerated and each + * file is returned with a {@link path} relative to the drop root. Directory + * entries themselves are included with {@link isDirectory} set to `true` and a + * `null` {@link file} handle (they carry no data, only structure). + */ +export interface DroppedFile { + /** Browser File handle. `null` for directory-only entries. */ + file: File | null; + /** File or directory basename (e.g. `"report.pdf"` or `"images"`). */ + name: string; + /** Size in bytes. Always 0 for directory entries. */ + size: number; + /** Last-modified timestamp (ms since Unix epoch, same as `File.lastModified`). */ + lastModified: number; + /** + * Relative directory path within the dropped collection, using `\` as + * separator to match the Windows wire convention (MS-RDPECLIP 3.1.1.2). + * `undefined` for entries at the drop root. + * + * @example + * // File "photo.png" inside dropped folder "docs/images": + * { name: "photo.png", path: "docs\\images", ... } + */ + path?: string; + /** Whether this entry represents a directory rather than a file. */ + isDirectory?: boolean; +} + +/** + * Result of initiating a file upload via {@link RdpFileTransferProvider.uploadFiles}. + */ +export interface UploadHandle { + /** Map of file index to unique transfer identifier for each file in the batch */ + transferIds: Map; + /** Promise that resolves when all files in the batch have been uploaded */ + completion: Promise; +} + +/** + * Internal state for tracking active file transfers. + */ +interface TransferState { + fileInfo: FileInfo; + fileIndex: number; + streamId: number; + clipDataId?: number; + expectedSize?: number; + writeHandle?: FileWriteHandle; + /** Resolves when `writeHandle` has been assigned. Always resolves (never + * rejects) so that concurrent `handleDataChunk` callers awaiting this + * promise do not need individual error handling -- failures are detected + * via the `!state.writeHandle` guard after the await. */ + writeHandleReady?: Promise; + bytesReceived: number; + resolve: (blob: Blob) => void; + reject: (error: Error) => void; +} + +/** + * Internal state for tracking file uploads. + */ +interface UploadState { + /** File handles indexed by position in the file list. `null` entries + * represent directory-only entries which carry no data. */ + files: (File | null)[]; + /** DroppedFile metadata for each entry, parallel to `files`. */ + droppedFiles: DroppedFile[]; + /** File indices that have permanently failed (e.g. read timeout). */ + failedFiles: Set; + expectedFileCount: number; + completedFiles: Set; + /** Tracks total bytes served per file index across all RANGE responses. + * Used for robust upload completion detection regardless of chunk order. */ + bytesServed: Map; + activeReaders: Map; + readerTimeouts: Map>; + /** Maps file index to unique transfer identifier for each file in the batch. */ + transferIds: Map; + resolve: () => void; + reject: (error: Error) => void; + /** True when this state was rebuilt from retainedFiles for a re-paste. + * Skips onUploadStarted/onUploadFinished lifecycle callbacks since the + * original upload already completed and monitoring is in the right state. */ + isRePaste?: boolean; +} + +type EventHandler = (...args: T) => void; + +type EventMap = { + 'download-progress': [TransferProgress]; + 'upload-progress': [TransferProgress]; + 'download-complete': [FileInfo, Blob, number, number]; + 'upload-complete': [File, number, number]; + /** Emitted when an upload batch begins (both initial paste and re-paste). + * Provides the full batch of transferIds and DroppedFile metadata so + * listeners can register all transfers eagerly before progress events arrive. */ + 'upload-batch-started': [Map, DroppedFile[]]; + 'files-available': [FileInfo[]]; + /** Remote's response to one of our outbound Format Lists: `true` = accepted + * (CB_RESPONSE_OK), `false` = rejected (CB_RESPONSE_FAIL). Fires for every + * outbound advertise, so consumers can drive paste from it (e.g. inject the + * paste keystroke on accept, retry on reject). */ + 'format-list-response': [boolean]; + error: [FileTransferError]; +}; + +/** + * RdpFileTransferProvider provides a high-level API for bidirectional file transfer + * in browser-based RDP sessions. + * + * This class wraps the low-level WASM file transfer API and handles: + * - State management for downloads and uploads + * - Chunking and reassembly + * - Progress tracking + * - Browser integration helpers (file picker, drag-and-drop) + * + * ## Clipboard Locking + * + * Clipboard locks are managed automatically by the Rust cliprdr processor. When + * the remote copies files (FormatList containing FileGroupDescriptorW), a lock + * is acquired automatically. The lock ID is passed to RdpFileTransferProvider via + * the filesAvailable callback and used in all subsequent FileContentsRequest + * PDUs. Lock lifecycle (expiry on clipboard change, Unlock PDU emission) is + * handled entirely by the Rust layer - no explicit lock/unlock calls are needed. + * + * ## Error Handling Best Practices + * + * Production applications should implement comprehensive error handling: + * + * @example Basic Usage + * ```typescript + * const provider = new RdpFileTransferProvider({ chunkSize: 64 * 1024 }); + * component.enableFileTransfer(provider); + * await component.connect(config); + * + * // Handle downloads from remote + * provider.on('files-available', async (files) => { + * for (const file of files) { + * const { completion } = provider.downloadFile(file, files.indexOf(file)); + * const blob = await completion; + * saveAs(blob, file.name); + * } + * }); + * + * // Handle uploads using file picker + * const files = await provider.showFilePicker({ multiple: true }); + * provider.uploadFiles(files); + * ``` + * + * @example Error Handling + * ```typescript + * provider.on('error', (error) => { + * console.error('Transfer error:', error.message); + * if (error.fileName) { + * showNotification(`Failed to transfer ${error.fileName}: ${error.message}`); + * } + * }); + * ``` + * + * @example Handling Lock Expiration + * ```typescript + * provider.on('error', (error) => { + * if (error.message.includes('lock expired')) { + * showNotification( + * 'File download timed out', + * 'The transfer took too long. Try a faster connection or smaller files.', + * 'warning' + * ); + * } + * }); + * ``` + */ +export class RdpFileTransferProvider { + /** Maximum file size for downloads (2GB) to prevent browser out-of-memory errors */ + private static readonly MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; + /** Timeout for FileReader operations (60 seconds) to prevent stalled uploads */ + private static readonly FILE_READER_TIMEOUT_MS = 60 * 1000; + /** + * How long to keep clipboard monitoring suppressed after advertising an upload + * while waiting for the remote to pull the files (first FileContentsRequest), + * before giving up. Matches the Rust cliprdr lock inactivity timeout (60s), so + * the JS side gives up exactly when the protocol lock does. If the remote never + * requests contents (the paste landed in a non-file target, or the advertise was + * clobbered), the watchdog resumes monitoring and fails the upload so its state + * cannot wedge later uploads. + */ + private static readonly PASTE_ACK_TIMEOUT_MS = 60 * 1000; + /** + * Upload inactivity window. After the remote starts pulling, each FileContentsRequest + * resets this; if pulls then stop for this long -- e.g. the remote grabbed the + * clipboard with a text/image copy that never reaches handleFilesAvailable -- the + * upload is failed so `uploadState` is released and later uploads aren't wedged. A + * slow-but-progressing transfer keeps resetting it, so it is never killed. + */ + private static readonly UPLOAD_INACTIVITY_TIMEOUT_MS = 60 * 1000; + /** Timeout for storage backend write handle initialization (30 seconds). */ + private static readonly WRITE_HANDLE_INIT_TIMEOUT_MS = 30 * 1000; + /** Maximum recursion depth when traversing dropped directories. */ + private static readonly MAX_DIRECTORY_DEPTH = 32; + /** Maximum total entries (files + directories) collected from a single drop. */ + private static readonly MAX_DIRECTORY_ENTRIES = 1000; + + private session?: ExtensionSession; + private readonly chunkSize: number; + private readonly onUploadStarted?: () => void; + private readonly onUploadFinished?: () => void; + private readonly storagePreference: StorageBackendPreference; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly eventHandlers: Map>> = new Map(); + + private storageBackend?: FileStorageBackend; + private storageBackendReady?: Promise; + private activeDownloads: Map = new Map(); + private uploadState?: UploadState; + // Upload paste-window watchdog. Armed when we advertise an upload, disarmed on the + // first FileContentsRequest (the remote pulled the files). Being armed is the single + // "advertised but not yet pulled" signal: on timeout it resumes monitoring and fails + // the never-pulled upload, and handleFormatListResponse consults it to fail a refused + // paste early (handleUnlock is a deliberate no-op: Unlock is not a reliable failure + // signal). Upload completion/failure run only after that first request, so they need + // not touch it. + private pasteAckTimeout?: ReturnType; + // Upload inactivity watchdog. Armed/reset on each FileContentsRequest once the remote + // starts pulling; fires if pulls stop for UPLOAD_INACTIVITY_TIMEOUT_MS (a stalled or + // clipboard-superseded paste) to release uploadState. Complements the paste-ack + // watchdog above, which only covers the "advertised but never pulled" case. + private uploadInactivityTimeout?: ReturnType; + // True between onUploadStarted (suppress monitoring) and onUploadFinished + // (resume), so resume fires exactly once even though it is now deferred until + // the paste is pulled, times out, fails, or the provider is disposed. + private uploadMonitoringSuppressed = false; + // DroppedFile metadata retained after upload completes so re-paste works + // without re-dropping. Cleared when a new upload starts or the manager is disposed. + private retainedFiles?: DroppedFile[]; + private availableFiles: FileInfo[] = []; + // Clipboard lock ID received with the most recent file list. The Rust layer + // acquires this lock automatically when FileGroupDescriptorW is detected in + // the FormatList. Downloads use it instead of explicit lock/unlock calls - + // the lock lifecycle is managed entirely by the Rust cliprdr processor. + private clipDataId?: number; + private nextStreamId: number = 1; + private disposed: boolean = false; + + constructor(options?: RdpFileTransferProviderOptions) { + this.chunkSize = options?.chunkSize ?? 65536; // Default: 64KB + this.onUploadStarted = options?.onUploadStarted; + this.onUploadFinished = options?.onUploadFinished; + + const sb = options?.storageBackend; + if (typeof sb === 'object' && sb !== null) { + if ( + typeof (sb as FileStorageBackend).createWriteHandle !== 'function' || + typeof (sb as FileStorageBackend).dispose !== 'function' + ) { + throw new Error( + "storageBackend: expected 'auto', 'blob', or a FileStorageBackend " + + 'with createWriteHandle() and dispose() methods', + ); + } + this.storageBackend = sb; + this.storagePreference = 'auto'; // unused when backend is pre-set + } else { + const pref = sb ?? 'auto'; + if (pref !== 'auto' && pref !== 'blob') { + throw new Error( + `storageBackend: invalid preference '${pref}', expected 'auto', 'blob', or a FileStorageBackend instance`, + ); + } + this.storagePreference = pref; + } + } + + /** + * Set the session instance after connection is established. + * Called by the web component's connect() flow via the FileTransferProvider interface. + */ + setSession(session: ExtensionSession): void { + this.session = session; + } + + private ensureSession(): ExtensionSession { + if (this.session === undefined) { + throw new Error('RdpFileTransferProvider: Session not available. Ensure connect() has been called.'); + } + return this.session; + } + + /** + * Lazily initialize and return the storage backend. + * + * Detection is performed once and cached. Concurrent callers share + * the same initialization promise so the probe only runs once. + * If detection fails the cached promise is cleared so subsequent + * downloads can retry. + */ + private async ensureStorageBackend(): Promise { + if (this.storageBackend) { + return this.storageBackend; + } + + if (!this.storageBackendReady) { + this.storageBackendReady = detectStorageBackend(this.storagePreference) + .then((backend) => { + this.storageBackend = backend; + console.debug(`File transfer storage: ${backend.name}`); + return backend; + }) + .catch((error: unknown) => { + // Clear the cached promise so the next download retries + // detection instead of hitting the same failure. + this.storageBackendReady = undefined; + throw error; + }); + } + + return this.storageBackendReady; + } + + // --- Extension-based session method wrappers --- + // These replace direct session.requestFileContents() etc. calls + // with invokeExtension() to keep the Session interface protocol-agnostic. + + private sendRequestFileContents( + streamId: number, + fileIndex: number, + flags: number, + position: number, + size: number, + clipDataId?: number, + ): void { + this.ensureSession().invokeExtension( + requestFileContents({ + stream_id: streamId, + file_index: fileIndex, + flags, + position, + size, + clip_data_id: clipDataId, + }), + ); + } + + private sendSubmitFileContents(streamId: number, isError: boolean, data: Uint8Array): void { + this.session?.invokeExtension(submitFileContents({ stream_id: streamId, is_error: isError, data })); + } + + private sendInitiateFileCopy(files: FileInfo[]): void { + this.ensureSession().invokeExtension(initiateFileCopy(files)); + } + + /** + * Returns the extension objects to register on the SessionBuilder before connect(). + * Implements the FileTransferProvider interface. + */ + getBuilderExtensions(): Extension[] { + return [ + filesAvailableCallback((files: FileInfo[], clipDataId?: number) => + this.handleFilesAvailable(files, clipDataId), + ), + fileContentsRequestCallback((req: FileContentsRequest) => this.handleFileContentsRequest(req)), + fileContentsResponseCallback((resp: FileContentsResponse) => this.handleFileContentsResponse(resp)), + lockCallback((id: number) => this.handleLock(id)), + unlockCallback((id: number) => this.handleUnlock(id)), + locksExpiredCallback((ids: Uint32Array) => this.handleLocksExpired(ids)), + formatListResponseCallback((ok: boolean) => this.handleFormatListResponse(ok)), + ]; + } + + /** + * Register an event handler. + * + * @param event - Event name + * @param handler - Event handler function + * + * @example + * ```typescript + * manager.on('download-progress', (progress) => { + * console.log(`${progress.fileName}: ${progress.percentage}%`); + * }); + * ``` + */ + on(event: K, handler: EventHandler): void { + if (!this.eventHandlers.has(event)) { + this.eventHandlers.set(event, new Set()); + } + this.eventHandlers.get(event)!.add(handler); + } + + /** + * Unregister an event handler. + * + * @param event - Event name + * @param handler - Event handler function to remove + */ + off(event: K, handler: EventHandler): void { + const handlers = this.eventHandlers.get(event); + if (handlers) { + handlers.delete(handler); + } + } + + /** + * Emit an event to all registered handlers. + */ + private emit(event: K, ...args: EventMap[K]): void { + const handlers = this.eventHandlers.get(event); + if (handlers) { + for (const handler of handlers) { + try { + handler(...args); + } catch (error) { + console.error(`Error in ${event} handler:`, error); + } + } + } + } + + /** + * Download a single file from the remote. + * + * Returns a {@link DownloadHandle} with a `transferId` available synchronously + * (for immediate UI association) and a `completion` promise that resolves with + * the downloaded blob. + * + * @param fileInfo - File metadata from 'files-available' event + * @param fileIndex - Index of the file in the original file list + * @returns Handle with synchronous transferId and async completion + * + * @example + * ```typescript + * const { transferId, completion } = manager.downloadFile(fileInfo, 0); + * // transferId is available immediately for UI binding + * const blob = await completion; + * saveAs(blob, fileInfo.name); + * ``` + */ + downloadFile(fileInfo: FileInfo, fileIndex: number): DownloadHandle { + // Generate unique stream ID (serves as transferId) + const streamId = this.generateStreamId(); + + const completion = this.executeDownload(fileInfo, fileIndex, streamId); + + return { transferId: streamId, completion }; + } + + /** + * Internal: execute the async download workflow for a single file. + */ + private async executeDownload(fileInfo: FileInfo, fileIndex: number, streamId: number): Promise { + // Use the clipboard lock acquired by the Rust layer when the file list + // was received. The lock lifecycle (creation, expiry, Unlock PDUs) is + // managed entirely by the cliprdr processor - we just pass the ID + // through to FileContentsRequest so the server associates requests with + // the correct clipboard snapshot. + const clipDataId = this.clipDataId; + + // Create transfer state + const transferPromise = new Promise((resolve, reject) => { + const state: TransferState = { + fileInfo, + fileIndex, + streamId, + clipDataId, + bytesReceived: 0, + resolve, + reject, + }; + + this.activeDownloads.set(streamId, state); + }); + + // Request file size first (flags = 0x1). + // Per MS-RDPECLIP 2.2.5.3, SIZE requests MUST set cbRequested to 8. + try { + this.sendRequestFileContents(streamId, fileIndex, FileContentsFlags.SIZE, 0, 8, clipDataId); + } catch (error) { + this.activeDownloads.delete(streamId); + const err: FileTransferError = { + message: 'Failed to request file size', + transferId: streamId, + fileIndex, + fileName: fileInfo.name, + direction: 'download', + cause: error, + }; + this.emit('error', err); + throw new Error(err.message, { cause: error }); + } + + return transferPromise; + } + + /** + * Download multiple files sequentially. + * + * @param files - Array of FileInfo from 'files-available' event + * @returns AsyncGenerator yielding file/blob pairs as they complete + * + * @example + * ```typescript + * for await (const { file, blob } of manager.downloadFiles(files)) { + * saveAs(blob, file.name); + * } + * ``` + */ + async *downloadFiles(files: FileInfo[]): AsyncGenerator<{ file: FileInfo; blob: Blob; transferId: number }> { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const { transferId, completion } = this.downloadFile(file, i); + const blob = await completion; + yield { file, blob, transferId }; + } + } + + /** + * Download multiple files concurrently with configurable parallelism. + * + * This method initiates multiple file downloads in parallel, improving + * performance for multi-file transfers. Each file uses an independent + * clipboard lock and stream ID. + * + * @param files - Array of FileInfo from 'files-available' event + * @param options - Download options + * @param options.maxConcurrent - Maximum concurrent downloads (default: 3) + * @returns Promise resolving to map of fileIndex to Blob + * + * @example + * ```typescript + * const blobs = await manager.downloadFilesConcurrent(files, { maxConcurrent: 5 }); + * files.forEach((file, i) => saveAs(blobs.get(i)!, file.name)); + * ``` + */ + async downloadFilesConcurrent( + files: FileInfo[], + options: { maxConcurrent?: number } = {}, + ): Promise> { + const maxConcurrent = options.maxConcurrent ?? 3; + + const results = new Map(); + const errors: Array<{ index: number; error: unknown }> = []; + + // Create download tasks + const downloadTasks = files.map((file, index) => async () => { + try { + const { completion } = this.downloadFile(file, index); + const blob = await completion; + results.set(index, blob); + } catch (error) { + errors.push({ index, error }); + } + }); + + // Execute with concurrency limit. + // Each task promise catches its own errors so that Promise.race/Promise.all + // never reject - errors are collected in the `errors` array above. + const executing: Array> = []; + for (const task of downloadTasks) { + const promise = task().finally(() => { + executing.splice(executing.indexOf(promise), 1); + }); + + executing.push(promise); + + if (executing.length >= maxConcurrent) { + await Promise.race(executing); + } + } + + // Wait for remaining downloads (all rejections already caught) + await Promise.all(executing); + + // Throw if any downloads failed + if (errors.length > 0) { + throw new Error( + `Failed to download ${errors.length} file(s): ` + + errors.map((e) => `file ${e.index}: ${e.error}`).join(', '), + ); + } + + return results; + } + + /** + * Upload files to the remote. + * + * Returns an {@link UploadHandle} with per-file `transferIds` available + * synchronously (for immediate UI association) and a `completion` promise + * that resolves when all files have been uploaded. + * + * @param files - Array of File objects to upload + * @returns Handle with synchronous transferIds and async completion + * + * @example + * ```typescript + * const files = await manager.showFilePicker({ multiple: true }); + * const { transferIds, completion } = manager.uploadFiles(files); + * // transferIds available immediately for UI binding + * await completion; + * ``` + */ + uploadFiles(files: File[] | DroppedFile[]): UploadHandle { + // A new paste supersedes the previous offer (MS-RDPECLIP §3.1.1.1), so tear down any + // existing batch instead of throwing. + if (this.uploadState !== undefined) { + this.supersedeUpload(); + } + + // New upload supersedes any retained files from a previous batch + this.retainedFiles = undefined; + + // Normalize: accept both plain File[] (backward compat) and DroppedFile[] + const dropped: DroppedFile[] = RdpFileTransferProvider.normalizeToDroppedFiles(files); + + // Generate unique transfer IDs for each entry in the batch + const transferIds = new Map(); + for (let i = 0; i < dropped.length; i++) { + transferIds.set(i, this.generateStreamId()); + } + + // Build FileInfo with path and isDirectory so the WASM layer can + // encode FileGroupDescriptorW with correct relative paths and + // FILE_ATTRIBUTE_DIRECTORY attributes. + const fileInfos: FileInfo[] = dropped.map((d) => ({ + name: d.name, + size: d.size, + lastModified: d.lastModified, + path: d.path, + isDirectory: d.isDirectory, + })); + + // Directory entries carry no data - only count actual files toward + // the expected completion count so progress percentages make sense. + const fileCount = dropped.filter((d) => d.isDirectory !== true).length; + + // Extract File handles parallel to dropped[], null for directory entries + const fileHandles: (File | null)[] = dropped.map((d) => d.file); + + // Create completion promise + const completion = new Promise((resolve, reject) => { + // Store upload state with completion tracking + this.uploadState = { + files: fileHandles, + droppedFiles: dropped, + failedFiles: new Set(), + expectedFileCount: fileCount, + completedFiles: new Set(), + bytesServed: new Map(), + activeReaders: new Map(), + readerTimeouts: new Map(), + transferIds, + resolve, + reject, + }; + + // Suppress clipboard monitoring so the 100ms polling loop cannot clobber our + // file FormatList with a stale text/image update during the paste window. It + // stays suppressed -- NOT just for the synchronous wire send, which left a + // race the monitor could win -- until we leave the advertised-but-not-pulled + // window: the remote pulls (first FileContentsRequest), or we give up on it + // (watchdog timeout or a rejected advertise). + this.suppressUploadMonitoring(); + + // Initiate file copy (broadcasts file list to remote) + try { + this.sendInitiateFileCopy(fileInfos); + this.emit('upload-batch-started', transferIds, dropped); + } catch (error) { + // Immediate failure: resume monitoring now and clear state so the + // next upload is not blocked. + this.uploadState = undefined; + this.resumeUploadMonitoring(); + const err: FileTransferError = { + message: 'Failed to initiate file upload', + direction: 'upload', + cause: error, + }; + this.emit('error', err); + reject(new Error(err.message, { cause: error })); + return; + } + + // FormatList is on the wire. Wait for the remote to pull the files; if it + // never does (paste landed in a non-file target, or the advertise was + // clobbered/rejected), the watchdog resumes monitoring and rejects this + // upload so its state cannot wedge later uploads ("Upload already in progress"). + this.armPasteAckWatchdog(); + }); + + return { transferIds, completion }; + } + + /** Whether an upload batch is currently advertised or in flight (a fresh upload, or a re-paste + * rebuilt from retained files). */ + isUploadInProgress(): boolean { + return this.uploadState !== undefined; + } + + /** Suppress clipboard monitoring for an upload's paste window. Idempotent: a supersede keeps + * monitoring suppressed across the old->new upload, so this must not re-fire `onUploadStarted`. */ + private suppressUploadMonitoring(): void { + if (this.uploadMonitoringSuppressed) { + return; + } + // Flip the flag before the callback so it holds even if the callback throws. + this.uploadMonitoringSuppressed = true; + try { + this.onUploadStarted?.(); + } catch (error) { + console.error('Error in onUploadStarted callback:', error); + } + } + + /** Resume clipboard monitoring (idempotent: fires onUploadFinished at most once + * per upload, since the resume point is now deferred past the wire send). */ + private resumeUploadMonitoring(): void { + if (!this.uploadMonitoringSuppressed) { + return; + } + // Flip the flag before the callback so it holds even if the callback throws. + this.uploadMonitoringSuppressed = false; + try { + this.onUploadFinished?.(); + } catch (error) { + console.error('Error in onUploadFinished callback:', error); + } + } + + /** Arm the paste-acknowledgment watchdog for the current upload. */ + private armPasteAckWatchdog(): void { + this.clearPasteAckWatchdog(); + // A new upload is starting: drop any inactivity watchdog left from a prior upload + // so it can't fire mid-way through this one. + this.clearUploadInactivityWatchdog(); + this.pasteAckTimeout = setTimeout(() => { + this.pasteAckTimeout = undefined; + // The remote never requested the files within the lock window. Resume + // monitoring and fail the pending upload so it cannot wedge later ones. + const state = this.uploadState; + if (state !== undefined && state.isRePaste !== true) { + this.failPendingUpload('The remote did not request the files in time, so the paste was not completed'); + } else { + this.resumeUploadMonitoring(); + } + }, RdpFileTransferProvider.PASTE_ACK_TIMEOUT_MS); + } + + /** Disarm the paste-acknowledgment watchdog, if armed. */ + private clearPasteAckWatchdog(): void { + if (this.pasteAckTimeout !== undefined) { + clearTimeout(this.pasteAckTimeout); + this.pasteAckTimeout = undefined; + } + } + + /** + * (Re)arm the upload inactivity watchdog (see {@link UPLOAD_INACTIVITY_TIMEOUT_MS}). + * Called on every FileContentsRequest, so continued pulls keep resetting it and a + * slow-but-progressing transfer is never killed; if pulls stop for the window the + * upload is failed (releasing uploadState). Skipped for re-pastes, matching the + * paste-ack watchdog. + */ + private resetUploadInactivityWatchdog(): void { + this.clearUploadInactivityWatchdog(); + this.uploadInactivityTimeout = setTimeout(() => { + this.uploadInactivityTimeout = undefined; + const state = this.uploadState; + if (state !== undefined && state.isRePaste !== true) { + this.failPendingUpload('The remote stopped requesting the files, so the paste did not complete'); + } + }, RdpFileTransferProvider.UPLOAD_INACTIVITY_TIMEOUT_MS); + } + + /** Disarm the upload inactivity watchdog, if armed. */ + private clearUploadInactivityWatchdog(): void { + if (this.uploadInactivityTimeout !== undefined) { + clearTimeout(this.uploadInactivityTimeout); + this.uploadInactivityTimeout = undefined; + } + } + + /** + * The remote acknowledged the paste by requesting file contents: the clobber + * window is over, so disarm the paste-ack watchdog and resume clipboard monitoring. + * Called on every FileContentsRequest; only the first disarms/resumes. Every request + * also (re)arms the inactivity watchdog so a stall after pulling began is recovered. + */ + private acknowledgePaste(): void { + if (this.pasteAckTimeout !== undefined) { + this.clearPasteAckWatchdog(); + this.resumeUploadMonitoring(); + } + + // A directory-only batch (expectedFileCount === 0) gets a SIZE request per directory but + // never a RANGE -- the only path that marks a file complete -- so finishUploadBatch would + // never run and the inactivity watchdog below would fail an upload that actually succeeded. + // The remote acknowledging the paste is the only completion signal a data-less batch can + // get, so finish it now. (Mirrors the 0-byte file SIZE-path completion; here there are no + // counted files at all.) Re-pastes are left alone, like the watchdog itself. + const state = this.uploadState; + if (state !== undefined && state.isRePaste !== true && state.expectedFileCount === 0) { + this.finishUploadBatch(state); + return; + } + + this.resetUploadInactivityWatchdog(); + } + + /** + * Abort any in-flight chunk reads for a batch and clear their timeouts. Without this, a read + * still running when the batch is torn down keeps its FileReader and a 60s reader-timeout + * alive. Shared by every upload teardown path (fail, supersede, dispose). + */ + private abortInFlightReads(state: UploadState): void { + for (const timeout of state.readerTimeouts.values()) { + clearTimeout(timeout); + } + state.readerTimeouts.clear(); + for (const reader of state.activeReaders.values()) { + reader.abort(); + } + state.activeReaders.clear(); + } + + /** + * Fail the in-flight upload (reject its completion, emit an upload error, clear + * uploadState) and resume monitoring. Used when the advertise is rejected or the + * paste is never pulled, so `uploadState` is released instead of lingering and + * throwing "Upload already in progress" on every later upload. + */ + private failPendingUpload(message: string): void { + this.clearPasteAckWatchdog(); + this.clearUploadInactivityWatchdog(); + this.resumeUploadMonitoring(); + const state = this.uploadState; + if (state === undefined) { + return; + } + this.abortInFlightReads(state); + + const err: FileTransferError = { message, direction: 'upload' }; + this.emit('error', err); + const { reject } = state; + this.uploadState = undefined; + reject(new Error(message)); + } + + /** + * Tear down the current upload because a new paste is replacing it. Unlike + * {@link failPendingUpload}, this emits no error and leaves monitoring suppressed (a new upload + * is starting); it aborts in-flight reads, clears the state, and *resolves* the old completion + * (a replacement, not a failure). + */ + private supersedeUpload(): void { + this.clearPasteAckWatchdog(); + this.clearUploadInactivityWatchdog(); + const state = this.uploadState; + if (state === undefined) { + return; + } + this.abortInFlightReads(state); + const { resolve } = state; + this.uploadState = undefined; + resolve(); + } + + /** + * Finalize a fully-accounted upload batch (every counted file either served in full + * or permanently failed). Stops the inactivity watchdog so it cannot linger past + * completion, retains the DroppedFile metadata so a re-paste from the remote can serve + * the data again, clears `uploadState`, and resolves the completion promise. + */ + private finishUploadBatch(state: UploadState): void { + this.clearUploadInactivityWatchdog(); + this.retainedFiles = state.droppedFiles; + this.uploadState = undefined; + state.resolve(); + } + + /** + * Show a file picker dialog and return selected files. + * + * Note: This must be called in response to a user gesture (e.g., button click) + * due to browser security restrictions. + * + * @param options - File picker options + * @returns Promise resolving to selected File objects + * + * @example + * ```typescript + * button.onclick = async () => { + * const files = await manager.showFilePicker({ multiple: true, accept: 'image/*' }); + * await manager.uploadFiles(files); + * }; + * ``` + */ + showFilePicker(options?: { multiple?: boolean; accept?: string }): Promise { + return new Promise((resolve) => { + // Create hidden file input + const input = document.createElement('input'); + input.type = 'file'; + input.style.display = 'none'; + + if (options?.multiple === true) { + input.multiple = true; + } + if (options?.accept !== undefined && options.accept.length > 0) { + input.accept = options.accept; + } + + // Handle file selection + input.addEventListener('change', () => { + const files = Array.from(input.files || []); + cleanup(); + window.removeEventListener('focus', onFocus); + resolve(files); + }); + + // Handle cancellation - the 'cancel' event is supported in modern + // browsers (Chrome 113+). For older browsers, fall back to detecting + // window focus after the picker closes without a selection. + let settled = false; + const cleanup = () => { + if (settled) return; + settled = true; + if (input.parentNode) { + document.body.removeChild(input); + } + }; + + input.addEventListener('cancel', () => { + cleanup(); + window.removeEventListener('focus', onFocus); + resolve([]); + }); + + // Fallback: if the browser doesn't fire 'cancel', resolve empty + // when the window regains focus after the picker is dismissed. + const onFocus = () => { + // 300 ms gives the browser enough time to schedule the + // 'change' event (macrotask) before we treat focus as a + // cancellation signal. This path only runs when the picker + // is dismissed without selecting files, so the latency is + // invisible to the user. + setTimeout(() => { + if (!settled) { + cleanup(); + resolve([]); + } + window.removeEventListener('focus', onFocus); + }, 300); + }; + window.addEventListener('focus', onFocus); + + // Add to DOM and trigger click + document.body.appendChild(input); + input.click(); + }); + } + + /** + * Extract files (and recursively traverse directories) from a drag-and-drop event. + * + * Uses the File and Directory Entries API (`webkitGetAsEntry`) which is + * supported across all major browsers (Chrome, Firefox, Safari, Edge). + * Falls back to `getAsFile()` when the Entries API is unavailable. + * + * Directory entries are included in the result with `isDirectory: true` + * and a `null` file handle. Files inside directories have their + * {@link DroppedFile.path} set to the relative backslash-separated path. + * + * @param event - DragEvent from drop handler + * @returns Promise resolving to an array of DroppedFile descriptors + * + * @example + * ```typescript + * dropZone.addEventListener('drop', async (e) => { + * const files = await manager.handleDrop(e); + * manager.uploadFiles(files); + * }); + * ``` + */ + async handleDrop(event: DragEvent): Promise { + event.preventDefault(); + + const results: DroppedFile[] = []; + + if (event.dataTransfer?.items) { + // Collect FileSystemEntry references synchronously - DataTransferItem + // references become invalid once the event handler returns. + const entries: FileSystemEntry[] = []; + const fallbackFiles: File[] = []; + + for (const item of event.dataTransfer.items) { + if (item.kind !== 'file') continue; + + const entry = item.webkitGetAsEntry?.(); + if (entry) { + entries.push(entry); + } else { + // Browser does not support webkitGetAsEntry - fall back + const file = item.getAsFile(); + if (file) { + fallbackFiles.push(file); + } + } + } + + // Async traversal is safe now that we hold entry references + for (const entry of entries) { + await this.traverseEntry(entry, undefined, results, 0); + } + + // Append any fallback files (flat, no path metadata) + for (const file of fallbackFiles) { + results.push({ + file, + name: file.name, + size: file.size, + lastModified: file.lastModified, + }); + } + } else if (event.dataTransfer?.files) { + // Fallback: no items API, use files list (no directory support) + for (const file of Array.from(event.dataTransfer.files)) { + results.push({ + file, + name: file.name, + size: file.size, + lastModified: file.lastModified, + }); + } + } + + return results; + } + + /** + * Normalize a plain `File[]` or `DroppedFile[]` into `DroppedFile[]`. + * Allows `uploadFiles` to accept either type for backward compatibility. + */ + private static normalizeToDroppedFiles(files: File[] | DroppedFile[]): DroppedFile[] { + if (files.length === 0) return []; + + // Duck-type check: DroppedFile always has a `file` property (File | null) + // while a plain File does not. `isDirectory` is optional so we only + // check for `file` to distinguish the two shapes. + const first = files[0]; + if ('file' in first) { + return files as DroppedFile[]; + } + + // Plain File[] - wrap each one + return (files as File[]).map((f) => ({ + file: f, + name: f.name, + size: f.size, + lastModified: f.lastModified, + })); + } + + /** + * Recursively traverse a FileSystemEntry, collecting files and directory + * entries into `results`. + */ + private async traverseEntry( + entry: FileSystemEntry, + parentPath: string | undefined, + results: DroppedFile[], + depth: number, + ): Promise { + if (results.length >= RdpFileTransferProvider.MAX_DIRECTORY_ENTRIES) return; + + if (depth > RdpFileTransferProvider.MAX_DIRECTORY_DEPTH) { + console.warn( + `Skipping "${entry.name}": directory depth exceeds ${RdpFileTransferProvider.MAX_DIRECTORY_DEPTH}`, + ); + return; + } + + if (entry.isFile) { + const file = await new Promise((resolve, reject) => { + (entry as FileSystemFileEntry).file(resolve, reject); + }); + results.push({ + file, + name: file.name, + size: file.size, + lastModified: file.lastModified, + path: parentPath, + }); + } else if (entry.isDirectory) { + const dirPath = parentPath !== undefined ? `${parentPath}\\${entry.name}` : entry.name; + + // Include the directory entry itself so the remote sees the folder structure + results.push({ + file: null, + name: entry.name, + size: 0, + lastModified: 0, + path: parentPath, + isDirectory: true, + }); + + const reader = (entry as FileSystemDirectoryEntry).createReader(); + const children = await RdpFileTransferProvider.readAllDirectoryEntries(reader); + for (const child of children) { + await this.traverseEntry(child, dirPath, results, depth + 1); + } + } + } + + /** + * Read all entries from a FileSystemDirectoryReader. Chromium-based + * browsers return at most 100 entries per `readEntries()` call, so we + * must loop until an empty batch is returned. + */ + private static readAllDirectoryEntries(reader: FileSystemDirectoryReader): Promise { + return new Promise((resolve, reject) => { + const all: FileSystemEntry[] = []; + const readBatch = (): void => { + reader.readEntries((entries) => { + if (entries.length === 0) { + resolve(all); + } else { + all.push(...entries); + readBatch(); + } + }, reject); + }; + readBatch(); + }); + } + + /** + * Prevent default drag-over behavior to enable drop target. + * + * This must be called in the dragover event handler for drag-and-drop to work. + * + * @param event - DragEvent from dragover handler + * + * @example + * ```typescript + * dropZone.addEventListener('dragover', (e) => manager.handleDragOver(e)); + * ``` + */ + handleDragOver(event: DragEvent): void { + event.preventDefault(); + } + + /** + * Cleanup resources and unregister callbacks. + * + * Call this when the session is terminating or RdpFileTransferProvider is no longer needed. + */ + dispose(): void { + this.disposed = true; + + // Stop the paste-ack watchdog and resume monitoring if an upload was still + // mid-paste-window (we defer the resume, so it may not have fired yet). + this.clearPasteAckWatchdog(); + this.clearUploadInactivityWatchdog(); + this.resumeUploadMonitoring(); + + // Cancel active downloads (lock cleanup is handled by the Rust layer) + for (const state of this.activeDownloads.values()) { + void this.abortWriteHandle(state); + state.reject(new Error('RdpFileTransferProvider disposed')); + } + this.activeDownloads.clear(); + + // Clean up active FileReaders, clear timeouts, and reject upload promise + if (this.uploadState !== undefined) { + this.abortInFlightReads(this.uploadState); + this.uploadState.reject(new Error('RdpFileTransferProvider disposed')); + } + this.uploadState = undefined; + this.retainedFiles = undefined; + + // Clear available files and lock reference + this.availableFiles = []; + this.clipDataId = undefined; + + // Dispose the storage backend (deletes OPFS session directory, etc.). + // Fire-and-forget -- dispose() is synchronous per the FileTransferProvider + // interface, but backend cleanup is async. This is acceptable because + // the session is terminating and the OPFS data is expendable. + if (this.storageBackend) { + void this.storageBackend.dispose(); + this.storageBackend = undefined; + this.storageBackendReady = undefined; + } + + // Clear event handlers + this.eventHandlers.clear(); + } + + // ==================== Callback Handlers ==================== + + private handleFilesAvailable(files: FileInfo[], clipDataId?: number): void { + // A remote FormatList means the remote took ownership of the clipboard, which + // supersedes any in-flight upload advertise of ours: the remote will not pull our + // files, and once the paste has been acknowledged the paste-ack watchdog is + // already disarmed, so nothing else would ever release `uploadState`. Left + // lingering it wedges every later upload with "Upload already in progress". Release + // it here. This is the symmetric counterpart of handleFormatListResponse(false): + // that recovers when the remote *rejects* our advertise; this recovers when the + // remote *replaces* it. No-op when no upload is in flight. + this.failPendingUpload('Upload interrupted: the remote clipboard changed'); + + // Do NOT cancel active downloads here. + // + // Per MS-RDPECLIP 2.2.4.1 and 3.1.5.3.2, clipboard locks ensure that + // the server retains file stream data even after the clipboard changes. + // Each download holds its own lock (via clipDataId), so the server will + // continue to service FileContentsRequest PDUs for in-flight transfers + // regardless of new FormatList arrivals. Downloads complete or fail on + // their own based on their transfer state and protocol completion. + + // Defense-in-depth: sanitize file info from remote to prevent path traversal. + // The Rust layer already sanitizes, but we guard again at the JS boundary. + const sanitized = files.map((f) => ({ + ...f, + name: RdpFileTransferProvider.sanitizeFileName(f.name), + path: f.path !== undefined ? RdpFileTransferProvider.sanitizePath(f.path) : undefined, + })); + this.availableFiles = sanitized; + this.clipDataId = clipDataId; + this.emit('files-available', sanitized); + } + + /** + * Extract the basename from a file name, stripping any path traversal or + * directory components. Returns "unnamed_file" if the name is empty or + * consists entirely of path separators / traversal sequences. + */ + /** @internal Visible for testing. */ + static sanitizeFileName(name: string): string { + // Split on both Windows and Unix separators, find last non-traversal component + const components = name.split(/[/\\]/); + for (let i = components.length - 1; i >= 0; i--) { + const c = components[i]; + if (c.length > 0 && c !== '.' && c !== '..') { + return c; + } + } + return 'unnamed_file'; + } + + /** + * Sanitize a relative directory path by stripping traversal components + * (`.` and `..`) and absolute path prefixes. Returns undefined if the + * path is empty after sanitization. + */ + /** @internal Visible for testing. */ + static sanitizePath(path: string): string | undefined { + const components = path.split(/[/\\]/); + const safe = components.filter((c) => c.length > 0 && c !== '.' && c !== '..'); + + // Strip absolute path prefixes to match the Rust sanitizer's coverage. + // UNC-like prefix: \\?\ or \\.\ splits into "?" or "." as first component + if (safe.length > 0 && (safe[0] === '?' || safe[0] === '.')) { + safe.shift(); + // May be followed by a drive letter (e.g. \\?\C:\path) + if (safe.length > 0 && /^[A-Za-z]:$/.test(safe[0])) { + safe.shift(); + } + } + // Drive letter prefix: "C:" + if (safe.length > 0 && /^[A-Za-z]:$/.test(safe[0])) { + safe.shift(); + } + + if (safe.length === 0) { + return undefined; + } + + // Normalize to backslash separator (Windows wire convention) + return safe.join('\\'); + } + + private handleFileContentsRequest(request: FileContentsRequest): void { + // The remote is pulling the files, so the paste was accepted: end the + // clobber-protection window (disarm the watchdog, resume monitoring). + this.acknowledgePaste(); + + if (!this.uploadState) { + if (!this.retainedFiles) { + console.warn('Received file contents request but no upload in progress'); + this.sendSubmitFileContents(request.streamId, true, new Uint8Array()); + return; + } + // Re-paste: rebuild uploadState from retained files so the main + // code path handles progress/completion tracking identically. + this.rebuildUploadStateFromRetained(); + } + + // Non-null: either already existed or just rebuilt from retainedFiles + const state = this.uploadState!; + const { files, droppedFiles } = state; + + // If this file previously failed (e.g. read timeout), send an error + // response for any subsequent requests without aborting the batch. + if (state.failedFiles.has(request.index)) { + this.sendSubmitFileContents(request.streamId, true, new Uint8Array()); + return; + } + + const fileHandle = files[request.index]; + const dropped = droppedFiles[request.index]; + if (dropped === undefined) { + console.error( + `File index ${request.index} out of range (stream ${request.streamId}, valid: 0..${droppedFiles.length - 1})`, + ); + this.sendSubmitFileContents(request.streamId, true, new Uint8Array()); + return; + } + + // Directory entries have no data. Respond to SIZE with 0 and error + // for RANGE (the remote should not request ranges for directories). + if (fileHandle === null || fileHandle === undefined) { + if ((request.flags & FileContentsFlags.SIZE) !== 0) { + const sizeBytes = new Uint8Array(8); + // Size is already 0 in the zeroed buffer + this.sendSubmitFileContents(request.streamId, false, sizeBytes); + } else { + this.sendSubmitFileContents(request.streamId, true, new Uint8Array()); + } + // Directory entries are not counted toward expectedFileCount, + // so we do not mark them as completed here. + return; + } + + // From here on, fileHandle is a non-null File + const file: File = fileHandle; + + if ((request.flags & FileContentsFlags.SIZE) !== 0) { + // SIZE request: return 8-byte LE u64 + const sizeBytes = new Uint8Array(8); + const view = new DataView(sizeBytes.buffer); + view.setBigUint64(0, BigInt(file.size), true); + this.sendSubmitFileContents(request.streamId, false, sizeBytes); + + // A 0-byte file gets no RANGE request (no bytes to read), so complete it here -- + // otherwise it never reaches completedFiles and the batch never finishes. Mirrors + // the download path's size===0 handling. + if (file.size === 0) { + this.markUploadFileComplete(request.index, file, state.transferIds.get(request.index) ?? -1); + } + } else if ((request.flags & FileContentsFlags.RANGE) !== 0) { + // RANGE request: read file chunk + const chunk = file.slice(request.position, request.position + request.size); + const reader = new FileReader(); + + // Track active reader by streamId for cleanup on abort/dispose + // Using streamId (unique per request) instead of file index avoids + // collisions if the remote sends concurrent requests for the same file. + state.activeReaders.set(request.streamId, reader); + + // Add timeout to prevent indefinite hangs. + // On timeout, mark this file as failed and let remaining files continue. + const timeoutId = setTimeout(() => { + reader.abort(); + if (this.uploadState !== undefined) { + this.uploadState.activeReaders.delete(request.streamId); + this.uploadState.readerTimeouts.delete(request.streamId); + } + this.sendSubmitFileContents(request.streamId, true, new Uint8Array()); + const err: FileTransferError = { + message: `File read timeout after ${RdpFileTransferProvider.FILE_READER_TIMEOUT_MS / 1000}s`, + transferId: this.uploadState?.transferIds.get(request.index), + fileIndex: request.index, + fileName: dropped.name, + direction: 'upload', + }; + this.emit('error', err); + + // Mark this file as failed and check if the batch is done + if (this.uploadState !== undefined) { + this.uploadState.failedFiles.add(request.index); + this.uploadState.completedFiles.add(request.index); + if (this.uploadState.completedFiles.size >= this.uploadState.expectedFileCount) { + this.finishUploadBatch(this.uploadState); + } + } + }, RdpFileTransferProvider.FILE_READER_TIMEOUT_MS); + state.readerTimeouts.set(request.streamId, timeoutId); + + reader.onload = () => { + // Clean up reader and timeout after successful read + if (this.uploadState !== undefined) { + this.uploadState.activeReaders.delete(request.streamId); + const timeout = this.uploadState.readerTimeouts.get(request.streamId); + if (timeout !== undefined) { + clearTimeout(timeout); + this.uploadState.readerTimeouts.delete(request.streamId); + } + } + + const data = new Uint8Array(reader.result as ArrayBuffer); + this.sendSubmitFileContents(request.streamId, false, data); + + // Track cumulative bytes served per file for robust completion + // detection regardless of chunk request order. + if (this.uploadState !== undefined) { + const served = (this.uploadState.bytesServed.get(request.index) ?? 0) + data.length; + this.uploadState.bytesServed.set(request.index, served); + + // Emit progress (clamp percentage to 100% in case of overlapping ranges) + const uploadTransferId = this.uploadState.transferIds.get(request.index) ?? -1; + const progress: TransferProgress = { + transferId: uploadTransferId, + fileIndex: request.index, + fileName: dropped.name, + bytesTransferred: served, + totalBytes: file.size, + percentage: file.size === 0 ? 100 : Math.min((served / file.size) * 100, 100), + }; + this.emit('upload-progress', progress); + + // Check if all bytes for this file have been served + if (served >= file.size) { + this.markUploadFileComplete(request.index, file, uploadTransferId); + } + } + }; + + reader.onerror = () => { + // Clean up reader and timeout after error + if (this.uploadState !== undefined) { + this.uploadState.activeReaders.delete(request.streamId); + const timeout = this.uploadState.readerTimeouts.get(request.streamId); + if (timeout !== undefined) { + clearTimeout(timeout); + this.uploadState.readerTimeouts.delete(request.streamId); + } + } + + // If this file already failed (e.g. timeout), the timeout + // handler already tracked completion. + if (this.uploadState?.failedFiles.has(request.index) === true) { + return; + } + + this.sendSubmitFileContents(request.streamId, true, new Uint8Array()); + const err: FileTransferError = { + message: 'Failed to read file chunk', + transferId: this.uploadState?.transferIds.get(request.index), + fileIndex: request.index, + fileName: dropped.name, + direction: 'upload', + cause: reader.error, + }; + this.emit('error', err); + + // Mark this file as failed and let remaining files continue. + if (this.uploadState !== undefined) { + this.uploadState.failedFiles.add(request.index); + this.uploadState.completedFiles.add(request.index); + if (this.uploadState.completedFiles.size >= this.uploadState.expectedFileCount) { + this.finishUploadBatch(this.uploadState); + } + } + }; + + reader.readAsArrayBuffer(chunk); + } + } + + /** + * Mark a single upload file as fully served: record it, emit upload-complete once, and + * finalize the batch once every counted file is accounted for. Shared by the RANGE onload + * path (final chunk served) and the SIZE path for 0-byte files (which get no RANGE request). + */ + private markUploadFileComplete(index: number, file: File, transferId: number): void { + const state = this.uploadState; + if (state === undefined || state.completedFiles.has(index)) { + return; + } + state.completedFiles.add(index); + this.emit('upload-complete', file, index, transferId); + if (state.completedFiles.size === state.expectedFileCount) { + this.finishUploadBatch(state); + } + } + + /** + * Lazily rebuild uploadState from retainedFiles when the remote re-pastes + * after the original upload completed. This lets the main code path in + * handleFileContentsRequest handle progress and completion identically. + * No external promise - resolve/reject are no-ops. + */ + private rebuildUploadStateFromRetained(): void { + const dropped = this.retainedFiles!; + const transferIds = new Map(); + for (let i = 0; i < dropped.length; i++) { + transferIds.set(i, this.generateStreamId()); + } + + const fileCount = dropped.filter((d) => d.isDirectory !== true).length; + + this.uploadState = { + files: dropped.map((d) => d.file), + droppedFiles: dropped, + failedFiles: new Set(), + expectedFileCount: fileCount, + completedFiles: new Set(), + bytesServed: new Map(), + activeReaders: new Map(), + readerTimeouts: new Map(), + transferIds, + resolve: () => {}, + reject: () => {}, + isRePaste: true, + }; + + this.emit('upload-batch-started', transferIds, dropped); + } + + private handleFileContentsResponse(response: FileContentsResponse): void { + const state = this.activeDownloads.get(response.streamId); + if (!state) { + console.warn(`Received response for unknown stream ${response.streamId}`); + return; + } + + if (response.isError) { + this.activeDownloads.delete(response.streamId); + void this.abortWriteHandle(state); + const err: FileTransferError = { + message: 'Remote failed to provide file contents', + transferId: state.streamId, + fileIndex: state.fileIndex, + fileName: state.fileInfo.name, + direction: 'download', + }; + this.emit('error', err); + state.reject(new Error(err.message)); + return; + } + + if (state.expectedSize === undefined) { + // This is the SIZE response + // Validate response data is valid before creating DataView + if (response.data.length < 8) { + this.activeDownloads.delete(response.streamId); + void this.abortWriteHandle(state); + const err: FileTransferError = { + message: 'Invalid SIZE response: expected 8 bytes for file size', + transferId: state.streamId, + fileIndex: state.fileIndex, + fileName: state.fileInfo.name, + direction: 'download', + }; + this.emit('error', err); + state.reject(new Error(err.message)); + return; + } + + const view = new DataView(response.data.buffer, response.data.byteOffset, response.data.byteLength); + const size = Number(view.getBigUint64(0, true)); + + // Validate file size doesn't exceed browser memory limits + if (size > RdpFileTransferProvider.MAX_FILE_SIZE) { + this.activeDownloads.delete(response.streamId); + void this.abortWriteHandle(state); + const err: FileTransferError = { + message: `File size ${(size / (1024 * 1024 * 1024)).toFixed(2)}GB exceeds maximum download limit of 2GB`, + transferId: state.streamId, + fileIndex: state.fileIndex, + fileName: state.fileInfo.name, + direction: 'download', + }; + this.emit('error', err); + state.reject(new Error(err.message)); + return; + } + + state.expectedSize = size; + + // Handle empty files + if (size === 0) { + this.activeDownloads.delete(response.streamId); + void this.abortWriteHandle(state); + const blob = new Blob([]); + this.emit('download-complete', state.fileInfo, blob, state.fileIndex, state.streamId); + state.resolve(blob); + return; + } + + // Initialize the storage write handle now that we know the file + // size, then request the first data chunk. + this.initWriteHandleAndRequestFirstChunk(state); + } else { + // This is a DATA response -- write the chunk to the storage backend. + void this.handleDataChunk(state, response.data); + } + } + + /** + * Create a write handle for the given transfer and request the first + * data chunk. Runs asynchronously because backend initialization + * (especially OPFS) is async. + * + * The init promise is stored on `state.writeHandleReady` so that + * DATA responses arriving before the handle is ready can await it. + */ + private initWriteHandleAndRequestFirstChunk(state: TransferState): void { + // The init promise always resolves (never rejects) so that awaiting + // callers in handleDataChunk do not need individual error handling. + // Failures are signaled by leaving writeHandle undefined; the + // !state.writeHandle guard after the await detects this. + state.writeHandleReady = (async () => { + try { + const initPromise = (async () => { + const backend = await this.ensureStorageBackend(); + return backend.createWriteHandle(state.fileInfo.name, state.expectedSize ?? 0); + })(); + + const timeout = RdpFileTransferProvider.WRITE_HANDLE_INIT_TIMEOUT_MS; + const handle = await Promise.race([ + initPromise, + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error(`Storage init timed out after ${timeout / 1000}s`)), timeout), + ), + ]); + + // Provider may have been disposed while we were awaiting. + // Abort the newly created handle to avoid orphaned OPFS files. + if (this.disposed || !this.activeDownloads.has(state.streamId)) { + try { + await handle.abort(); + } catch { + // Best-effort cleanup. + } + return; + } + + state.writeHandle = handle; + } catch (error) { + this.activeDownloads.delete(state.streamId); + const err: FileTransferError = { + message: 'Failed to initialize storage for download', + transferId: state.streamId, + fileIndex: state.fileIndex, + fileName: state.fileInfo.name, + direction: 'download', + cause: error, + }; + this.emit('error', err); + state.reject(new Error(err.message, { cause: error })); + // Do not rethrow: the promise must resolve (not reject) so + // that awaiting callers in handleDataChunk can detect the + // failure via the !state.writeHandle guard without needing + // per-caller error handling. + return; + } + + this.requestNextChunk(state); + })(); + } + + /** + * Write a data chunk to the storage backend and advance the download. + * + * If the write handle is not yet ready (DATA response arrived before + * backend init completed), this method awaits `state.writeHandleReady` + * to preserve chunk ordering -- each concurrent caller awaits the same + * promise in sequence. + */ + private async handleDataChunk(state: TransferState, data: Uint8Array): Promise { + if (!state.writeHandle) { + if (!state.writeHandleReady || this.disposed || !this.activeDownloads.has(state.streamId)) { + // No init in progress or download already cancelled. + return; + } + // writeHandleReady always resolves (never rejects); init + // failures leave writeHandle undefined, caught by the + // guard below. + await state.writeHandleReady; + + // Re-check: the download may have been cancelled or disposed + // while we were waiting for the write handle. + if (this.disposed || !this.activeDownloads.has(state.streamId)) { + return; + } + } + + // Guard defensively: writeHandle may still be undefined if init + // failed or dispose() cleared it during the await above. + const writeHandle = state.writeHandle; + if (!writeHandle) { + return; + } + + try { + await writeHandle.write(data); + } catch (error) { + this.activeDownloads.delete(state.streamId); + void this.abortWriteHandle(state); + const isQuota = error instanceof DOMException && error.name === 'QuotaExceededError'; + const message = isQuota + ? `Storage quota exceeded while downloading "${state.fileInfo.name}"` + : 'Failed to write download chunk to storage'; + const err: FileTransferError = { + message, + transferId: state.streamId, + fileIndex: state.fileIndex, + fileName: state.fileInfo.name, + direction: 'download', + cause: error, + }; + this.emit('error', err); + state.reject(new Error(err.message, { cause: error })); + return; + } + + state.bytesReceived += data.length; + + // Validate that received data doesn't grossly exceed expected size + if (state.expectedSize !== undefined && state.bytesReceived > state.expectedSize * 2) { + this.activeDownloads.delete(state.streamId); + void this.abortWriteHandle(state); + const err: FileTransferError = { + message: `Received ${state.bytesReceived} bytes but expected ${state.expectedSize} - aborting`, + transferId: state.streamId, + fileIndex: state.fileIndex, + fileName: state.fileInfo.name, + direction: 'download', + }; + this.emit('error', err); + state.reject(new Error(err.message)); + return; + } + + // Emit progress (clamp percentage to 100% in case server sends slightly more than expected) + if (state.expectedSize !== undefined) { + const progress: TransferProgress = { + transferId: state.streamId, + fileIndex: state.fileIndex, + fileName: state.fileInfo.name, + bytesTransferred: state.bytesReceived, + totalBytes: state.expectedSize, + percentage: Math.min((state.bytesReceived / state.expectedSize) * 100, 100), + }; + this.emit('download-progress', progress); + } + + // Check if download complete + if (state.expectedSize !== undefined && state.bytesReceived >= state.expectedSize) { + this.activeDownloads.delete(state.streamId); + try { + const blob = await writeHandle.finalize(); + this.emit('download-complete', state.fileInfo, blob, state.fileIndex, state.streamId); + state.resolve(blob); + } catch (error) { + void this.abortWriteHandle(state); + const err: FileTransferError = { + message: 'Failed to finalize downloaded file', + transferId: state.streamId, + fileIndex: state.fileIndex, + fileName: state.fileInfo.name, + direction: 'download', + cause: error, + }; + this.emit('error', err); + state.reject(new Error(err.message, { cause: error })); + } + } else { + // Request next chunk + this.requestNextChunk(state); + } + } + + /** Abort and clean up a write handle, ignoring errors. */ + private async abortWriteHandle(state: TransferState): Promise { + if (state.writeHandle) { + try { + await state.writeHandle.abort(); + } catch { + // Best-effort cleanup. + } + state.writeHandle = undefined; + } + } + + /** + * Handle the remote's response to one of our outbound Format Lists, surfaced via + * `on_format_list_response`. Always re-emitted as a `format-list-response` event so + * a frontend can drive paste from it (inject on accept, retry on reject). + * + * On reject (`ok === false`) the remote silently discards the advertised clipboard + * (MS-RDPECLIP), so an upload still waiting to be pulled can never complete -- + * previously this left `uploadState` set and every later upload threw "Upload + * already in progress". The watchdog-armed check scopes the release to an upload + * that was advertised but not yet pulled, so re-paste and an already-progressing + * transfer are left alone. + */ + private handleFormatListResponse(ok: boolean): void { + this.emit('format-list-response', ok); + if (!ok && this.pasteAckTimeout !== undefined) { + this.failPendingUpload('The remote rejected the file list, so the paste was not accepted'); + } + } + + private handleLock(_dataId: number): void { + // Remote locked their clipboard (informational only for uploads). + } + + private handleUnlock(_dataId: number): void { + // Remote unlocked their clipboard (informational only for uploads). + } + + private handleLocksExpired(clipDataIds: Uint32Array): void { + // Client-side locks expired due to inactivity timeout + // Check which active downloads are affected and abort them + const expiredLockSet = new Set(); + for (let i = 0; i < clipDataIds.length; i++) { + expiredLockSet.add(clipDataIds[i]); + } + + // Abort downloads that are using expired locks + for (const [streamId, state] of this.activeDownloads) { + if (state.clipDataId !== undefined && expiredLockSet.has(state.clipDataId)) { + this.activeDownloads.delete(streamId); + void this.abortWriteHandle(state); + + // Build user-friendly error message with timeout info and remediation + const errorMessage = + `File download timed out for "${state.fileInfo.name}". ` + + `Clipboard lock expired due to inactivity. ` + + `This can happen with slow network connections or large files. ` + + `Try downloading smaller files, increasing chunk size, or checking your network connection.`; + + state.reject(new Error(errorMessage)); + + this.emit('error', { + message: errorMessage, + transferId: state.streamId, + fileIndex: state.fileIndex, + fileName: state.fileInfo.name, + direction: 'download', + }); + } + } + } + + // ==================== Helper Methods ==================== + + private requestNextChunk(state: TransferState): void { + if (state.expectedSize === undefined || state.expectedSize === 0) { + return; + } + + const position = state.bytesReceived; + const remaining = state.expectedSize - position; + const size = Math.min(this.chunkSize, remaining); + + try { + this.sendRequestFileContents( + state.streamId, + state.fileIndex, + FileContentsFlags.RANGE, + position, + size, + state.clipDataId, + ); + } catch (error) { + this.activeDownloads.delete(state.streamId); + void this.abortWriteHandle(state); + const err: FileTransferError = { + message: 'Failed to request file chunk', + transferId: state.streamId, + fileIndex: state.fileIndex, + fileName: state.fileInfo.name, + direction: 'download', + cause: error, + }; + this.emit('error', err); + state.reject(new Error(err.message, { cause: error })); + } + } + + private generateStreamId(): number { + // Monotonic counter wrapping at 32-bit boundary. + // After wraparound (~4 billion IDs), skip any IDs still in activeDownloads + // to avoid silently overwriting an in-flight download's state. + const maxAttempts = this.activeDownloads.size + 2; + for (let i = 0; i < maxAttempts; i++) { + const streamId = this.nextStreamId; + this.nextStreamId = (this.nextStreamId + 1) % 0x1_0000_0000; // Wrap at 2^32 + if (this.nextStreamId === 0) { + this.nextStreamId = 1; // Skip 0 + } + if (!this.activeDownloads.has(streamId) && streamId !== 0) { + return streamId; + } + } + // Should never happen: more active downloads than the counter can skip + throw new Error('Unable to generate unique stream ID'); + } +} diff --git a/web-client/iron-remote-desktop-rdp/src/extensions.ts b/web-client/iron-remote-desktop-rdp/src/extensions.ts new file mode 100644 index 0000000000..8881a7646f --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/extensions.ts @@ -0,0 +1,112 @@ +/** + * RDP-specific extension factories for file transfer. + * + * These create Extension objects that are dispatched through the WASM + * invoke_extension() / extension() mechanism in ironrdp-web. + */ + +import { Extension } from '../../../crates/ironrdp-web/pkg/ironrdp_web'; +import type { FileInfo } from './FileTransfer'; + +// Builder-time callback extensions (registered via SessionBuilder.extension()) + +export function filesAvailableCallback(cb: (files: FileInfo[], clipDataId?: number) => void): Extension { + return new Extension('files_available_callback', cb as unknown); +} + +export function fileContentsRequestCallback( + cb: (request: { + streamId: number; + index: number; + flags: number; + position: number; + size: number; + dataId?: number; + }) => void, +): Extension { + return new Extension('file_contents_request_callback', cb as unknown); +} + +export function fileContentsResponseCallback( + cb: (response: { streamId: number; isError: boolean; data: Uint8Array }) => void, +): Extension { + return new Extension('file_contents_response_callback', cb as unknown); +} + +export function lockCallback(cb: (dataId: number) => void): Extension { + return new Extension('lock_callback', cb as unknown); +} + +export function unlockCallback(cb: (dataId: number) => void): Extension { + return new Extension('unlock_callback', cb as unknown); +} + +export function locksExpiredCallback(cb: (clipDataIds: Uint32Array) => void): Extension { + return new Extension('locks_expired_callback', cb as unknown); +} + +export function formatListResponseCallback(cb: (ok: boolean) => void): Extension { + return new Extension('format_list_response_callback', cb as unknown); +} + +// Virtual printer (RDPDR) extensions +// +// Registering `printJobStreamCallbacks` activates the browser-side virtual +// printer device. The printer backend streams write chunks as they arrive +// instead of buffering a completed job in WASM memory. By default, the web +// connector follows FreeRDP's macOS heuristic where possible: +// browser-reported macOS 14+ uses Microsoft Print to PDF, and other clients use +// MS Publisher Imagesetter for PostScript bytes. Use `printerDriverName` when +// the target host needs a different installed printer driver. Jobs larger than +// 128 MiB are rejected, and queued write chunks are bounded to protect browser +// memory. `printerName`, `printerDeviceId`, and `printerDriverName` are +// optional; sensible defaults are applied when omitted. + +export const PrinterDriverName = { + PostScript: 'MS Publisher Imagesetter', + MicrosoftPrintToPdf: 'Microsoft Print to PDF', +} as const; + +export interface PrintJobStreamCallbacks { + onJobStart?: (fileId: number) => void; + onJobData: (fileId: number, chunk: Uint8Array) => void; + onJobComplete: (fileId: number) => void; + onJobError?: (fileId: number) => void; +} + +export function printJobStreamCallbacks(callbacks: PrintJobStreamCallbacks): Extension { + return new Extension('print_job_stream_callbacks', callbacks as unknown); +} + +export function printerName(name: string): Extension { + return new Extension('printer_name', name); +} + +export function printerDeviceId(id: number): Extension { + return new Extension('printer_device_id', id); +} + +export function printerDriverName(driverName: string): Extension { + return new Extension('printer_driver_name', driverName); +} + +// Runtime operation extensions (invoked via Session.invokeExtension()) + +export function requestFileContents(params: { + stream_id: number; + file_index: number; + flags: number; + position: number; + size: number; + clip_data_id?: number; +}): Extension { + return new Extension('request_file_contents', params as unknown); +} + +export function submitFileContents(params: { stream_id: number; is_error: boolean; data: Uint8Array }): Extension { + return new Extension('submit_file_contents', params as unknown); +} + +export function initiateFileCopy(files: FileInfo[]): Extension { + return new Extension('initiate_file_copy', files as unknown); +} diff --git a/web-client/iron-remote-desktop-rdp/src/main.ts b/web-client/iron-remote-desktop-rdp/src/main.ts index b5b0200e25..ccc39a83ac 100644 --- a/web-client/iron-remote-desktop-rdp/src/main.ts +++ b/web-client/iron-remote-desktop-rdp/src/main.ts @@ -24,6 +24,8 @@ export const Backend = { DeviceEvent: DeviceEvent, }; +// --- Pre-connection configuration extensions --- + export function preConnectionBlob(pcb: string): Extension { return new Extension('pcb', pcb); } @@ -43,3 +45,46 @@ export function outboundMessageSizeLimit(limit: number): Extension { export function enableCredssp(enable: boolean): Extension { return new Extension('enable_credssp', enable); } + +// --- File transfer (RDP-specific) --- + +export { RdpFileTransferProvider } from './RdpFileTransferProvider'; +export type { + RdpFileTransferProviderOptions, + TransferProgress, + FileTransferError, + DownloadHandle, + UploadHandle, + DroppedFile, +} from './RdpFileTransferProvider'; +export type { FileInfo, FileContentsRequest, FileContentsResponse } from './FileTransfer'; +export { FileContentsFlags } from './FileContentsFlags'; + +// --- Storage backends --- +// Re-export for consumers who want to configure the storageBackend +// option on RdpFileTransferProviderOptions, implement a custom backend, +// or construct a specific backend instance directly. +export type { FileStorageBackend, FileWriteHandle, StorageBackendPreference } from './storage'; +export { BlobStorageBackend } from './storage'; +export { OpfsStorageBackend } from './storage'; +export { detectStorageBackend } from './storage'; + +// Re-export extension factories for advanced consumers who want to +// register callbacks or invoke file transfer operations directly. +export { + filesAvailableCallback, + fileContentsRequestCallback, + fileContentsResponseCallback, + lockCallback, + unlockCallback, + locksExpiredCallback, + requestFileContents, + submitFileContents, + initiateFileCopy, + printJobStreamCallbacks, + PrinterDriverName, + printerName, + printerDeviceId, + printerDriverName, +} from './extensions'; +export type { PrintJobStreamCallbacks } from './extensions'; diff --git a/web-client/iron-remote-desktop-rdp/src/storage/BlobStorageBackend.ts b/web-client/iron-remote-desktop-rdp/src/storage/BlobStorageBackend.ts new file mode 100644 index 0000000000..c7ac3f8478 --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/storage/BlobStorageBackend.ts @@ -0,0 +1,69 @@ +import type { FileStorageBackend, FileWriteHandle } from './FileStorageBackend'; + +/** + * Write handle that accumulates chunks in an in-memory array and assembles + * a {@link Blob} on {@link finalize}. + * + * Simple and universally supported, but peak RAM is approximately 2x the + * file size (the chunk array plus the final Blob). See + * {@link OpfsStorageBackend} for a streaming alternative. + */ +class BlobWriteHandle implements FileWriteHandle { + private chunks: Uint8Array[] = []; + private _bytesWritten = 0; + private finalized = false; + + get bytesWritten(): number { + return this._bytesWritten; + } + + async write(chunk: Uint8Array): Promise { + if (this.finalized) { + throw new Error('BlobWriteHandle: write after finalize/abort'); + } + this.chunks.push(chunk); + this._bytesWritten += chunk.length; + } + + async finalize(): Promise { + if (this.finalized) { + throw new Error('BlobWriteHandle: already finalized or aborted'); + } + this.finalized = true; + const blob = new Blob(this.chunks); + this.chunks = []; + return blob; + } + + async abort(): Promise { + if (this.finalized) { + return; + } + this.finalized = true; + this.chunks = []; + } +} + +/** + * In-memory Blob storage backend. + * + * Downloads are buffered as {@link Uint8Array} chunks in a plain array and + * assembled into a single {@link Blob} when the transfer completes. This + * is the universal fallback that works in every browser context. + * + * **Trade-offs:** + * - Peak RAM ~2x file size (chunk array + final Blob). + * - No persistent storage; data is lost on page unload. + * - No setup cost; works even in non-secure contexts and private browsing. + */ +export class BlobStorageBackend implements FileStorageBackend { + readonly name = 'blob'; + + async createWriteHandle(_fileName: string, _expectedSize: number): Promise { + return new BlobWriteHandle(); + } + + async dispose(): Promise { + // Nothing persistent to clean up. + } +} diff --git a/web-client/iron-remote-desktop-rdp/src/storage/FileStorageBackend.ts b/web-client/iron-remote-desktop-rdp/src/storage/FileStorageBackend.ts new file mode 100644 index 0000000000..8660d1e240 --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/storage/FileStorageBackend.ts @@ -0,0 +1,85 @@ +/** + * A write handle for streaming file data to a storage backend. + * + * Created once per download via {@link FileStorageBackend.createWriteHandle}. + * Chunks are appended via {@link write}, and on success {@link finalize} + * returns the assembled data as a {@link Blob} (or {@link File}, which + * extends Blob). On failure or cancellation, {@link abort} releases all + * resources held by the handle. + * + * Implementations may buffer in memory (Blob backend), stream to the + * Origin Private File System (OPFS backend), or stream to a user-chosen + * location (future FSAPI backend). + */ +export interface FileWriteHandle { + /** Append a chunk of data. + * + * Backends may buffer the chunk in memory or flush it to persistent + * storage immediately. The returned promise resolves once the chunk + * has been accepted (not necessarily persisted). */ + write(chunk: Uint8Array): Promise; + + /** + * Finalize the file and return the result as a Blob. + * + * For in-memory backends this assembles a Blob from buffered chunks. + * For persistent backends (e.g. OPFS) this closes the writable stream + * and returns a {@link File} (which extends Blob) backed by the on-disk + * data, keeping peak RAM close to zero. + * + * After calling finalize the handle must not be reused. + */ + finalize(): Promise; + + /** + * Discard all written data and release resources. + * + * Safe to call multiple times. After abort the handle must not be + * reused. + */ + abort(): Promise; + + /** Number of bytes successfully written so far. */ + readonly bytesWritten: number; +} + +/** + * Pluggable storage backend for file transfer downloads. + * + * The backend determines *where* incoming file chunks are buffered during + * a download. Protocol-specific file transfer providers delegate all + * storage concerns to the active backend, keeping download orchestration + * logic storage-agnostic. + * + * Three backends are planned: + * + * | Backend | Buffering | Peak RAM | Browser support | + * |---------|--------------------|---------------|---------------------------| + * | Blob | In-memory array | ~2x file size | Universal | + * | OPFS | Origin Private FS | ~chunk size | Baseline (September 2025) | + * | FSAPI | User-chosen file | ~chunk size | Chromium-only (future) | + */ +export interface FileStorageBackend { + /** Human-readable backend name, used in log messages. */ + readonly name: string; + + /** + * Create a write handle for a new download. + * + * @param fileName - Sanitized file basename (used for the temp file + * name in persistent backends). + * @param expectedSize - Expected total size in bytes. Backends may use + * this for pre-allocation or quota checks. A value + * of 0 means the size is unknown or the file is + * empty. + */ + createWriteHandle(fileName: string, expectedSize: number): Promise; + + /** + * Release all backend resources. + * + * For persistent backends this deletes the session directory and any + * temp files. Safe to call multiple times. + */ + dispose(): Promise; +} diff --git a/web-client/iron-remote-desktop-rdp/src/storage/OpfsStorageBackend.ts b/web-client/iron-remote-desktop-rdp/src/storage/OpfsStorageBackend.ts new file mode 100644 index 0000000000..08bc43b357 --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/storage/OpfsStorageBackend.ts @@ -0,0 +1,301 @@ +import type { FileStorageBackend, FileWriteHandle } from './FileStorageBackend'; + +/** + * Write handle that streams chunks to a file in the Origin Private File + * System via {@link FileSystemWritableFileStream}. + * + * Each chunk is flushed to disk immediately, so peak RAM stays close to + * the chunk size regardless of total file size. On {@link finalize} the + * stream is closed and a lazy {@link File} reference (which extends + * {@link Blob}) is returned -- the browser memory-maps reads from OPFS + * rather than loading the entire file into RAM. + */ +class OpfsWriteHandle implements FileWriteHandle { + private writable: FileSystemWritableFileStream | undefined; + private _bytesWritten = 0; + private finalized = false; + + constructor( + private readonly fileHandle: FileSystemFileHandle, + private readonly sessionDir: FileSystemDirectoryHandle, + private readonly entryName: string, + writable: FileSystemWritableFileStream, + ) { + this.writable = writable; + } + + get bytesWritten(): number { + return this._bytesWritten; + } + + async write(chunk: Uint8Array): Promise { + if (this.finalized || !this.writable) { + throw new Error('OpfsWriteHandle: write after finalize/abort'); + } + await this.writable.write(chunk); + this._bytesWritten += chunk.length; + } + + async finalize(): Promise { + if (this.finalized) { + throw new Error('OpfsWriteHandle: already finalized or aborted'); + } + this.finalized = true; + + if (this.writable) { + await this.writable.close(); + this.writable = undefined; + } + + // getFile() returns a File (extends Blob) backed by OPFS storage. + // The browser lazily reads from disk -- the file data is NOT copied + // into RAM here. + return this.fileHandle.getFile(); + } + + async abort(): Promise { + if (this.finalized) { + return; + } + this.finalized = true; + + if (this.writable) { + try { + await this.writable.abort(); + } catch { + // Writable may already be closed or errored; ignore. + } + this.writable = undefined; + } + + // Remove the temp file so it does not consume quota. + try { + await this.sessionDir.removeEntry(this.entryName); + } catch { + // Entry may already be gone (e.g., session dir was deleted). + } + } +} + +/** + * Storage backend that streams download chunks to the Origin Private File + * System (OPFS). + * + * OPFS is a browser-provided, origin-scoped file system that requires no + * user permission prompts and is available on the main thread (async + * only). This backend requires the {@link FileSystemWritableFileStream} + * API, which reached Baseline across all major browsers in September 2025 + * (Chrome 86+, Firefox 111+, Safari 17.2+, Edge 86+). Older browsers + * are detected automatically via {@link OpfsStorageBackend.probe} and + * fall back to the Blob backend. + * + * **How it works:** + * 1. On construction, a per-session subdirectory is created under + * `ironrdp-transfers/` in the OPFS root. + * 2. Each download opens a {@link FileSystemWritableFileStream} inside + * that directory and flushes chunks to disk as they arrive. + * 3. On completion, the stream is closed and a lazy {@link File} handle is + * returned. The File extends Blob, so existing consumers that expect + * a Blob work without changes. + * 4. On dispose, the entire session directory is deleted. + * + * **Trade-offs vs Blob backend:** + * - Peak RAM drops from ~2x file size to ~chunk size (typically 64 KB). + * - Moderate write latency per chunk (async disk I/O), but the download + * is already async and network-bound. + * - Storage is subject to the origin's quota (typically 60% of disk). + * - May be unavailable in some private browsing modes. + * + * **Construction:** Use the static {@link OpfsStorageBackend.create} + * factory method. The constructor is private because initialization + * requires async OPFS directory setup. + */ +export class OpfsStorageBackend implements FileStorageBackend { + readonly name = 'opfs'; + + /** Sequence counter for generating unique temp file names. */ + private sequence = 0; + + private constructor( + private readonly opfsRoot: FileSystemDirectoryHandle, + private sessionDir: FileSystemDirectoryHandle | undefined, + private readonly sessionId: string, + ) {} + + /** + * Create an OPFS backend, including the per-session directory. + * + * Call {@link probe} first to verify OPFS is available before + * constructing -- this factory assumes OPFS works. + */ + static async create(opfsRoot: FileSystemDirectoryHandle, sessionId?: string): Promise { + const id = sessionId ?? `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const transfersDir = await opfsRoot.getDirectoryHandle('ironrdp-transfers', { create: true }); + const sessionDir = await transfersDir.getDirectoryHandle(id, { create: true }); + + // Best-effort cleanup of orphaned session directories from previous + // sessions that did not call dispose() (e.g., tab crash, browser + // force-quit). Runs asynchronously and never blocks creation. + void OpfsStorageBackend.cleanupStale(transfersDir, id); + + return new OpfsStorageBackend(opfsRoot, sessionDir, id); + } + + /** Maximum age (in milliseconds) before a session directory is considered stale. */ + private static readonly STALE_SESSION_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours + + /** + * Remove session directories older than {@link STALE_SESSION_THRESHOLD_MS}. + * + * Session IDs generated by {@link create} embed a timestamp in the + * format `s-{Date.now()}-{random}`. This method parses that timestamp + * to determine age. Directories with unparsable names or those + * belonging to the current session are skipped. + */ + private static async cleanupStale( + transfersDir: FileSystemDirectoryHandle, + currentSessionId: string, + ): Promise { + const now = Date.now(); + try { + for await (const name of transfersDir.keys()) { + if (name === currentSessionId) { + continue; + } + // Parse timestamp from the session ID format: s-{timestamp}-{random}. + const match = /^s-(\d+)-/.exec(name); + if (!match) { + continue; + } + const timestamp = Number(match[1]); + if (now - timestamp > OpfsStorageBackend.STALE_SESSION_THRESHOLD_MS) { + try { + await transfersDir.removeEntry(name, { recursive: true }); + } catch { + // Ignore per-entry errors (may be in use by another tab). + } + } + } + } catch { + // The transfers directory may have been removed or is inaccessible. + } + } + + /** + * Probe whether OPFS is usable in the current context. + * + * Performs a full round-trip: creates a temp file, opens a writable, + * closes it, and deletes it. This catches environments where the API + * exists but throws at runtime (e.g., some private browsing modes). + */ + static async probe(opfsRoot: FileSystemDirectoryHandle): Promise { + try { + const handle = await opfsRoot.getFileHandle('.ironrdp-opfs-probe', { create: true }); + const writable = await handle.createWritable(); + await writable.close(); + await opfsRoot.removeEntry('.ironrdp-opfs-probe'); + return true; + } catch { + return false; + } + } + + async createWriteHandle(fileName: string, _expectedSize: number): Promise { + if (!this.sessionDir) { + throw new Error('OpfsStorageBackend: backend has been disposed'); + } + + // Use a sequence number + sanitized name to avoid collisions when + // the same file name is downloaded multiple times in one session. + const seq = this.sequence++; + const entryName = `${seq}-${sanitizeOpfsName(fileName)}`; + + const fileHandle = await this.sessionDir.getFileHandle(entryName, { create: true }); + const writable = await fileHandle.createWritable(); + + return new OpfsWriteHandle(fileHandle, this.sessionDir, entryName, writable); + } + + async dispose(): Promise { + if (!this.sessionDir) { + return; + } + + const sessionDir = this.sessionDir; + this.sessionDir = undefined; + + try { + const transfersDir = await this.opfsRoot.getDirectoryHandle('ironrdp-transfers'); + await transfersDir.removeEntry(this.sessionId, { recursive: true }); + + // Clean up the parent directory if it is now empty. + let hasEntries = false; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of transfersDir.values()) { + hasEntries = true; + break; + } + if (!hasEntries) { + await this.opfsRoot.removeEntry('ironrdp-transfers'); + } + } catch (error) { + console.debug('OPFS session directory removal failed, falling back to per-file cleanup:', error); + // The directory may already be gone if another tab cleaned up, + // or the OPFS was cleared externally. Fall back to deleting + // individual files from the session directory handle. + try { + for await (const name of sessionDir.keys()) { + try { + await sessionDir.removeEntry(name); + } catch { + // Ignore per-file errors. + } + } + } catch { + // Session dir handle may be stale; nothing more to do. + } + } + } +} + +/** + * Sanitize a file name for use as an OPFS entry name. + * + * OPFS entry names must not contain `/` or `\`, and must not be `.` or + * `..`. We strip control characters, replace separators with + * underscores, and strip leading dots. + */ +function sanitizeOpfsName(name: string): string { + // Strip ASCII control characters (U+0000-U+001F) that could cause + // inconsistent behavior across OPFS implementations or confuse logs. + // eslint-disable-next-line no-control-regex + let safe = name.replace(/[\u0000-\u001f]/g, ''); + + safe = safe.replace(/[/\\]/g, '_'); + + // Strip leading dots to avoid `.` / `..` collisions. + safe = safe.replace(/^\.+/, ''); + + // Ensure we always have a non-empty name. + if (safe.length === 0) { + safe = 'unnamed'; + } + + // OPFS entry names are typically limited to 255 bytes. Truncate by + // UTF-8 byte length (not JS char count) to leave room for the + // sequence prefix added by createWriteHandle. Non-ASCII characters + // can be 2-4 bytes each, so a char-based limit could still exceed + // the byte budget. + const encoder = new TextEncoder(); + if (encoder.encode(safe).byteLength > 200) { + while (encoder.encode(safe).byteLength > 200) { + safe = safe.slice(0, -1); + } + // Ensure truncation did not leave us empty. + if (safe.length === 0) { + safe = 'unnamed'; + } + } + + return safe; +} diff --git a/web-client/iron-remote-desktop-rdp/src/storage/detect.ts b/web-client/iron-remote-desktop-rdp/src/storage/detect.ts new file mode 100644 index 0000000000..0bfdf94236 --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/storage/detect.ts @@ -0,0 +1,62 @@ +import type { FileStorageBackend } from './FileStorageBackend'; +import { BlobStorageBackend } from './BlobStorageBackend'; +import { OpfsStorageBackend } from './OpfsStorageBackend'; + +/** + * Storage backend preference for downloads. + * + * - `'auto'` - detect the best available backend (OPFS with Blob + * fallback). OPFS reduces peak download RAM from ~2x file size to + * ~chunk size. + * - `'blob'` - force in-memory Blob storage regardless of OPFS + * availability. + */ +export type StorageBackendPreference = 'auto' | 'blob'; + +/** + * Detect the best available storage backend for the current browser + * context. + * + * When `preference` is `'auto'` (default), the function probes for OPFS + * support with a full round-trip smoke test. If OPFS is available, an + * {@link OpfsStorageBackend} is returned; otherwise a + * {@link BlobStorageBackend} is used as the universal fallback. + * + * When `preference` is `'blob'`, OPFS detection is skipped entirely and + * the Blob backend is returned immediately. + * + * @param preference - `'auto'` to detect, `'blob'` to force in-memory. + * @param sessionId - Optional session identifier used as the OPFS + * subdirectory name. When omitted a unique ID is + * generated from the current timestamp. + * @returns The selected backend, ready to use. + */ +export async function detectStorageBackend( + preference: StorageBackendPreference = 'auto', + sessionId?: string, +): Promise { + if (preference === 'blob') { + return new BlobStorageBackend(); + } + + // Attempt OPFS detection. + if (typeof globalThis.navigator?.storage?.getDirectory === 'function') { + try { + const opfsRoot = await navigator.storage.getDirectory(); + + if (await OpfsStorageBackend.probe(opfsRoot)) { + return OpfsStorageBackend.create(opfsRoot, sessionId); + } + + // Probe failed: the OPFS API exists but is not functional + // (e.g., createWritable() throws in some browser modes). + console.debug('OPFS probe failed (createWritable not functional), falling back to blob storage'); + } catch (error) { + // getDirectory() itself threw (e.g., SecurityError in some + // private browsing modes). Fall through to Blob. + console.debug('OPFS unavailable, falling back to blob storage:', error); + } + } + + return new BlobStorageBackend(); +} diff --git a/web-client/iron-remote-desktop-rdp/src/storage/index.ts b/web-client/iron-remote-desktop-rdp/src/storage/index.ts new file mode 100644 index 0000000000..6d0332a846 --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/storage/index.ts @@ -0,0 +1,5 @@ +export type { FileStorageBackend, FileWriteHandle } from './FileStorageBackend'; +export { BlobStorageBackend } from './BlobStorageBackend'; +export { OpfsStorageBackend } from './OpfsStorageBackend'; +export { detectStorageBackend } from './detect'; +export type { StorageBackendPreference } from './detect'; diff --git a/web-client/iron-remote-desktop-rdp/src/storage/storage.test.ts b/web-client/iron-remote-desktop-rdp/src/storage/storage.test.ts new file mode 100644 index 0000000000..f6e885cf90 --- /dev/null +++ b/web-client/iron-remote-desktop-rdp/src/storage/storage.test.ts @@ -0,0 +1,620 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { BlobStorageBackend } from './BlobStorageBackend'; +import { OpfsStorageBackend } from './OpfsStorageBackend'; +import { detectStorageBackend } from './detect'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function chunk(bytes: number[]): Uint8Array { + return new Uint8Array(bytes); +} + +async function blobToBytes(blob: Blob): Promise { + // jsdom Blob.arrayBuffer() may not be available or may behave + // inconsistently. Use FileReader which jsdom supports reliably. + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer)); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(blob); + }); +} + +// --------------------------------------------------------------------------- +// Minimal in-memory mock of the OPFS directory/file handle API. +// Shared across OpfsStorageBackend and detectStorageBackend test suites. +// --------------------------------------------------------------------------- + +interface MockEntry { + kind: 'file' | 'directory'; + name: string; + children?: Map; + content?: Uint8Array[]; + writable?: MockWritable; +} + +class MockWritable { + closed = false; + aborted = false; + private target: MockEntry; + + constructor(target: MockEntry) { + this.target = target; + // Reset content on each new writable (mirrors real createWritable) + this.target.content = []; + } + + async write(data: Uint8Array): Promise { + if (this.closed || this.aborted) throw new Error('stream closed'); + this.target.content!.push(new Uint8Array(data)); + } + + async close(): Promise { + this.closed = true; + } + + async abort(): Promise { + this.aborted = true; + } +} + +function createMockFileHandle(entry: MockEntry): FileSystemFileHandle { + return { + kind: 'file' as const, + name: entry.name, + isSameEntry: vi.fn(), + async getFile() { + const parts = entry.content ?? []; + return new Blob(parts); + }, + async createWritable() { + const w = new MockWritable(entry); + entry.writable = w; + return w as unknown as FileSystemWritableFileStream; + }, + async createSyncAccessHandle() { + throw new Error('not implemented'); + }, + } as unknown as FileSystemFileHandle; +} + +function createMockDirectoryHandle(name: string, children?: Map): FileSystemDirectoryHandle { + const entries: Map = children ?? new Map(); + + function makeValuesIterator(): AsyncIterableIterator { + const values = [...entries.values()]; + let index = 0; + return { + [Symbol.asyncIterator]() { + return this; + }, + async next() { + if (index < values.length) { + return { value: values[index++] as unknown as FileSystemHandle, done: false as const }; + } + return { value: undefined, done: true as const }; + }, + }; + } + + function makeKeysIterator(): AsyncIterableIterator { + const keys = [...entries.keys()]; + let index = 0; + return { + [Symbol.asyncIterator]() { + return this; + }, + async next() { + if (index < keys.length) { + return { value: keys[index++], done: false as const }; + } + return { value: undefined, done: true as const }; + }, + }; + } + + return { + kind: 'directory' as const, + name, + isSameEntry: vi.fn(), + async getFileHandle(fileName: string, options?: FileSystemGetFileOptions) { + let entry = entries.get(fileName); + if (entry === undefined && options?.create === true) { + entry = { kind: 'file', name: fileName, content: [] }; + entries.set(fileName, entry); + } + if (entry === undefined) throw new DOMException('NotFoundError'); + return createMockFileHandle(entry); + }, + async getDirectoryHandle(dirName: string, options?: FileSystemGetDirectoryOptions) { + let entry = entries.get(dirName); + if (entry === undefined && options?.create === true) { + entry = { kind: 'directory', name: dirName, children: new Map() }; + entries.set(dirName, entry); + } + if (entry === undefined) throw new DOMException('NotFoundError'); + return createMockDirectoryHandle(dirName, entry.children); + }, + async removeEntry(entryName: string, _options?: FileSystemRemoveOptions) { + if (!entries.has(entryName)) throw new DOMException('NotFoundError'); + entries.delete(entryName); + }, + async resolve(_child: FileSystemHandle) { + return null; + }, + values: makeValuesIterator, + keys: makeKeysIterator, + entries() { + return makeValuesIterator() as unknown as AsyncIterableIterator<[string, FileSystemHandle]>; + }, + [Symbol.asyncIterator]() { + return makeValuesIterator() as unknown as AsyncIterableIterator<[string, FileSystemHandle]>; + }, + } as unknown as FileSystemDirectoryHandle; +} + +// --------------------------------------------------------------------------- +// BlobStorageBackend +// --------------------------------------------------------------------------- + +describe('BlobStorageBackend', () => { + let backend: BlobStorageBackend; + + beforeEach(() => { + backend = new BlobStorageBackend(); + }); + + it('has name "blob"', () => { + expect(backend.name).toBe('blob'); + }); + + it('write then finalize produces correct Blob', async () => { + const handle = await backend.createWriteHandle('test.bin', 6); + + await handle.write(chunk([1, 2, 3])); + expect(handle.bytesWritten).toBe(3); + + await handle.write(chunk([4, 5, 6])); + expect(handle.bytesWritten).toBe(6); + + const blob = await handle.finalize(); + expect(blob).toBeInstanceOf(Blob); + expect(blob.size).toBe(6); + + const data = await blobToBytes(blob); + expect(data).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6])); + }); + + it('finalize on empty handle returns empty Blob', async () => { + const handle = await backend.createWriteHandle('empty.bin', 0); + const blob = await handle.finalize(); + expect(blob.size).toBe(0); + expect(handle.bytesWritten).toBe(0); + }); + + it('abort clears state and prevents reuse', async () => { + const handle = await backend.createWriteHandle('test.bin', 3); + await handle.write(chunk([1, 2, 3])); + await handle.abort(); + + // Write after abort throws + await expect(handle.write(chunk([4]))).rejects.toThrow(/finalize|abort/); + // Finalize after abort throws + await expect(handle.finalize()).rejects.toThrow(/finalize|abort/); + }); + + it('double abort is safe', async () => { + const handle = await backend.createWriteHandle('test.bin', 0); + await handle.abort(); + await expect(handle.abort()).resolves.toBeUndefined(); + }); + + it('write after finalize throws', async () => { + const handle = await backend.createWriteHandle('test.bin', 0); + await handle.finalize(); + await expect(handle.write(chunk([1]))).rejects.toThrow(/finalize|abort/); + }); + + it('double finalize throws', async () => { + const handle = await backend.createWriteHandle('test.bin', 0); + await handle.finalize(); + await expect(handle.finalize()).rejects.toThrow(/finalize|abort/); + }); + + it('dispose is a no-op', async () => { + await expect(backend.dispose()).resolves.toBeUndefined(); + }); + + it('multiple concurrent handles are independent', async () => { + const h1 = await backend.createWriteHandle('a.bin', 2); + const h2 = await backend.createWriteHandle('b.bin', 2); + + await h1.write(chunk([10, 20])); + await h2.write(chunk([30, 40])); + + const b1 = await h1.finalize(); + const b2 = await h2.finalize(); + + expect(await blobToBytes(b1)).toEqual(new Uint8Array([10, 20])); + expect(await blobToBytes(b2)).toEqual(new Uint8Array([30, 40])); + }); +}); + +// --------------------------------------------------------------------------- +// OpfsStorageBackend - mocked OPFS +// --------------------------------------------------------------------------- + +describe('OpfsStorageBackend', () => { + let mockOpfsRoot: FileSystemDirectoryHandle; + + beforeEach(() => { + mockOpfsRoot = createMockDirectoryHandle(''); + }); + + describe('probe', () => { + it('returns true when OPFS works', async () => { + expect(await OpfsStorageBackend.probe(mockOpfsRoot)).toBe(true); + }); + + it('returns false when createWritable throws', async () => { + const broken = { + ...mockOpfsRoot, + async getFileHandle() { + return { + async createWritable() { + throw new Error('SecurityError'); + }, + } as unknown as FileSystemFileHandle; + }, + } as unknown as FileSystemDirectoryHandle; + + expect(await OpfsStorageBackend.probe(broken)).toBe(false); + }); + }); + + describe('lifecycle', () => { + it('creates session directory on construction', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'test-session'); + expect(backend.name).toBe('opfs'); + + // Verify the session directory was created + const transfersDir = await mockOpfsRoot.getDirectoryHandle('ironrdp-transfers'); + const sessionDir = await transfersDir.getDirectoryHandle('test-session'); + expect(sessionDir.name).toBe('test-session'); + + await backend.dispose(); + }); + + it('write/finalize produces a Blob', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-1'); + const handle = await backend.createWriteHandle('hello.bin', 4); + + await handle.write(chunk([10, 20])); + expect(handle.bytesWritten).toBe(2); + + await handle.write(chunk([30, 40])); + expect(handle.bytesWritten).toBe(4); + + const blob = await handle.finalize(); + expect(blob.size).toBe(4); + + const data = await blobToBytes(blob); + expect(data).toEqual(new Uint8Array([10, 20, 30, 40])); + + await backend.dispose(); + }); + + it('abort removes the temp file', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-2'); + const handle = await backend.createWriteHandle('abort-me.bin', 4); + await handle.write(chunk([1, 2, 3, 4])); + await handle.abort(); + + // Write after abort should throw + await expect(handle.write(chunk([5]))).rejects.toThrow(); + + await backend.dispose(); + }); + + it('double abort is safe', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-3'); + const handle = await backend.createWriteHandle('test.bin', 0); + await handle.abort(); + await expect(handle.abort()).resolves.toBeUndefined(); + await backend.dispose(); + }); + + it('write after finalize throws', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-waf'); + const handle = await backend.createWriteHandle('test.bin', 0); + await handle.finalize(); + await expect(handle.write(chunk([1]))).rejects.toThrow(/finalize|abort/); + await backend.dispose(); + }); + + it('double finalize throws', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-df'); + const handle = await backend.createWriteHandle('test.bin', 0); + await handle.finalize(); + await expect(handle.finalize()).rejects.toThrow(/finalize|abort/); + await backend.dispose(); + }); + + it('dispose cleans up session directory', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-cleanup'); + await backend.createWriteHandle('file1.bin', 0); + await backend.dispose(); + + // When the session was the only child, dispose also removes the + // parent `ironrdp-transfers` directory. Either the parent or + // the session subdir being gone confirms cleanup succeeded. + let sessionDirExists = true; + try { + const transfersDir = await mockOpfsRoot.getDirectoryHandle('ironrdp-transfers'); + await transfersDir.getDirectoryHandle('sess-cleanup'); + } catch { + sessionDirExists = false; + } + expect(sessionDirExists).toBe(false); + }); + + it('double dispose is safe', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-double'); + await backend.dispose(); + await expect(backend.dispose()).resolves.toBeUndefined(); + }); + + it('createWriteHandle after dispose throws', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-after'); + await backend.dispose(); + await expect(backend.createWriteHandle('nope.bin', 0)).rejects.toThrow(/disposed/); + }); + + it('sanitizes file names for OPFS entries', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-sanitize'); + + // Collect entry names created in the session directory. + const transfersDir = await mockOpfsRoot.getDirectoryHandle('ironrdp-transfers'); + const sessionDir = await transfersDir.getDirectoryHandle('sess-sanitize'); + async function entryNames(): Promise { + const names: string[] = []; + for await (const name of sessionDir.keys()) names.push(name); + return names; + } + + // Path traversal: slashes become underscores, dots are interior + // (leading-dot stripping only applies after separator replacement). + const h1 = await backend.createWriteHandle('../../etc/passwd', 3); + await h1.write(chunk([1, 2, 3])); + await h1.finalize(); + expect(await entryNames()).toEqual(['0-_.._etc_passwd']); + + // Bare ".." becomes empty after stripping, falls back to "unnamed". + const h2 = await backend.createWriteHandle('..', 1); + await h2.write(chunk([1])); + await h2.finalize(); + expect(await entryNames()).toContain('1-unnamed'); + + // Leading dots stripped, rest preserved. + const h3 = await backend.createWriteHandle('.hidden', 1); + await h3.write(chunk([1])); + await h3.finalize(); + expect(await entryNames()).toContain('2-hidden'); + + // Control characters (null bytes, tabs, newlines) are stripped. + const h4 = await backend.createWriteHandle('foo\x00bar\tbaz\n.txt', 1); + await h4.write(chunk([1])); + await h4.finalize(); + expect(await entryNames()).toContain('3-foobarbaz.txt'); + + // Multi-byte UTF-8 names are truncated by byte length, not char count. + // Each emoji is 4 UTF-8 bytes; 51 emojis = 204 bytes > 200 byte limit. + const longEmoji = '\u{1F600}'.repeat(51); // 51 x 4 = 204 bytes + const h5 = await backend.createWriteHandle(longEmoji, 1); + await h5.write(chunk([1])); + await h5.finalize(); + const names = await entryNames(); + const emojiEntry = names.find((n) => n.startsWith('4-')); + expect(emojiEntry).toBeDefined(); + // Should be truncated to at most 200 UTF-8 bytes (50 emojis = 200 bytes). + const encoder = new TextEncoder(); + const sanitized = emojiEntry!.slice(2); // strip "4-" prefix + expect(encoder.encode(sanitized).byteLength).toBeLessThanOrEqual(200); + expect(encoder.encode(sanitized).byteLength).toBeGreaterThan(0); + + await backend.dispose(); + }); + + it('handles concurrent writes to different files', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-concurrent'); + + const h1 = await backend.createWriteHandle('a.bin', 2); + const h2 = await backend.createWriteHandle('b.bin', 2); + + await h1.write(chunk([1, 2])); + await h2.write(chunk([3, 4])); + + const b1 = await h1.finalize(); + const b2 = await h2.finalize(); + + expect(await blobToBytes(b1)).toEqual(new Uint8Array([1, 2])); + expect(await blobToBytes(b2)).toEqual(new Uint8Array([3, 4])); + + await backend.dispose(); + }); + + it('generates unique entry names for duplicate file names', async () => { + const backend = await OpfsStorageBackend.create(mockOpfsRoot, 'sess-dup'); + + // Two downloads of the same file name should not collide + const h1 = await backend.createWriteHandle('same.bin', 1); + const h2 = await backend.createWriteHandle('same.bin', 1); + + await h1.write(chunk([10])); + await h2.write(chunk([20])); + + const b1 = await h1.finalize(); + const b2 = await h2.finalize(); + + // They should be independent + expect(await blobToBytes(b1)).toEqual(new Uint8Array([10])); + expect(await blobToBytes(b2)).toEqual(new Uint8Array([20])); + + await backend.dispose(); + }); + }); + + describe('stale session cleanup', () => { + it('removes session directories older than 24 hours on create', async () => { + // Pre-populate ironrdp-transfers/ with stale and fresh entries. + const transfersDir = await mockOpfsRoot.getDirectoryHandle('ironrdp-transfers', { create: true }); + + const staleTimestamp = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago + const freshTimestamp = Date.now() - 1 * 60 * 60 * 1000; // 1 hour ago + const staleId = `s-${staleTimestamp}-abc123`; + const freshId = `s-${freshTimestamp}-def456`; + + await transfersDir.getDirectoryHandle(staleId, { create: true }); + await transfersDir.getDirectoryHandle(freshId, { create: true }); + + // Creating a new backend triggers cleanupStale (fire-and-forget). + const backend = await OpfsStorageBackend.create(mockOpfsRoot, `s-${Date.now()}-new000`); + await new Promise((r) => setTimeout(r, 10)); + + // The stale directory should have been removed. + let staleExists = true; + try { + await transfersDir.getDirectoryHandle(staleId); + } catch { + staleExists = false; + } + expect(staleExists).toBe(false); + + // The fresh directory should still exist. + const freshDir = await transfersDir.getDirectoryHandle(freshId); + expect(freshDir.name).toBe(freshId); + + await backend.dispose(); + }); + + it('skips directories with unparsable names', async () => { + const transfersDir = await mockOpfsRoot.getDirectoryHandle('ironrdp-transfers', { create: true }); + + // Non-matching names should be left alone. + await transfersDir.getDirectoryHandle('custom-dir', { create: true }); + + const backend = await OpfsStorageBackend.create(mockOpfsRoot, `s-${Date.now()}-test00`); + await new Promise((r) => setTimeout(r, 10)); + + const customDir = await transfersDir.getDirectoryHandle('custom-dir'); + expect(customDir.name).toBe('custom-dir'); + + await backend.dispose(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// detectStorageBackend +// --------------------------------------------------------------------------- + +describe('detectStorageBackend', () => { + const originalNavigator = globalThis.navigator; + + afterEach(() => { + // Restore navigator after each test + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + writable: true, + configurable: true, + }); + }); + + it('returns BlobStorageBackend when preference is "blob"', async () => { + const backend = await detectStorageBackend('blob'); + expect(backend.name).toBe('blob'); + }); + + it('returns BlobStorageBackend when navigator.storage is unavailable', async () => { + Object.defineProperty(globalThis, 'navigator', { + value: {}, + writable: true, + configurable: true, + }); + + const backend = await detectStorageBackend('auto'); + expect(backend.name).toBe('blob'); + }); + + it('returns BlobStorageBackend when getDirectory throws', async () => { + Object.defineProperty(globalThis, 'navigator', { + value: { + storage: { + getDirectory: () => Promise.reject(new Error('SecurityError')), + }, + }, + writable: true, + configurable: true, + }); + + const backend = await detectStorageBackend('auto'); + expect(backend.name).toBe('blob'); + }); + + it('defaults to auto when no preference given', async () => { + Object.defineProperty(globalThis, 'navigator', { + value: {}, + writable: true, + configurable: true, + }); + + const backend = await detectStorageBackend(); + expect(backend.name).toBe('blob'); + }); + + it('returns BlobStorageBackend when OPFS probe fails', async () => { + // getDirectory() succeeds, but createWritable() throws inside probe(). + const brokenRoot = { + ...createMockDirectoryHandle(''), + async getFileHandle() { + return { + async createWritable() { + throw new Error('SecurityError'); + }, + } as unknown as FileSystemFileHandle; + }, + } as unknown as FileSystemDirectoryHandle; + + Object.defineProperty(globalThis, 'navigator', { + value: { + storage: { + getDirectory: () => Promise.resolve(brokenRoot), + }, + }, + writable: true, + configurable: true, + }); + + const backend = await detectStorageBackend('auto'); + expect(backend.name).toBe('blob'); + }); + + it('returns OpfsStorageBackend when OPFS is available', async () => { + const mockRoot = createMockDirectoryHandle(''); + Object.defineProperty(globalThis, 'navigator', { + value: { + storage: { + getDirectory: () => Promise.resolve(mockRoot), + }, + }, + writable: true, + configurable: true, + }); + + const backend = await detectStorageBackend('auto'); + expect(backend.name).toBe('opfs'); + await backend.dispose(); + }); +}); diff --git a/web-client/iron-remote-desktop-rdp/tsconfig.json b/web-client/iron-remote-desktop-rdp/tsconfig.json index 44d0f26e38..6ce1748cc9 100644 --- a/web-client/iron-remote-desktop-rdp/tsconfig.json +++ b/web-client/iron-remote-desktop-rdp/tsconfig.json @@ -19,7 +19,9 @@ "strictNullChecks": true, "noImplicitAny": true, "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "types": ["ua-parser-js"], + "skipLibCheck": true }, "include": ["src/**/*.ts", "src/**/*.js"], "references": [ diff --git a/web-client/iron-remote-desktop-rdp/vite.config.ts b/web-client/iron-remote-desktop-rdp/vite.config.ts index a81a541274..256e5097b9 100644 --- a/web-client/iron-remote-desktop-rdp/vite.config.ts +++ b/web-client/iron-remote-desktop-rdp/vite.config.ts @@ -1,9 +1,13 @@ +/// import { defineConfig } from 'vite'; import topLevelAwait from 'vite-plugin-top-level-await'; import dtsPlugin from 'vite-plugin-dts'; // https://vitejs.dev/config/ export default defineConfig({ + test: { + environment: 'jsdom', + }, build: { lib: { entry: './src/main.ts', diff --git a/web-client/iron-remote-desktop/.prettierignore b/web-client/iron-remote-desktop/.prettierignore index 764bd85cc0..da1310908e 100644 --- a/web-client/iron-remote-desktop/.prettierignore +++ b/web-client/iron-remote-desktop/.prettierignore @@ -10,7 +10,11 @@ node_modules/ /static/bearcss /static/material-icons /dist +/pkg # Ignore files for PNPM, NPM and YARN pnpm-lock.yaml package-lock.json yarn.lock + +# Auto-generated by git-cliff +/public/CHANGELOG.md diff --git a/web-client/iron-remote-desktop/README.md b/web-client/iron-remote-desktop/README.md index 39d0289909..aaf2e7a46c 100644 --- a/web-client/iron-remote-desktop/README.md +++ b/web-client/iron-remote-desktop/README.md @@ -1,7 +1,58 @@ # Iron Remote Desktop -This is the core of the web client written on top of Svelte and built as a reusable Web Component. -Also, it contains the TypeScript interfaces exposed by WebAssembly bindings from `ironrdp-web` and used by `iron-svelte-client`. +Reusable web component and NPM package for remote desktop sessions, built with Svelte. + +## Design Philosophy + +`iron-remote-desktop` is **protocol-agnostic**. It knows nothing about RDP, VNC, or any other +specific remote protocol. It defines only features that are universal across all remote backends: +keyboard and mouse input, canvas rendering and resize, clipboard text/binary, connection +lifecycle, and cursor style. + +### Backends + +A **backend** implements the `RemoteDesktopModule` interface and plugs in via the `module` +component property/prop (for example, by assigning `element.module = Backend` or the +framework-specific equivalent). The RDP backend is `iron-remote-desktop-rdp`; other backends +can be written against the same interfaces. + +### Extension mechanism + +Protocol-specific features that have no equivalent in other protocols must never be added to +`UserInteraction`, `Session`, or `SessionBuilder`. They belong in the backend package and are +delivered via the extension mechanism: + +```typescript +// Backend-defined factory (in iron-remote-desktop-rdp): +import { preConnectionBlob, displayControl } from '@devolutions/iron-remote-desktop-rdp'; + +// Consumer configures protocol-specific behaviour through extensions on the UserInteraction +// instance received from the `ready` event: +ironRemoteDesktop.addEventListener('ready', (event) => { + const ui = event.detail; + + const config = ui.configBuilder().withExtension(preConnectionBlob('...')).withExtension(displayControl(true)).build(); + + ui.connect(config); +}); +``` + +The `Extension` type is `unknown` in `iron-remote-desktop`, opaque by design. The component +passes extension values to the backend without inspection; the backend interprets them. + +At runtime, `invokeExtension(ext)` follows the same pattern for dynamic, post-connect control. + +**The guiding question for any new `UserInteraction` / `Session` / `SessionBuilder` method:** + +A method belongs in the base API if **either** of the following is true: + +1. **The web component itself needs to call it** to implement transparent, protocol-independent + behaviour (e.g., `supportsUnicodeKeyboardShortcuts()` is called by the component to adapt + keyboard handling, without consumer involvement). +2. **The feature is universal**: every reasonable remote protocol backend would implement it + in a meaningful way (e.g., resize, clipboard text, cursor style). + +If neither applies, it is protocol-specific and must go through extensions. ## Development @@ -29,16 +80,10 @@ In your code add a listener for the `ready` event on the `iron-remote-desktop` H Get `evt.detail.irgUserInteraction` from the `Promise`, a property whose type is `UserInteraction`. Call the `connect` method on this object. -## Limitations - -For now, we didn't make the enums used by some method directly available (I didn't find the good way to export them directly with the component.). -You need to recreate them on your application for now (it will be improved in future version); +## Supported Input -Also, even if the connection to RDP work there is still a lot of improvement to do. -As of now, you can expect, mouse movement and click (4 buttons) - no scroll, Keyboard for at least the standard. -Windows and CTRL+ALT+DEL can be called by method on `UserInteraction`. -Lock keys (like caps lock), have a partial support. -Other advanced functionalities (sharing / copy past...) are not implemented yet. +Mouse: movement, click (4 buttons), scroll. Keyboard: standard layout, Windows key, +Ctrl+Alt+Del. Lock keys (Caps Lock, Num Lock, Scroll Lock, Kana): partial support. ## Component parameters @@ -54,42 +99,35 @@ You can add some parameters for default initialization on the component `; +configBuilder(): ConfigBuilder; +connect(config: Config): Promise; ``` -> `username` and `password` are the credentials to use on the remote host. +> `ctrlAltDel()` — Sends Ctrl+Alt+Del to the remote host. -> `destination` refers to the Devolutions Gateway hostname and port. +> `metaKey()` — Sends the Windows/Meta key to the remote host. -> `proxyAddress` is the address of the Devolutions Gateway proxy +> `setVisibility(value: boolean)` — Shows or hides the rendering canvas. -> `serverDomain` is the Windows domain name (if the target computer has one) +> `setScale(scale: ScreenScale)` — Sets canvas scaling behaviour (`fit`, `real`, or `full`). +> See [`ScreenScale`](./src/enums/ScreenScale.ts). -> `authtoken` is the authentication token to send to the Devolutions Gateway. +> `shutdown()` — Terminates the active session. -> `desktopSize` is the initial size of the desktop +> `setKeyboardUnicodeMode(useUnicode: boolean)` — Toggles Unicode keyboard mode. -> `preConnectionBlob` is the pre connection blob data +> `setCursorStyleOverride(style: string | null)` — Overrides cursor style; `null` restores default. -> `kdc_proxy_url` is the URL to a KDC Proxy, as specified in [MS-KKDCP documentation](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-kkdcp/5bcebb8d-b747-4ee5-9453-428aec1c5c38) +> `resize(width: number, height: number, scale?: number)` — Resizes the remote screen. -> `use_display_control` is the value that defined if the Display Control Virtual Channel will be used. +> `setEnableClipboard(enable: boolean)` — Enables or disables clipboard synchronization. -> `ctrlAltDel()` -> -> Sends the ctrl+alt+del key to server. +> `setEnableAutoClipboard(enable: boolean)` — Enables or disables automatic clipboard polling. > `metaKey()` > @@ -123,3 +161,46 @@ connect( > `setEnableClipboard(enable: boolean)` > > Enables or disable the clipboard based on the `enable` value. + +> `invokeExtension(ext: Extension)` — Sends a protocol-specific extension command at runtime. +> The extension value is passed to the backend without inspection. + +## File Transfer + +File transfer is protocol-specific. The `iron-remote-desktop` package defines only the +protocol-agnostic `FileTransferProvider` interface; the implementation lives in the backend +package (e.g., `RdpFileTransferProvider` in `@devolutions/iron-remote-desktop-rdp`). + +### Enabling File Transfer + +```typescript +import { RdpFileTransferProvider } from '@devolutions/iron-remote-desktop-rdp'; + +// Create a provider and pass it to the web component +const provider = new RdpFileTransferProvider({ chunkSize: 64 * 1024 }); +component.enableFileTransfer(provider); + +// Connect as usual - the provider receives builder extensions and session automatically +await component.connect(config); + +// Listen for files available for download +provider.on('files-available', async (files) => { + for (let i = 0; i < files.length; i++) { + const { completion } = provider.downloadFile(files[i], i); + const blob = await completion; + saveAs(blob, files[i].name); + } +}); + +// Track progress +provider.on('download-progress', (progress) => { + console.log(`${progress.fileName}: ${progress.percentage}%`); +}); + +// Upload files via drag-and-drop or file picker +const dropped = await provider.handleDrop(event); +provider.uploadFiles(dropped); +``` + +See the `@devolutions/iron-remote-desktop-rdp` package for the full API, events, and +extension factories. diff --git a/web-client/iron-remote-desktop/package-lock.json b/web-client/iron-remote-desktop/package-lock.json index de4e238fd8..f27eb72957 100644 --- a/web-client/iron-remote-desktop/package-lock.json +++ b/web-client/iron-remote-desktop/package-lock.json @@ -14,11 +14,13 @@ "@tsconfig/svelte": "^5.0.4", "@types/ua-parser-js": "^0.7.36", "@typescript-eslint/eslint-plugin": "^8.25.0", + "@vitest/ui": "^2.1.8", "eslint": "^9.21.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-svelte": "^3.0.2", "globals": "^16.0.0", + "jsdom": "^25.0.1", "prettier": "^3.1.0", "prettier-plugin-svelte": "^3.1.0", "svelte": "~5.20.2", @@ -30,7 +32,8 @@ "vite": "^6.2.0", "vite-plugin-dts": "^4.5.0", "vite-plugin-top-level-await": "^1.2.2", - "vite-plugin-wasm": "^3.1.0" + "vite-plugin-wasm": "^3.1.0", + "vitest": "^2.1.8" } }, "node_modules/@ampproject/remapping": { @@ -47,10 +50,24 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", "engines": { @@ -58,9 +75,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -68,13 +85,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", - "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.26.9" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -84,23 +101,138 @@ } }, "node_modules/@babel/types": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", - "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -115,9 +247,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -132,9 +264,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -149,9 +281,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -166,9 +298,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -183,9 +315,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -200,9 +332,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -217,9 +349,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -234,9 +366,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -251,9 +383,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -268,9 +400,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -285,9 +417,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -302,9 +434,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -319,9 +451,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -336,9 +468,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -353,9 +485,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -370,9 +502,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -387,9 +519,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], @@ -404,9 +536,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -421,9 +553,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -438,9 +570,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -454,10 +586,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -472,9 +621,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -489,9 +638,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -506,9 +655,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -523,9 +672,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -542,9 +691,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -552,48 +701,37 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", - "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, - "license": "ISC", + "license": "Apache-2.0", "dependencies": { - "brace-expansion": "^1.1.7" + "@eslint/core": "^0.17.0" }, "engines": { - "node": "*" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -604,20 +742,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", - "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -627,17 +765,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -651,33 +778,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@eslint/js": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.21.0.tgz", - "integrity": "sha512-BqStZ3HX8Yz6LvsF5ByXYrtigrV5AXADWLAGc7PH/1SxOb7/FIYYMszZZWiUou/GB9P2lXWk2SV4d+Z8h0nknw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -685,13 +802,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", - "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.12.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -709,33 +826,19 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -751,9 +854,9 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", - "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -765,18 +868,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -789,27 +888,17 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -818,64 +907,93 @@ } }, "node_modules/@microsoft/api-extractor": { - "version": "7.51.1", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.51.1.tgz", - "integrity": "sha512-VoFvIeYXme8QctXDkixy1KIn750kZaFy2snAEOB3nhDFfbBcJNEcvBrpCIQIV09MqI4g9egKUkg+/12WMRC77w==", + "version": "7.58.1", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.58.1.tgz", + "integrity": "sha512-kF3GFME4lN22O5zbnXk2RP4y/4PDQdps0xKiYTipMYprkwCmmpsWLZt/N2Fkbil540cSLfJX0BW7LkHzgMVUYg==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/api-extractor-model": "7.30.3", - "@microsoft/tsdoc": "~0.15.1", - "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.11.0", - "@rushstack/rig-package": "0.5.3", - "@rushstack/terminal": "0.15.0", - "@rushstack/ts-command-line": "4.23.5", - "lodash": "~4.17.15", - "minimatch": "~3.0.3", + "@microsoft/api-extractor-model": "7.33.5", + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.1", + "@rushstack/node-core-library": "5.21.0", + "@rushstack/rig-package": "0.7.2", + "@rushstack/terminal": "0.22.4", + "@rushstack/ts-command-line": "5.3.4", + "diff": "~8.0.2", + "lodash": "~4.18.1", + "minimatch": "10.2.3", "resolve": "~1.22.1", "semver": "~7.5.4", "source-map": "~0.6.1", - "typescript": "5.7.3" + "typescript": "5.9.3" }, "bin": { "api-extractor": "bin/api-extractor" } }, "node_modules/@microsoft/api-extractor-model": { - "version": "7.30.3", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.30.3.tgz", - "integrity": "sha512-yEAvq0F78MmStXdqz9TTT4PZ05Xu5R8nqgwI5xmUmQjWBQ9E6R2n8HB/iZMRciG4rf9iwI2mtuQwIzDXBvHn1w==", + "version": "7.33.5", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.33.5.tgz", + "integrity": "sha512-Xh4dXuusndVQqVz4nEN9xOp0DyzsKxeD2FFJkSPg4arAjDSKPcy6cAc7CaeBPA7kF2wV1fuDlo2p/bNMpVr8yg==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "~0.15.1", - "@microsoft/tsdoc-config": "~0.17.1", - "@rushstack/node-core-library": "5.11.0" + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.1", + "@rushstack/node-core-library": "5.21.0" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/@microsoft/api-extractor/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/@microsoft/api-extractor/node_modules/minimatch": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", - "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", + "node_modules/@microsoft/api-extractor/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "yallist": "^4.0.0" }, "engines": { - "node": "*" + "node": ">=10" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/minimatch": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", + "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@microsoft/api-extractor/node_modules/semver": { @@ -894,37 +1012,51 @@ "node": ">=10" } }, + "node_modules/@microsoft/api-extractor/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@microsoft/tsdoc": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", - "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "dev": true, "license": "MIT" }, "node_modules/@microsoft/tsdoc-config": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.17.1.tgz", - "integrity": "sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.1.tgz", + "integrity": "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "0.15.1", - "ajv": "~8.12.0", + "@microsoft/tsdoc": "0.16.0", + "ajv": "~8.18.0", "jju": "~1.4.0", "resolve": "~1.22.2" } }, "node_modules/@microsoft/tsdoc-config/node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -938,57 +1070,26 @@ "dev": true, "license": "MIT" }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@pkgr/core": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", - "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/unts" + "url": "https://opencollective.com/pkgr" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/plugin-virtual": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz", @@ -1008,9 +1109,9 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", - "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1030,23 +1131,10 @@ } } }, - "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.9.tgz", - "integrity": "sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", "cpu": [ "arm" ], @@ -1058,9 +1146,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.9.tgz", - "integrity": "sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], @@ -1072,9 +1160,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.9.tgz", - "integrity": "sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", "cpu": [ "arm64" ], @@ -1086,9 +1174,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz", - "integrity": "sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], @@ -1100,9 +1188,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.9.tgz", - "integrity": "sha512-2lzjQPJbN5UnHm7bHIUKFMulGTQwdvOkouJDpPysJS+QFBGDJqcfh+CxxtG23Ik/9tEvnebQiylYoazFMAgrYw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", "cpu": [ "arm64" ], @@ -1114,9 +1202,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.9.tgz", - "integrity": "sha512-SLl0hi2Ah2H7xQYd6Qaiu01kFPzQ+hqvdYSoOtHYg/zCIFs6t8sV95kaoqjzjFwuYQLtOI0RZre/Ke0nPaQV+g==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", "cpu": [ "x64" ], @@ -1128,9 +1216,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.9.tgz", - "integrity": "sha512-88I+D3TeKItrw+Y/2ud4Tw0+3CxQ2kLgu3QvrogZ0OfkmX/DEppehus7L3TS2Q4lpB+hYyxhkQiYPJ6Mf5/dPg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", "cpu": [ "arm" ], @@ -1142,9 +1230,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.9.tgz", - "integrity": "sha512-3qyfWljSFHi9zH0KgtEPG4cBXHDFhwD8kwg6xLfHQ0IWuH9crp005GfoUUh/6w9/FWGBwEHg3lxK1iHRN1MFlA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", "cpu": [ "arm" ], @@ -1156,9 +1244,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz", - "integrity": "sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", "cpu": [ "arm64" ], @@ -1170,9 +1258,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz", - "integrity": "sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", "cpu": [ "arm64" ], @@ -1183,10 +1271,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.9.tgz", - "integrity": "sha512-dRAgTfDsn0TE0HI6cmo13hemKpVHOEyeciGtvlBTkpx/F65kTvShtY/EVyZEIfxFkV5JJTuQ9tP5HGBS0hfxIg==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", "cpu": [ "loong64" ], @@ -1197,12 +1285,12 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.9.tgz", - "integrity": "sha512-PHcNOAEhkoMSQtMf+rJofwisZqaU8iQ8EaSps58f5HYll9EAY5BSErCZ8qBDMVbq88h4UxaNPlbrKqfWP8RfJA==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", "cpu": [ - "ppc64" + "loong64" ], "dev": true, "license": "MIT", @@ -1211,12 +1299,12 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.9.tgz", - "integrity": "sha512-Z2i0Uy5G96KBYKjeQFKbbsB54xFOL5/y1P5wNBsbXB8yE+At3oh0DVMjQVzCJRJSfReiB2tX8T6HUFZ2k8iaKg==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", "cpu": [ - "riscv64" + "ppc64" ], "dev": true, "license": "MIT", @@ -1225,12 +1313,12 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.9.tgz", - "integrity": "sha512-U+5SwTMoeYXoDzJX5dhDTxRltSrIax8KWwfaaYcynuJw8mT33W7oOgz0a+AaXtGuvhzTr2tVKh5UO8GVANTxyQ==", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", "cpu": [ - "s390x" + "ppc64" ], "dev": true, "license": "MIT", @@ -1239,12 +1327,12 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz", - "integrity": "sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==", + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", "cpu": [ - "x64" + "riscv64" ], "dev": true, "license": "MIT", @@ -1253,12 +1341,12 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz", - "integrity": "sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==", + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", "cpu": [ - "x64" + "riscv64" ], "dev": true, "license": "MIT", @@ -1267,38 +1355,122 @@ "linux" ] }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz", - "integrity": "sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==", + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", "cpu": [ - "arm64" + "s390x" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ] }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.9.tgz", - "integrity": "sha512-KB48mPtaoHy1AwDNkAJfHXvHp24H0ryZog28spEs0V48l3H1fr4i37tiyHsgKZJnCmvxsbATdZGBpbmxTE3a9w==", + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", "cpu": [ - "ia32" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.9.tgz", - "integrity": "sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ "x64" ], @@ -1310,13 +1482,13 @@ ] }, "node_modules/@rushstack/node-core-library": { - "version": "5.11.0", - "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.11.0.tgz", - "integrity": "sha512-I8+VzG9A0F3nH2rLpPd7hF8F7l5Xb7D+ldrWVZYegXM6CsKkvWc670RlgK3WX8/AseZfXA/vVrh0bpXe2Y2UDQ==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.21.0.tgz", + "integrity": "sha512-LFzN+1lyWROit/P8Md6yxAth7lLYKn37oCKJHirEE2TQB25NDUM7bALf0ar+JAtwFfRCH+D+DGOA7DAzIi2r+g==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "~8.13.0", + "ajv": "~8.18.0", "ajv-draft-04": "~1.0.0", "ajv-formats": "~3.0.1", "fs-extra": "~11.3.0", @@ -1335,16 +1507,16 @@ } }, "node_modules/@rushstack/node-core-library/node_modules/ajv": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.13.0.tgz", - "integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.4.1" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -1373,6 +1545,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@rushstack/node-core-library/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rushstack/node-core-library/node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -1389,10 +1574,25 @@ "node": ">=10" } }, + "node_modules/@rushstack/problem-matcher": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@rushstack/problem-matcher/-/problem-matcher-0.2.1.tgz", + "integrity": "sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@rushstack/rig-package": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.5.3.tgz", - "integrity": "sha512-olzSSjYrvCNxUFZowevC3uz8gvKr3WTpHQ7BkpjtRpA3wK+T0ybep/SRUMfr195gBzJm5gaXw0ZMgjIyHqJUow==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.7.2.tgz", + "integrity": "sha512-9XbFWuqMYcHUso4mnETfhGVUSaADBRj6HUAAEYk50nMPn8WRICmBuCphycQGNB3duIR6EEZX3Xj3SYc2XiP+9A==", "dev": true, "license": "MIT", "dependencies": { @@ -1401,13 +1601,14 @@ } }, "node_modules/@rushstack/terminal": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.15.0.tgz", - "integrity": "sha512-vXQPRQ+vJJn4GVqxkwRe+UGgzNxdV8xuJZY2zem46Y0p3tlahucH9/hPmLGj2i9dQnUBFiRnoM9/KW7PYw8F4Q==", + "version": "0.22.4", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.22.4.tgz", + "integrity": "sha512-fhtLjnXCc/4WleVbVl6aoc7jcWnU6yqjS1S8WoaNREG3ycu/viZ9R/9QM7Y/b4CDvcXoiDyMNIay7JMwBptM3g==", "dev": true, "license": "MIT", "dependencies": { - "@rushstack/node-core-library": "5.11.0", + "@rushstack/node-core-library": "5.21.0", + "@rushstack/problem-matcher": "0.2.1", "supports-color": "~8.1.1" }, "peerDependencies": { @@ -1436,13 +1637,13 @@ } }, "node_modules/@rushstack/ts-command-line": { - "version": "4.23.5", - "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-4.23.5.tgz", - "integrity": "sha512-jg70HfoK44KfSP3MTiL5rxsZH7X1ktX3cZs9Sl8eDu1/LxJSbPsh0MOFRC710lIuYYSgxWjI5AjbCBAl7u3RxA==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.3.4.tgz", + "integrity": "sha512-MLkVKVEN6/2clKTrjN2B2KqKCuPxRwnNsWY7a+FCAq2EMdkj10cM8YgiBSMeGFfzM0mDMzargpHNnNzaBi9Whg==", "dev": true, "license": "MIT", "dependencies": { - "@rushstack/terminal": "0.15.0", + "@rushstack/terminal": "0.22.4", "@types/argparse": "1.0.38", "argparse": "~1.0.9", "string-argv": "~0.3.1" @@ -1459,18 +1660,18 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.0.3.tgz", - "integrity": "sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", - "debug": "^4.4.0", + "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", - "magic-string": "^0.30.15", - "vitefu": "^1.0.4" + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" }, "engines": { "node": "^18.0.0 || ^20.0.0 || >=22" @@ -1499,15 +1700,15 @@ } }, "node_modules/@swc/core": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.5.tgz", - "integrity": "sha512-EVY7zfpehxhTZXOfy508gb3D78ihoGGmvyiTWtlBPjgIaidP1Xw0naHMD78CWiFlZmeDjKXJufGtsEGOnZdmNA==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.24.tgz", + "integrity": "sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.19" + "@swc/types": "^0.1.26" }, "engines": { "node": ">=10" @@ -1517,19 +1718,21 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.11.5", - "@swc/core-darwin-x64": "1.11.5", - "@swc/core-linux-arm-gnueabihf": "1.11.5", - "@swc/core-linux-arm64-gnu": "1.11.5", - "@swc/core-linux-arm64-musl": "1.11.5", - "@swc/core-linux-x64-gnu": "1.11.5", - "@swc/core-linux-x64-musl": "1.11.5", - "@swc/core-win32-arm64-msvc": "1.11.5", - "@swc/core-win32-ia32-msvc": "1.11.5", - "@swc/core-win32-x64-msvc": "1.11.5" + "@swc/core-darwin-arm64": "1.15.24", + "@swc/core-darwin-x64": "1.15.24", + "@swc/core-linux-arm-gnueabihf": "1.15.24", + "@swc/core-linux-arm64-gnu": "1.15.24", + "@swc/core-linux-arm64-musl": "1.15.24", + "@swc/core-linux-ppc64-gnu": "1.15.24", + "@swc/core-linux-s390x-gnu": "1.15.24", + "@swc/core-linux-x64-gnu": "1.15.24", + "@swc/core-linux-x64-musl": "1.15.24", + "@swc/core-win32-arm64-msvc": "1.15.24", + "@swc/core-win32-ia32-msvc": "1.15.24", + "@swc/core-win32-x64-msvc": "1.15.24" }, "peerDependencies": { - "@swc/helpers": "*" + "@swc/helpers": ">=0.5.17" }, "peerDependenciesMeta": { "@swc/helpers": { @@ -1538,9 +1741,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.5.tgz", - "integrity": "sha512-GEd1hzEx0mSGkJYMFMGLnrGgjL2rOsOsuYWyjyiA3WLmhD7o+n/EWBDo6mzD/9aeF8dzSPC0TnW216gJbvrNzA==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.24.tgz", + "integrity": "sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==", "cpu": [ "arm64" ], @@ -1555,9 +1758,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.5.tgz", - "integrity": "sha512-toz04z9wAClVvQSEY3xzrgyyeWBAfMWcKG4K0ugNvO56h/wczi2ZHRlnAXZW1tghKBk3z6MXqa/srfXgNhffKw==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.24.tgz", + "integrity": "sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==", "cpu": [ "x64" ], @@ -1572,9 +1775,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.5.tgz", - "integrity": "sha512-5SjmKxXdwbBpsYGTpgeXOXMIjS563/ntRGn8Zc12H/c4VfPrRLGhgbJ/48z2XVFyBLcw7BCHZyFuVX1+ZI3W0Q==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.24.tgz", + "integrity": "sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==", "cpu": [ "arm" ], @@ -1589,9 +1792,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.5.tgz", - "integrity": "sha512-pydIlInHRzRIwB0NHblz3Dx58H/bsi0I5F2deLf9iOmwPNuOGcEEZF1Qatc7YIjP5DFbXK+Dcz+pMUZb2cc2MQ==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.24.tgz", + "integrity": "sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==", "cpu": [ "arm64" ], @@ -1606,9 +1809,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.5.tgz", - "integrity": "sha512-LhBHKjkZq5tJF1Lh0NJFpx7ROnCWLckrlIAIdSt9XfOV+zuEXJQOj+NFcM1eNk17GFfFyUMOZyGZxzYq5dveEQ==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.24.tgz", + "integrity": "sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==", "cpu": [ "arm64" ], @@ -1622,10 +1825,44 @@ "node": ">=10" } }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.24.tgz", + "integrity": "sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.24.tgz", + "integrity": "sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.5.tgz", - "integrity": "sha512-dCi4xkxXlsk5sQYb3i413Cfh7+wMJeBYTvBZTD5xh+/DgRtIcIJLYJ2tNjWC4/C2i5fj+Ze9bKNSdd8weRWZ3A==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.24.tgz", + "integrity": "sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==", "cpu": [ "x64" ], @@ -1640,9 +1877,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.5.tgz", - "integrity": "sha512-K0AC4TreM5Oo/tXNXnE/Gf5+5y/HwUdd7xvUjOpZddcX/RlsbYOKWLgOtA3fdFIuta7XC+vrGKmIhm5l70DSVQ==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.24.tgz", + "integrity": "sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==", "cpu": [ "x64" ], @@ -1657,9 +1894,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.5.tgz", - "integrity": "sha512-wzum8sYUsvPY7kgUfuqVYTgIPYmBC8KPksoNM1fz5UfhudU0ciQuYvUBD47GIGOevaoxhLkjPH4CB95vh1mJ9w==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.24.tgz", + "integrity": "sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==", "cpu": [ "arm64" ], @@ -1674,9 +1911,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.5.tgz", - "integrity": "sha512-lco7mw0TPRTpVPR6NwggJpjdUkAboGRkLrDHjIsUaR+Y5+0m5FMMkHOMxWXAbrBS5c4ph7QErp4Lma4r9Mn5og==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.24.tgz", + "integrity": "sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==", "cpu": [ "ia32" ], @@ -1691,9 +1928,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.5.tgz", - "integrity": "sha512-E+DApLSC6JRK8VkDa4bNsBdD7Qoomx1HvKVZpOXl9v94hUZI5GMExl4vU5isvb+hPWL7rZ0NeI7ITnVLgLJRbA==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.24.tgz", + "integrity": "sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==", "cpu": [ "x64" ], @@ -1715,19 +1952,26 @@ "license": "Apache-2.0" }, "node_modules/@swc/types": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.19.tgz", - "integrity": "sha512-WkAZaAfj44kh/UFdAQcrMP1I0nwRqpt27u+08LMBYMqmQfwwMofYoMh/48NGkMMRfC4ynpfwRbJuu8ErfNloeA==", + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" } }, + "node_modules/@swc/wasm": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.24.tgz", + "integrity": "sha512-vFjzOE8dhJcfeTbM4+HO9Qy58IINV0ysqStAgw81uds+KqCeUDM9huN+SZ5lWZ6U+5nf8VcZoEw5N81xMtAidg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@tsconfig/svelte": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.4.tgz", - "integrity": "sha512-BV9NplVgLmSi4mwKzD8BD/NQ8erOY/nUE/GpgWe2ckx+wIQF5RyRirn/QsSSCPeulVpc3RA/iJt6DpfTIZps0Q==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", "dev": true, "license": "MIT" }, @@ -1739,9 +1983,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -1760,21 +2004,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.25.0.tgz", - "integrity": "sha512-VM7bpzAe7JO/BFf40pIT1lJqS/z1F8OaSsUB3rpFJucQA4cOSuH2RVVVkFULN+En0Djgr29/jb4EQnedUo95KA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.25.0", - "@typescript-eslint/type-utils": "8.25.0", - "@typescript-eslint/utils": "8.25.0", - "@typescript-eslint/visitor-keys": "8.25.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1784,24 +2027,57 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.25.0.tgz", - "integrity": "sha512-4gbs64bnbSzu4FpgMiQ1A+D+urxkoJk/kqlDJ2W//5SygaEiAP2B4GoS7TEdxgwol2el03gckFV9lJ4QOMiiHg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.25.0", - "@typescript-eslint/types": "8.25.0", - "@typescript-eslint/typescript-estree": "8.25.0", - "@typescript-eslint/visitor-keys": "8.25.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1811,19 +2087,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.25.0.tgz", - "integrity": "sha512-6PPeiKIGbgStEyt4NNXa2ru5pMzQ8OYKO1hX1z53HMomrmiSB+R5FmChgQAP1ro8jMtNawz+TRQo/cSXrauTpg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.25.0", - "@typescript-eslint/visitor-keys": "8.25.0" + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1833,17 +2108,35 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.25.0.tgz", - "integrity": "sha512-d77dHgHWnxmXOPJuDWO4FDWADmGQkN5+tt6SFRZz/RtCWl4pHgFl3+WdYCn16+3teG09DY6XtEpf3gGD0a186g==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.25.0", - "@typescript-eslint/utils": "8.25.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1853,14 +2146,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.25.0.tgz", - "integrity": "sha512-+vUe0Zb4tkNgznQwicsvLUJgZIRs6ITeWSCclX1q85pR1iOiaj+4uZJIUp//Z27QWu5Cseiw3O3AR8hVpax7Aw==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", "dev": true, "license": "MIT", "engines": { @@ -1872,20 +2165,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.25.0.tgz", - "integrity": "sha512-ZPaiAKEZ6Blt/TPAx5Ot0EIB/yGtLI2EsGoY6F7XKklfMxYQyvtL+gT/UCqkMzO0BVFHLDlzvFqQzurYahxv9Q==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.25.0", - "@typescript-eslint/visitor-keys": "8.25.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1895,20 +2189,59 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.25.0.tgz", - "integrity": "sha512-syqRbrEv0J1wywiLsK60XzHnQe/kRViI3zwFALrNEgnntn1l24Ra2KvOAWwWbWZ1lBZxZljPDGOq967dsl6fkA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.25.0", - "@typescript-eslint/types": "8.25.0", - "@typescript-eslint/typescript-estree": "8.25.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1918,19 +2251,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.25.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.25.0.tgz", - "integrity": "sha512-kCYXKAum9CecGVHGij7muybDfTS2sD3t0L4bJsEZLkyrXUImiCTq1M3LG2SRtOhiHFwMR9wAFplpT6XHYjTkwQ==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.25.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1941,70 +2274,191 @@ } }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-2.1.9.tgz", + "integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "fflate": "^0.8.2", + "flatted": "^3.3.1", + "pathe": "^1.1.2", + "sirv": "^3.0.0", + "tinyglobby": "^0.2.10", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "2.1.9" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.11.tgz", - "integrity": "sha512-lN2C1+ByfW9/JRPpqScuZt/4OrUUse57GLI6TbLgTIqBVemdl1wNcZ1qYGEo2+Gw8coYLgCy7SuKqn6IrQcQgg==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", "dev": true, "license": "MIT", "dependencies": { - "@volar/source-map": "2.4.11" + "@volar/source-map": "2.4.28" } }, "node_modules/@volar/source-map": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.11.tgz", - "integrity": "sha512-ZQpmafIGvaZMn/8iuvCFGrW3smeqkq/IIh9F1SdSx9aUl0J4Iurzd6/FhmjNO5g2ejF3rT45dKskgXWiofqlZQ==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", "dev": true, "license": "MIT" }, "node_modules/@volar/typescript": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.11.tgz", - "integrity": "sha512-2DT+Tdh88Spp5PyPbqhyoYavYCPDsqbHLFwcUI9K1NlY1YgUJvujGdrqUp0zWxnW7KWNTr3xSpMuv2WnaTKDAw==", + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", "dev": true, "license": "MIT", "dependencies": { - "@volar/language-core": "2.4.11", + "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "node_modules/@vue/compiler-core": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", - "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.25.3", - "@vue/shared": "3.5.13", - "entities": "^4.5.0", + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", "estree-walker": "^2.0.2", - "source-map-js": "^1.2.0" + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", - "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", "dev": true, "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.13", - "@vue/shared": "3.5.13" + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" } }, "node_modules/@vue/compiler-vue2": { @@ -2043,17 +2497,43 @@ } } }, + "node_modules/@vue/language-core/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vue/language-core/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@vue/shared": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", - "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", "dev": true, "license": "MIT" }, "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -2083,10 +2563,20 @@ "acorn": ">=8.9.0" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2119,9 +2609,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -2182,6 +2672,23 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2200,26 +2707,38 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" }, "engines": { - "node": ">=8" + "node": ">= 0.4" } }, "node_modules/callsites": { @@ -2232,6 +2751,23 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2249,6 +2785,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2295,6 +2841,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/compare-versions": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", @@ -2310,9 +2869,9 @@ "license": "MIT" }, "node_modules/confbox": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.1.tgz", - "integrity": "sha512-hkT3yDPFbs95mNCy1+7qNKC6Pro+/ibzYxtM2iqEigpf0sVw+bg4Zh9/snjsBcf990vfIsg5+1U7VyiyBb3etg==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "dev": true, "license": "MIT" }, @@ -2344,6 +2903,41 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -2352,9 +2946,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -2369,6 +2963,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2386,10 +2997,45 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -2399,10 +3045,66 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2413,31 +3115,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/escape-string-regexp": { @@ -2454,32 +3157,32 @@ } }, "node_modules/eslint": { - "version": "9.21.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.21.0.tgz", - "integrity": "sha512-KjeihdFqTPhOMXTt7StsDxriV4n66ueuF/jfPNC3j/lduHwr/ijDwJMsF+wyMJethgiKi5wniIE243vi07d3pg==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.2", - "@eslint/core": "^0.12.0", - "@eslint/eslintrc": "^3.3.0", - "@eslint/js": "9.21.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -2491,7 +3194,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -2513,26 +3216,10 @@ } } }, - "node_modules/eslint-compat-utils": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.6.4.tgz", - "integrity": "sha512-/u+GQt8NMfXO8w17QendT4gvO5acfxQsAKirAt0LVxDnr2N8YLCVbregaNc/Yhp7NM128DwCaRvr8PLDfeNkQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, "node_modules/eslint-config-prettier": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", - "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", "bin": { @@ -2543,14 +3230,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", - "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", "dev": true, "license": "MIT", "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.9.1" + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -2561,7 +3248,7 @@ "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", - "eslint-config-prettier": "*", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "peerDependenciesMeta": { @@ -2574,31 +3261,31 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.0.2.tgz", - "integrity": "sha512-+0QglmWNryvXXxRQKzLF3i+AreTsueCw7PBb0nGVBq+F9HoYqAjQeJ/9N6vFAtjMjK3wgsETrLVyBKPdeufN6Q==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-3.17.0.tgz", + "integrity": "sha512-sF6wgd5FLS2P8CCaOy2HdYYYEcZ6TwL251dLHUkNmtLnWECk1Dwc+j6VeulmmnFxr7Xs0WNtjweOA+bJ0PnaFw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.1", + "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", - "eslint-compat-utils": "^0.6.4", "esutils": "^2.0.3", - "known-css-properties": "^0.35.0", + "globals": "^16.0.0", + "known-css-properties": "^0.37.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", - "svelte-eslint-parser": "^1.0.0" + "svelte-eslint-parser": "^1.4.0" }, "engines": { - "node": "^18.20.4 || ^20.18.0 || >=22.10.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://github.com/sponsors/ota-meshi" }, "peerDependencies": { - "eslint": "^8.57.1 || ^9.0.0", + "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { @@ -2608,9 +3295,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", - "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2637,21 +3324,10 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2661,19 +3337,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -2682,15 +3345,15 @@ "license": "MIT" }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2700,9 +3363,9 @@ } }, "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2713,9 +3376,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2726,9 +3389,9 @@ } }, "node_modules/esrap": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.5.tgz", - "integrity": "sha512-CjNMjkBWWZeHn+VX+gS8YvFwJ5+NDhg8aWZBSFJPR8qQduDNjbJodA2WcwCm7uQa5Rjqj+nZvVmceg1RbHFB9g==", + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.9.tgz", + "integrity": "sha512-3OMlcd0a03UGuZpPeUC1HxR3nA23l+HEyCiZw3b3FumJIN9KphoGzDJKMXI1S72jVS1dsenDyQC0kJlO1U9E1g==", "dev": true, "license": "MIT", "dependencies": { @@ -2775,10 +3438,20 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.1.tgz", - "integrity": "sha512-Smf0iQtkQVJLaph8r/qS8C8SWfQkaq9Q/dFcD44MLbJj6DNhlWefVuaS21SjfqOsBbjVlKtbCj6L9ekXK6EZUg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "dev": true, "license": "MIT" }, @@ -2796,36 +3469,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2841,9 +3484,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "dev": true, "funding": [ { @@ -2857,16 +3500,31 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2880,19 +3538,6 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2925,16 +3570,33 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs-extra": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", - "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", "dev": true, "license": "MIT", "dependencies": { @@ -2971,6 +3633,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2985,9 +3686,9 @@ } }, "node_modules/globals": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.0.0.tgz", - "integrity": "sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", "engines": { @@ -2997,19 +3698,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, - "license": "ISC" + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "MIT" + "license": "ISC" }, "node_modules/has-flag": { "version": "4.0.0", @@ -3021,6 +3728,35 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -3044,6 +3780,60 @@ "he": "bin/he" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3130,15 +3920,12 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } + "license": "MIT" }, "node_modules/is-reference": { "version": "3.0.3", @@ -3165,9 +3952,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3177,6 +3964,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3199,9 +4027,9 @@ "license": "MIT" }, "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, "license": "MIT", "dependencies": { @@ -3232,9 +4060,9 @@ } }, "node_modules/known-css-properties": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.35.0.tgz", - "integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==", + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", "dev": true, "license": "MIT" }, @@ -3270,15 +4098,15 @@ } }, "node_modules/local-pkg": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.1.tgz", - "integrity": "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", "dev": true, "license": "MIT", "dependencies": { "mlly": "^1.7.4", - "pkg-types": "^2.0.1", - "quansync": "^0.2.8" + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" }, "engines": { "node": ">=14" @@ -3311,9 +4139,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -3324,80 +4152,87 @@ "dev": true, "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } + "license": "ISC" }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, "license": "MIT", "engines": { - "node": ">= 8" + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "mime-db": "1.52.0" }, "engines": { - "node": ">=8.6" + "node": ">= 0.6" } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "*" } }, "node_modules/mlly": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz", - "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", "dev": true, "license": "MIT", "dependencies": { - "acorn": "^8.14.0", - "pathe": "^2.0.1", - "pkg-types": "^1.3.0", - "ufo": "^1.5.4" + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" } }, "node_modules/mlly/node_modules/confbox": { @@ -3407,6 +4242,13 @@ "dev": true, "license": "MIT" }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/mlly/node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -3429,6 +4271,16 @@ "node": ">=4" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3444,9 +4296,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -3469,6 +4321,13 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3532,6 +4391,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -3567,12 +4439,22 @@ "license": "MIT" }, "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", "dev": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3581,34 +4463,41 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/pkg-types": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.1.0.tgz", - "integrity": "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", "dev": true, "license": "MIT", "dependencies": { - "confbox": "^0.2.1", - "exsolve": "^1.0.1", + "confbox": "^0.2.2", + "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -3626,7 +4515,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3665,9 +4554,9 @@ } }, "node_modules/postcss-load-config/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, "license": "ISC", "engines": { @@ -3729,9 +4618,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", "dependencies": { @@ -3753,9 +4642,9 @@ } }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -3769,9 +4658,9 @@ } }, "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", "dev": true, "license": "MIT", "dependencies": { @@ -3782,9 +4671,9 @@ } }, "node_modules/prettier-plugin-svelte": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.3.3.tgz", - "integrity": "sha512-yViK9zqQ+H2qZD1w/bH7W8i+bVfKrD8GIFjkFe4Thl6kCT9SlAsXVNmt3jCvQOCsnOhcvYgsoVlRV/Eu6x5nNw==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.1.tgz", + "integrity": "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3803,9 +4692,9 @@ } }, "node_modules/quansync": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.8.tgz", - "integrity": "sha512-4+saucphJMazjt7iOM27mbFCk+D9dd/zmgMDCzRZ8MEoBfYp7lAvoN38et/phRQF6wOPMy/OROBGgoWeSKyluA==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", "dev": true, "funding": [ { @@ -3819,27 +4708,6 @@ ], "license": "MIT" }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3865,13 +4733,13 @@ } }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -3895,25 +4763,14 @@ "node": ">=4" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, "node_modules/rollup": { - "version": "4.34.9", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.9.tgz", - "integrity": "sha512-nF5XYqWWp9hx/LrpC8sZvvvmq0TeTjQgaZHYmAgwysT9nh8sWnZhBnM8ZyVbbJFIQBLwHDNoMqsBZBbUo4U8sQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -3923,51 +4780,40 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.34.9", - "@rollup/rollup-android-arm64": "4.34.9", - "@rollup/rollup-darwin-arm64": "4.34.9", - "@rollup/rollup-darwin-x64": "4.34.9", - "@rollup/rollup-freebsd-arm64": "4.34.9", - "@rollup/rollup-freebsd-x64": "4.34.9", - "@rollup/rollup-linux-arm-gnueabihf": "4.34.9", - "@rollup/rollup-linux-arm-musleabihf": "4.34.9", - "@rollup/rollup-linux-arm64-gnu": "4.34.9", - "@rollup/rollup-linux-arm64-musl": "4.34.9", - "@rollup/rollup-linux-loongarch64-gnu": "4.34.9", - "@rollup/rollup-linux-powerpc64le-gnu": "4.34.9", - "@rollup/rollup-linux-riscv64-gnu": "4.34.9", - "@rollup/rollup-linux-s390x-gnu": "4.34.9", - "@rollup/rollup-linux-x64-gnu": "4.34.9", - "@rollup/rollup-linux-x64-musl": "4.34.9", - "@rollup/rollup-win32-arm64-msvc": "4.34.9", - "@rollup/rollup-win32-ia32-msvc": "4.34.9", - "@rollup/rollup-win32-x64-msvc": "4.34.9", + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } + "license": "MIT" }, "node_modules/sade": { "version": "1.8.1", @@ -3982,10 +4828,30 @@ "node": ">=6" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -4018,6 +4884,28 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -4045,6 +4933,20 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -4121,9 +5023,9 @@ } }, "node_modules/svelte-check": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.1.4.tgz", - "integrity": "sha512-v0j7yLbT29MezzaQJPEDwksybTE2Ups9rUxEXy92T06TiA0cbqcO8wAOwNUVkFW6B0hsYHA+oAX3BS8b/2oHtw==", + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.6.tgz", + "integrity": "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==", "dev": true, "license": "MIT", "dependencies": { @@ -4144,112 +5046,194 @@ "typescript": ">=5.0.0" } }, - "node_modules/svelte-check/node_modules/fdir": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz", - "integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==", + "node_modules/svelte-eslint-parser": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.6.0.tgz", + "integrity": "sha512-qoB1ehychT6OxEtQAqc/guSqLS20SlA53Uijl7x375s8nlUT0lb9ol/gzraEEatQwsyPTJo87s2CmKL9Xab+Uw==", "dev": true, "license": "MIT", + "dependencies": { + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.0.0", + "espree": "^10.0.0", + "postcss": "^8.4.49", + "postcss-scss": "^4.0.9", + "postcss-selector-parser": "^7.0.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0", + "pnpm": "10.30.3" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, "peerDependencies": { - "picomatch": "^3 || ^4" + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { - "picomatch": { + "svelte": { "optional": true } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true, + "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://opencollective.com/eslint" } }, - "node_modules/svelte-eslint-parser": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-1.0.0.tgz", - "integrity": "sha512-diZzpeeFhAxormeIhmRS4vXx98GG6T7Dq5y1a6qffqs/5MBrBqqDg8bj88iEohp6bvhU4MIABJmOTa0gXWcbSQ==", + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.0.0", - "espree": "^10.0.0", - "postcss": "^8.4.49", - "postcss-scss": "^4.0.9", - "postcss-selector-parser": "^7.0.0" + "@pkgr/core": "^0.2.9" }, "engines": { - "node": "^18.20.4 || ^20.18.0 || >=22.10.0" + "node": "^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/ota-meshi" - }, - "peerDependencies": { - "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "svelte": { - "optional": true - } + "url": "https://opencollective.com/synckit" } }, - "node_modules/svelte-eslint-parser/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12.0.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/synckit": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", - "integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==", + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.1.0", - "tslib": "^2.6.2" + "tldts-core": "^6.1.86" }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" }, - "funding": { - "url": "https://opencollective.com/unts" + "engines": { + "node": ">=16" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", "dependencies": { - "is-number": "^7.0.0" + "punycode": "^2.3.1" }, "engines": { - "node": ">=8.0" + "node": ">=18" } }, "node_modules/ts-api-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", - "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -4294,9 +5278,9 @@ } }, "node_modules/ua-parser-js": { - "version": "1.0.40", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz", - "integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==", + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", "dev": true, "funding": [ { @@ -4321,9 +5305,9 @@ } }, "node_modules/ufo": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz", - "integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "dev": true, "license": "MIT" }, @@ -4369,15 +5353,18 @@ } }, "node_modules/vite": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", - "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", "postcss": "^8.5.3", - "rollup": "^4.30.1" + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -4440,149 +5427,1377 @@ } } }, - "node_modules/vite-plugin-dts": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-4.5.3.tgz", - "integrity": "sha512-P64VnD00dR+e8S26ESoFELqc17+w7pKkwlBpgXteOljFyT0zDwD8hH4zXp49M/kciy//7ZbVXIwQCekBJjfWzA==", + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", "dev": true, "license": "MIT", "dependencies": { - "@microsoft/api-extractor": "^7.50.1", - "@rollup/pluginutils": "^5.1.4", - "@volar/typescript": "^2.4.11", - "@vue/language-core": "2.2.0", - "compare-versions": "^6.1.1", - "debug": "^4.4.0", - "kolorist": "^1.8.0", - "local-pkg": "^1.0.0", - "magic-string": "^0.30.17" + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" }, - "peerDependencies": { - "typescript": "*", - "vite": "*" + "bin": { + "vite-node": "vite-node.mjs" }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/vite-plugin-top-level-await": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.5.0.tgz", - "integrity": "sha512-r/DtuvHrSqUVk23XpG2cl8gjt1aATMG5cjExXL1BUTcSNab6CzkcPua9BPEc9fuTP5UpwClCxUe3+dNGL0yrgQ==", + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@rollup/plugin-virtual": "^3.0.2", - "@swc/core": "^1.10.16", - "uuid": "^10.0.0" - }, - "peerDependencies": { - "vite": ">=2.8" + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" } }, - "node_modules/vite-plugin-wasm": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.4.1.tgz", - "integrity": "sha512-ja3nSo2UCkVeitltJGkS3pfQHAanHv/DqGatdI39ja6McgABlpsZ5hVgl6wuR8Qx5etY3T5qgDQhOWzc5RReZA==", + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "peerDependencies": { - "vite": "^2 || ^3 || ^4 || ^5 || ^6" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" } }, - "node_modules/vitefu": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.6.tgz", - "integrity": "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==", + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*" + "optional": true, + "os": [ + "android" ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } + "engines": { + "node": ">=12" } }, - "node_modules/vscode-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", - "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 8" + "node": ">=12" } }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=0.10.0" + "node": ">=12" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC" + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } }, - "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", + "license": "MIT", "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 14" + "node": ">=12" } }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=12" } }, - "node_modules/zimmerframe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", - "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-plugin-dts": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-4.5.4.tgz", + "integrity": "sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/api-extractor": "^7.50.1", + "@rollup/pluginutils": "^5.1.4", + "@volar/typescript": "^2.4.11", + "@vue/language-core": "2.2.0", + "compare-versions": "^6.1.1", + "debug": "^4.4.0", + "kolorist": "^1.8.0", + "local-pkg": "^1.0.0", + "magic-string": "^0.30.17" + }, + "peerDependencies": { + "typescript": "*", + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vite-plugin-top-level-await": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.6.0.tgz", + "integrity": "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-virtual": "^3.0.2", + "@swc/core": "^1.12.14", + "@swc/wasm": "^1.12.14", + "uuid": "10.0.0" + }, + "peerDependencies": { + "vite": ">=2.8" + } + }, + "node_modules/vite-plugin-wasm": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz", + "integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "dev": true, "license": "MIT" } diff --git a/web-client/iron-remote-desktop/package.json b/web-client/iron-remote-desktop/package.json index bb55b31970..9aae873550 100644 --- a/web-client/iron-remote-desktop/package.json +++ b/web-client/iron-remote-desktop/package.json @@ -17,6 +17,9 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", "check": "svelte-check --tsconfig ./tsconfig.json", "check:dist": "tsc ./dist/index.d.ts --noEmit", "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", @@ -48,6 +51,9 @@ "vite-plugin-dts": "^4.5.0", "vite-plugin-top-level-await": "^1.2.2", "vite-plugin-wasm": "^3.1.0", + "vitest": "^2.1.8", + "@vitest/ui": "^2.1.8", + "jsdom": "^25.0.1", "ua-parser-js": "^1.0.33" } } diff --git a/web-client/iron-remote-desktop/public/CHANGELOG.md b/web-client/iron-remote-desktop/public/CHANGELOG.md new file mode 100644 index 0000000000..19cc182192 --- /dev/null +++ b/web-client/iron-remote-desktop/public/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [0.11.0] - 2026-05-26 + +### Features + +- Expose granular RDCleanPath error details ([#1117](https://github.com/Devolutions/IronRDP/issues/1117)) ([2911124e8f](https://github.com/Devolutions/IronRDP/commit/2911124e8fe6160bc8ba03a574b67077e6d2cca9)) + + Surface HTTP status, WSA, and TLS alert codes from RDCleanPath + errors so consumers can distinguish specific network failures + (e.g. `WSAEACCES`/10013) instead of a generic message. + +- Clipboard file transfer API surface ([#1166](https://github.com/Devolutions/IronRDP/issues/1166)) ([c98a8fb774](https://github.com/Devolutions/IronRDP/commit/c98a8fb7741986e9afef00cb5615250c963a7fa9)) + + Backend-agnostic API for clipboard file upload and download, + consumed by backends that implement CLIPRDR file transfer. + +### Bug Fixes + +- Disable clipboard polling loop on Firefox v127+ ([#1162](https://github.com/Devolutions/IronRDP/issues/1162)) ([9a1ac3092e](https://github.com/Devolutions/IronRDP/commit/9a1ac3092ee3eac3e81823349d8e027065f5b8f8)) + +- Release mouse and keyboard state on focus loss to resolve Firefox stuck right-click ([#1297](https://github.com/Devolutions/IronRDP/issues/1297)) ([c56ea16d05](https://github.com/Devolutions/IronRDP/commit/c56ea16d05a88109815906b6d2501cfdae4c07c4)) + + `mouseOut()` and a new `focusLost()` handler release pressed + buttons and keys when the canvas loses focus (mouseleave, window + `blur`, document `visibilitychange`). `mouseIn()` reconciles + tracked server-side button state against `event.buttons` on + re-entry. + +- Include Meta keys in WebKit scancode dispatch ([#1304](https://github.com/Devolutions/IronRDP/issues/1304)) ([0bbffcd0ec](https://github.com/Devolutions/IronRDP/commit/0bbffcd0ec54eb9a14950db5f65f9a164dabc05d)) diff --git a/web-client/iron-remote-desktop/public/package.json b/web-client/iron-remote-desktop/public/package.json index cd9889f38e..25c7e428fa 100644 --- a/web-client/iron-remote-desktop/public/package.json +++ b/web-client/iron-remote-desktop/public/package.json @@ -10,7 +10,16 @@ "Alexandr Yusuk" ], "description": "Backend-agnostic Web Component for remote desktop protocols", - "version": "0.7.0", + "version": "0.11.0", + "repository": { + "type": "git", + "url": "git+https://github.com/Devolutions/IronRDP.git" + }, + "homepage": "https://github.com/Devolutions/IronRDP", + "bugs": { + "url": "https://github.com/Devolutions/IronRDP/issues" + }, + "license": "MIT OR Apache-2.0", "main": "iron-remote-desktop.js", "types": "index.d.ts", "files": [ diff --git a/web-client/iron-remote-desktop/src/enums/ClipboardApiSupported.ts b/web-client/iron-remote-desktop/src/enums/ClipboardApiSupported.ts new file mode 100644 index 0000000000..7ba9b9195d --- /dev/null +++ b/web-client/iron-remote-desktop/src/enums/ClipboardApiSupported.ts @@ -0,0 +1,10 @@ +export enum ClipboardApiSupported { + // Full clipboard API support (read and write text and images) + Full, + // Text-only support (Firefox v125-v126) + TextOnly, + // Text-only support, but only writing data received from the server (Firefox < v125) + TextOnlyServerOnly, + // Clipboard API is not supported at all + None, +} diff --git a/web-client/iron-remote-desktop/src/enums/SessionEventType.ts b/web-client/iron-remote-desktop/src/enums/SessionEventType.ts deleted file mode 100644 index 07418e2aa4..0000000000 --- a/web-client/iron-remote-desktop/src/enums/SessionEventType.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum SessionEventType { - STARTED, - TERMINATED, - ERROR, -} diff --git a/web-client/iron-remote-desktop/src/enums/SpecialCombination.ts b/web-client/iron-remote-desktop/src/enums/SpecialCombination.ts index e335f94bf0..c2d74e2fb3 100644 --- a/web-client/iron-remote-desktop/src/enums/SpecialCombination.ts +++ b/web-client/iron-remote-desktop/src/enums/SpecialCombination.ts @@ -1,4 +1,6 @@ export enum SpecialCombination { CTRL_ALT_DEL, META, + CTRL_C, + CTRL_V, } diff --git a/web-client/iron-remote-desktop/src/interfaces/DeviceEvent.ts b/web-client/iron-remote-desktop/src/interfaces/DeviceEvent.ts index cd1f7ce522..5805b57da8 100644 --- a/web-client/iron-remote-desktop/src/interfaces/DeviceEvent.ts +++ b/web-client/iron-remote-desktop/src/interfaces/DeviceEvent.ts @@ -1 +1,7 @@ +export enum RotationUnit { + Pixel = 0, + Line = 1, + Page = 2, +} + export type DeviceEvent = unknown; diff --git a/web-client/iron-remote-desktop/src/interfaces/session-event.ts b/web-client/iron-remote-desktop/src/interfaces/Error.ts similarity index 51% rename from web-client/iron-remote-desktop/src/interfaces/session-event.ts rename to web-client/iron-remote-desktop/src/interfaces/Error.ts index e79aebef52..849e6817c3 100644 --- a/web-client/iron-remote-desktop/src/interfaces/session-event.ts +++ b/web-client/iron-remote-desktop/src/interfaces/Error.ts @@ -1,6 +1,4 @@ -import type { SessionEventType } from '../enums/SessionEventType'; - -export enum IronErrorKind { +export enum IronErrorKind { General = 0, WrongPassword = 1, LogonFailure = 2, @@ -10,12 +8,14 @@ export enum IronErrorKind { NegotiationFailure = 6, } +export interface RDCleanPathDetails { + readonly httpStatusCode?: number; + readonly wsaErrorCode?: number; + readonly tlsAlertCode?: number; +} + export interface IronError { backtrace: () => string; kind: () => IronErrorKind; -} - -export interface SessionEvent { - type: SessionEventType; - data: IronError | string; + rdcleanpathDetails: () => RDCleanPathDetails | undefined; } diff --git a/web-client/iron-remote-desktop/src/interfaces/FileTransferProvider.ts b/web-client/iron-remote-desktop/src/interfaces/FileTransferProvider.ts new file mode 100644 index 0000000000..95a3338a30 --- /dev/null +++ b/web-client/iron-remote-desktop/src/interfaces/FileTransferProvider.ts @@ -0,0 +1,26 @@ +import type { Extension } from './Extension'; +import type { Session } from './Session'; + +/** + * Protocol-agnostic interface for file transfer providers. + * + * Implementations live in protocol-specific packages (e.g., `RdpFileTransferProvider` + * in `iron-remote-desktop-rdp`) and are injected into the web component via + * `enableFileTransfer()`. + */ +export interface FileTransferProvider { + /** Extensions to register on the SessionBuilder before connect(). */ + getBuilderExtensions(): Extension[]; + + /** Called after connect() with the live session. */ + setSession(session: Session): void; + + /** Called when an upload begins (use for monitoring suppression). */ + onUploadStarted?: () => void; + + /** Called when an upload ends or is abandoned. */ + onUploadFinished?: () => void; + + /** Clean up resources. */ + dispose(): void; +} diff --git a/web-client/iron-remote-desktop/src/interfaces/NewSessionInfo.ts b/web-client/iron-remote-desktop/src/interfaces/NewSessionInfo.ts index add2ed7457..afcd59cbbc 100644 --- a/web-client/iron-remote-desktop/src/interfaces/NewSessionInfo.ts +++ b/web-client/iron-remote-desktop/src/interfaces/NewSessionInfo.ts @@ -1,7 +1,9 @@ import type { DesktopSize } from './DesktopSize'; +import type { SessionTerminationInfo } from './SessionTerminationInfo'; export interface NewSessionInfo { sessionId: number; websocketPort: number; initialDesktopSize: DesktopSize; + run: () => Promise; } diff --git a/web-client/iron-remote-desktop/src/interfaces/RemoteDesktopModule.ts b/web-client/iron-remote-desktop/src/interfaces/RemoteDesktopModule.ts index 7129f992aa..7925e41a27 100644 --- a/web-client/iron-remote-desktop/src/interfaces/RemoteDesktopModule.ts +++ b/web-client/iron-remote-desktop/src/interfaces/RemoteDesktopModule.ts @@ -3,6 +3,7 @@ import type { DeviceEvent } from './DeviceEvent'; import type { InputTransaction } from './InputTransaction'; import type { SessionBuilder } from './SessionBuilder'; import type { ClipboardData } from './ClipboardData'; +import type { RotationUnit } from './DeviceEvent.ts'; export interface RemoteDesktopModule { DesktopSize: { new (width: number, height: number): DesktopSize }; @@ -13,7 +14,7 @@ export interface RemoteDesktopModule { mouseButtonPressed(button: number): DeviceEvent; mouseButtonReleased(button: number): DeviceEvent; mouseMove(x: number, y: number): DeviceEvent; - wheelRotations(vertical: boolean, rotationUnits: number): DeviceEvent; + wheelRotations(vertical: boolean, rotation_amount: number, rotation_unit: RotationUnit): DeviceEvent; keyPressed(scancode: number): DeviceEvent; keyReleased(scancode: number): DeviceEvent; unicodePressed(unicode: string): DeviceEvent; diff --git a/web-client/iron-remote-desktop/src/interfaces/Session.ts b/web-client/iron-remote-desktop/src/interfaces/Session.ts index 514d0aac18..e96c6dba3f 100644 --- a/web-client/iron-remote-desktop/src/interfaces/Session.ts +++ b/web-client/iron-remote-desktop/src/interfaces/Session.ts @@ -2,6 +2,7 @@ import type { InputTransaction } from './InputTransaction'; import type { DesktopSize } from './DesktopSize'; import type { SessionTerminationInfo } from './SessionTerminationInfo'; import type { ClipboardData } from './ClipboardData'; +import type { Extension } from './Extension'; export interface Session { run(): Promise; @@ -9,7 +10,6 @@ export interface Session { applyInputs(transaction: InputTransaction): void; releaseAllInputs(): void; synchronizeLockKeys(scrollLock: boolean, numLock: boolean, capsLock: boolean, kanaLock: boolean): void; - invokeExtension(value: unknown): unknown; shutdown(): void; onClipboardPaste(data: ClipboardData): Promise; resize( @@ -20,4 +20,13 @@ export interface Session { physicalHeight?: number | null, ): void; supportsUnicodeKeyboardShortcuts(): boolean; + + /** + * Invoke a protocol-specific extension at runtime. + * + * File transfer operations (requestFileContents, submitFileContents, + * initiateFileCopy) are protocol-specific and routed through this + * method rather than living on Session directly. + */ + invokeExtension(ext: Extension): unknown; } diff --git a/web-client/iron-remote-desktop/src/interfaces/SessionBuilder.ts b/web-client/iron-remote-desktop/src/interfaces/SessionBuilder.ts index 57e74c7074..3ad7d3fd1e 100644 --- a/web-client/iron-remote-desktop/src/interfaces/SessionBuilder.ts +++ b/web-client/iron-remote-desktop/src/interfaces/SessionBuilder.ts @@ -1,6 +1,7 @@ import type { Session } from './Session'; import type { DesktopSize } from './DesktopSize'; import type { ClipboardData } from './ClipboardData'; +import type { Extension } from './Extension'; export interface SessionBuilder { /** @@ -53,10 +54,6 @@ export interface SessionBuilder { * Optional */ remoteClipboardChangedCallback(callback: RemoteClipboardChangedCallback): SessionBuilder; - /** - * Optional - */ - remoteReceivedFormatListCallback(callback: RemoteReceiveForwardListCallback): SessionBuilder; /** * Optional */ @@ -65,7 +62,16 @@ export interface SessionBuilder { * Optional */ canvasResizedCallback(callback: CanvasResizedCallback): SessionBuilder; - extension(value: unknown): SessionBuilder; + + /** + * Register a protocol-specific extension. + * + * File transfer callbacks (filesAvailableCallback, lockCallback, etc.) are + * protocol-specific and registered through this method via extension factory + * functions from the RDP backend package. + */ + extension(ext: Extension): SessionBuilder; + connect(): Promise; } @@ -82,10 +88,6 @@ interface RemoteClipboardChangedCallback { (data: ClipboardData): void; } -interface RemoteReceiveForwardListCallback { - (): void; -} - interface ForceClipboardUpdateCallback { (): void; } diff --git a/web-client/iron-remote-desktop/src/interfaces/UserInteraction.ts b/web-client/iron-remote-desktop/src/interfaces/UserInteraction.ts index 19c1e25172..9d496d9cf9 100644 --- a/web-client/iron-remote-desktop/src/interfaces/UserInteraction.ts +++ b/web-client/iron-remote-desktop/src/interfaces/UserInteraction.ts @@ -1,10 +1,10 @@ -import type { ScreenScale } from '../enums/ScreenScale'; +import type { ScreenScale } from '../enums/ScreenScale'; import type { NewSessionInfo } from './NewSessionInfo'; -import type { SessionEvent } from './session-event'; import { ConfigBuilder } from '../services/ConfigBuilder'; import type { Config } from '../services/Config'; import type { Extension } from './Extension'; import type { Callback } from '../lib/Observable'; +import type { FileTransferProvider } from './FileTransferProvider'; export interface UserInteraction { setVisibility(state: boolean): void; @@ -21,15 +21,37 @@ export interface UserInteraction { metaKey(): void; + ctrlC(): void; + + ctrlV(): void; + shutdown(): void; setCursorStyleOverride(style: string | null): void; - onSessionEvent(callback: Callback): void; + onWarningCallback(callback: Callback): void; + + onClipboardRemoteUpdateCallback(callback: Callback): void; resize(width: number, height: number, scale?: number): void; setEnableClipboard(enable: boolean): void; + setEnableAutoClipboard(enable: boolean): void; + + saveRemoteClipboardData(): Promise; + + sendClipboardData(): Promise; + invokeExtension(ext: Extension): void; + + /** + * Enable file transfer support. Must be called before connect(). + * The provider becomes active after connect() resolves. + * Implicitly enables clipboard (required for file transfer protocol). + * + * @param provider - Protocol-specific file transfer provider (e.g., RdpFileTransferProvider) + * @returns The same provider, with monitoring hooks composed in + */ + enableFileTransfer(provider: FileTransferProvider): FileTransferProvider; } diff --git a/web-client/iron-remote-desktop/src/iron-remote-desktop.svelte b/web-client/iron-remote-desktop/src/iron-remote-desktop.svelte index 3db73e4d88..fda0e270ff 100644 --- a/web-client/iron-remote-desktop/src/iron-remote-desktop.svelte +++ b/web-client/iron-remote-desktop/src/iron-remote-desktop.svelte @@ -20,8 +20,10 @@ import type { ResizeEvent } from './interfaces/ResizeEvent'; import { PublicAPI } from './services/PublicAPI'; import { ScreenScale } from './enums/ScreenScale'; - import type { ClipboardData } from './interfaces/ClipboardData'; import type { RemoteDesktopModule } from './interfaces/RemoteDesktopModule'; + import { isComponentDestroyed } from './lib/stores/componentLifecycleStore'; + import { runWhenFocusedQueue } from './lib/stores/runWhenFocusedStore'; + import { ClipboardService } from './services/clipboard.service'; let { scale, @@ -46,418 +48,32 @@ let inner: HTMLDivElement; let wrapper: HTMLDivElement; - let screenViewer: HTMLDivElement; let canvas: HTMLCanvasElement; let viewerStyle = $state(''); let wrapperStyle = $state(''); let remoteDesktopService = new RemoteDesktopService(module); - let publicAPI = new PublicAPI(remoteDesktopService); + let clipboardService = new ClipboardService(remoteDesktopService, module); + let publicAPI = new PublicAPI(remoteDesktopService, clipboardService); let currentScreenScale = ScreenScale.Fit; - // Firefox's clipboard API is very limited, and doesn't support reading from the clipboard - // without changing browser settings via `about:config`. - // - // For firefox, we will use a different approach by marking `screen-wrapper` component - // as `contenteditable=true`, and then using the `onpaste`/`oncopy`/`oncut` events. - let isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; - - const CLIPBOARD_MONITORING_INTERVAL = 100; // ms - - let isClipboardApiSupported = false; - let lastClientClipboardItems: Record = {}; - let lastReceivedClipboardData: Record = {}; - let lastSentClipboardData: ClipboardData | null = null; - let lastClipboardMonitorLoopError: Error | null = null; - - let componentDestroyed = false; - let runWhenFocusedQueue: (() => void)[] = []; - - /* Firefox-specific BEGIN */ - - // See `ffRemoteClipboardData` variable docs below - const FF_REMOTE_CLIPBOARD_DATA_SET_RETRY_INTERVAL = 100; // ms - const FF_REMOTE_CLIPBOARD_DATA_SET_MAX_RETRIES = 30; // 3 seconds (100ms * 30) - // On Firefox, this interval is used to stop delaying the keyboard events if the paste event has - // failed and we haven't received any clipboard data from the remote side. - const FF_LOCAL_CLIPBOARD_COPY_TIMEOUT = 1000; // 1s (For text-only data this should be enough) - - // In Firefox, we need this variable due to fact that `clipboard.writeText()` should only be - // called in scope of user-initiated event processing (e.g. keyboard event), but we receive - // clipboard data from the remote side asynchronously in wasm service callback. therefore we - // set this variable in callback and use its value on the user-initiated copy event. - let ffRemoteClipboardData: ClipboardData | null = null; - // For Firefox we need this variable to perform wait loop for the remote side to finish sending - // clipboard content to the client. - let ffRemoteClipboardDataRetriesLeft = 0; - let ffPostponeKeyboardEvents = false; - let ffDelayedKeyboardEvents: KeyboardEvent[] = []; - let ffCnavasFocused = false; - - /* Firefox-specific END */ - - /* Clipboard initialization BEGIN */ - function initClipboard() { - // Detect if browser supports async Clipboard API - if (!isFirefox && navigator.clipboard != undefined) { - if (navigator.clipboard.read != undefined && navigator.clipboard.write != undefined) { - isClipboardApiSupported = true; - } - } - - if (isFirefox) { - remoteDesktopService.setOnRemoteClipboardChanged(ffOnRemoteClipboardChanged); - remoteDesktopService.setOnRemoteReceivedFormatList(ffOnRemoteReceivedFormatList); - remoteDesktopService.setOnForceClipboardUpdate(onForceClipboardUpdate); - } else if (isClipboardApiSupported) { - remoteDesktopService.setOnRemoteClipboardChanged(onRemoteClipboardChanged); - remoteDesktopService.setOnForceClipboardUpdate(onForceClipboardUpdate); - - // Start the clipboard monitoring loop - setTimeout(onMonitorClipboard, CLIPBOARD_MONITORING_INTERVAL); - } - } - - /* Clipboard initialization END */ - - function isCopyKeyboardEvent(evt: KeyboardEvent) { - return ( - (evt.ctrlKey && evt.code === 'KeyC') || - (evt.ctrlKey && evt.code === 'KeyX') || - evt.code == 'Copy' || - evt.code == 'Cut' - ); - } - - function isPasteKeyboardEvent(evt: KeyboardEvent) { - return (evt.ctrlKey && evt.code === 'KeyV') || evt.code == 'Paste'; - } - - // This function is required to convert `ClipboardData` to an object that can be used - // with `ClipboardItem` API. - function clipboardDataToRecord(data: ClipboardData): Record { - let result = {} as Record; - - for (const item of data.items()) { - let mime = item.mimeType(); - let value = new Blob([item.value()], { type: mime }); - - result[mime] = value; - } - - return result; - } - - function clipboardDataToClipboardItemsRecord(data: ClipboardData): Record { - let result = {} as Record; - - for (const item of data.items()) { - let mime = item.mimeType(); - result[mime] = item.value(); - } - - return result; - } - - // This callback is required to send initial clipboard state if available. - function onForceClipboardUpdate() { - // TODO(Fix): lastSentClipboardData is nullptr. - try { - if (lastSentClipboardData) { - remoteDesktopService.onClipboardChanged(lastSentClipboardData); - } else { - remoteDesktopService.onClipboardChangedEmpty(); - } - } catch (err) { - console.error('Failed to send initial clipboard state: ' + err); - } - } - - function runWhenWindowFocused(fn: () => void) { - if (document.hasFocus()) { - fn(); - } else { - runWhenFocusedQueue.push(fn); + function captureKeys(evt: KeyboardEvent) { + if (capturingInputs()) { + keyboardEvent(evt); } } - // This callback is required to update client clipboard state when remote side has changed. - function onRemoteClipboardChanged(data: ClipboardData) { - try { - const mime_formats = clipboardDataToRecord(data); - const clipboard_item = new ClipboardItem(mime_formats); - runWhenWindowFocused(() => { - lastReceivedClipboardData = clipboardDataToClipboardItemsRecord(data); - navigator.clipboard.write([clipboard_item]); - }); - } catch (err) { - console.error('Failed to set client clipboard: ' + err); - } - } - - // Called periodically to monitor clipboard changes - async function onMonitorClipboard() { - try { - if (!document.hasFocus()) { - return; - } - - var value = await navigator.clipboard.read(); - - // Clipboard is empty - if (value.length == 0) { - return; - } - - // We only support one item at a time - var item = value[0]; - - if (!item.types.some((type) => type.startsWith('text/') || type.startsWith('image/png'))) { - // Unsupported types - return; - } - - var values: Record = {}; - var sameValue = true; - - // Sadly, browsers build new `ClipboardItem` object for each `read` call, - // so we can't do reference comparison here :( - // - // For monitoring loop approach we also can't drop this logic, as it will result in - // very frequent network activity. - for (const kind of item.types) { - // Get blob - const blobIsString = kind.startsWith('text/'); - - const blob = await item.getType(kind); - const value = blobIsString ? await blob.text() : new Uint8Array(await blob.arrayBuffer()); - - const is_equal = blobIsString - ? function (a: string | Uint8Array | undefined, b: string | Uint8Array | undefined) { - return a === b; - } - : function (a: string | Uint8Array | undefined, b: string | Uint8Array | undefined) { - if (!(a instanceof Uint8Array) || !(b instanceof Uint8Array)) { - return false; - } - - return ( - a != undefined && b != undefined && a.length === b.length && a.every((v, i) => v === b[i]) - ); - }; - - const previousValue = lastClientClipboardItems[kind]; - - if (!is_equal(previousValue, value)) { - // When the local clipboard updates, we need to compare it with the last data received from the server. - // If it's identical, the clipboard was updated with the server's data, so we shouldn't send this data - // to the server. - if (is_equal(lastReceivedClipboardData[kind], value)) { - lastClientClipboardItems[kind] = lastReceivedClipboardData[kind]; - } - // One of mime types has changed, we need to update the clipboard cache - else { - sameValue = false; - } - } - - values[kind] = value; - } - - // Clipboard has changed, we need to acknowledge remote side about it. - if (!sameValue) { - lastClientClipboardItems = values; - - let clipboardData = new module.ClipboardData(); - - // Iterate over `Record` type - Object.entries(values).forEach(([key, value]: [string, string | Uint8Array]) => { - // skip null/undefined values - if (value == null || value == undefined) { - return; - } - - if (key.startsWith('text/') && typeof value === 'string') { - clipboardData.addText(key, value); - } else if (key.startsWith('image/') && value instanceof Uint8Array) { - clipboardData.addBinary(key, value); - } - }); - - if (!clipboardData.isEmpty()) { - lastSentClipboardData = clipboardData; - // TODO(Fix): onClipboardChanged takes an ownership over clipboardData, so lastSentClipboardData will be nullptr. - await remoteDesktopService.onClipboardChanged(clipboardData); - } - } - } catch (err) { - if (err instanceof Error) { - const printError = - lastClipboardMonitorLoopError === null || - lastClipboardMonitorLoopError.toString() !== err.toString(); - // Prevent spamming the console with the same error - if (printError) { - console.error('Clipboard monitoring error: ' + err); - } - lastClipboardMonitorLoopError = err; - } - } finally { - if (!componentDestroyed) { - setTimeout(onMonitorClipboard, CLIPBOARD_MONITORING_INTERVAL); - } - } - } - - /* Firefox-specific BEGIN */ - - function ffOnRemoteReceivedFormatList() { - try { - // We are ready to send delayed Ctrl+V events - ffSimulateDelayedKeyEvents(); - } catch (err) { - console.error('Failed to send delayed keyboard events: ' + err); - } - } - - // Only set variable on callback, the real clipboard update will be performed in keyboard - // callback. (User-initiated event is required for Firefox to allow clipboard write) - function ffOnRemoteClipboardChanged(data: ClipboardData) { - ffRemoteClipboardData = data; - } - - function ffWaitForRemoteClipboardDataSet() { - if (ffRemoteClipboardData) { - try { - let clipboard_data = ffRemoteClipboardData; - ffRemoteClipboardData = null; - for (const item of clipboard_data.items()) { - // Firefox only supports text/plain mime type for clipboard writes :( - if (item.mimeType() === 'text/plain') { - const value = item.value(); - - if (typeof value === 'string') { - navigator.clipboard.writeText(value); - } else { - loggingService.error('Unexpected value for text/plain clipboard item'); - } - - break; - } - } - } catch (err) { - console.error('Failed to set client clipboard: ' + err); - } - } else if (ffRemoteClipboardDataRetriesLeft > 0) { - ffRemoteClipboardDataRetriesLeft--; - setTimeout(ffWaitForRemoteClipboardDataSet, FF_REMOTE_CLIPBOARD_DATA_SET_RETRY_INTERVAL); - } - } - - function ffSimulateDelayedKeyEvents() { - if (ffDelayedKeyboardEvents.length > 0) { - for (const evt of ffDelayedKeyboardEvents) { - // simulate consecutive key events - keyboardEvent(evt); - } - ffDelayedKeyboardEvents = []; - } - ffPostponeKeyboardEvents = false; - } - - function ffOnPasteHandler(evt: ClipboardEvent) { - // We don't actually want to paste the clipboard data into the `contenteditable` div. - evt.preventDefault(); - - // `onpaste` events are handled only for Firefox, other browsers we use the clipboard API - // for reading the clipboard. - if (!isFirefox) { - // Prevent processing of the paste event by the browser. - return; - } - - try { - let clipboardData = new module.ClipboardData(); - - if (evt.clipboardData == null) { - return; - } - - for (var clipItem of evt.clipboardData.items) { - let mime = clipItem.type; - - if (mime.startsWith('text/')) { - clipItem.getAsString((str: string) => { - clipboardData.addText(mime, str); - - if (!clipboardData.isEmpty()) { - remoteDesktopService.onClipboardChanged(clipboardData); - } - }); - break; - } - - if (mime.startsWith('image/')) { - let file = clipItem.getAsFile(); - if (file == null) { - continue; - } - - file.arrayBuffer().then((buffer: ArrayBuffer) => { - const strict_buffer = new Uint8Array(buffer); - - clipboardData.addBinary(mime, strict_buffer); - - if (!clipboardData.isEmpty()) { - remoteDesktopService.onClipboardChanged(clipboardData); - } - }); - break; - } - } - } catch (err) { - console.error('Failed to update remote clipboard: ' + err); - } - } - - /* Firefox-specific END */ - function initListeners() { serverBridgeListeners(); userInteractionListeners(); - function captureKeys(evt: KeyboardEvent) { - if (capturingInputs()) { - if (ffPostponeKeyboardEvents) { - evt.preventDefault(); - ffDelayedKeyboardEvents.push(evt); - return; - } - - // For Firefox we need to make `onpaste` event still fire even if - // keyboard is being captured. Not capturing `Ctrl + V` should not create any - // side effects, therefore is safe to skip capture for it. - let isFirefoxPaste = isFirefox && isPasteKeyboardEvent(evt); - - if (isFirefoxPaste) { - ffPostponeKeyboardEvents = true; - ffDelayedKeyboardEvents = []; - ffDelayedKeyboardEvents.push(evt); - - // If during the given timeout we weren't able to finish the copy sequence, we need to - // simulate all queued keyboard events. - setTimeout(ffSimulateDelayedKeyEvents, FF_LOCAL_CLIPBOARD_COPY_TIMEOUT); - return; - } - - keyboardEvent(evt); - } - } - window.addEventListener('keydown', captureKeys, false); window.addEventListener('keyup', captureKeys, false); window.addEventListener('focus', focusEventHandler); + window.addEventListener('blur', blurEventHandler); + document.addEventListener('visibilitychange', visibilityChangeHandler); } function resetHostStyle() { @@ -645,22 +261,6 @@ } function setMouseButtonState(state: MouseEvent, isDown: boolean) { - if (isFirefox) { - if (isDown && state.button == 0 && !ffCnavasFocused) { - // Do not capture first mouse down event on Firefox, as we need to transfer focus to the - // canvas first in order to receive paste events. - // wasmService.mouseButtonState(state, isDown, false); - // Focus `contenteditable` element to receive `on_paste` events - canvas.focus(); - // Finish the focus sequence on Firefox - ffCnavasFocused = true; - } else { - // This is needed to prevent visible "double click" selection on - // `texteditable` element - screenViewer.blur(); - } - } - remoteDesktopService.mouseButtonState(state, isDown, true); } @@ -678,18 +278,6 @@ } function keyboardEvent(evt: KeyboardEvent) { - const browserHasClipboardAccess = - navigator.clipboard != undefined && navigator.clipboard.writeText != undefined; - - if (isFirefox && browserHasClipboardAccess && isCopyKeyboardEvent(evt)) { - // Special processing for firefox, as the only way Firefox supports clipboard write is - // only after some user-initiated event (e.g. keyboard event). - // therefore we need to wait here for the clipboard data to be ready. - - ffRemoteClipboardDataRetriesLeft = FF_REMOTE_CLIPBOARD_DATA_SET_MAX_RETRIES; - ffWaitForRemoteClipboardDataSet(); - } - remoteDesktopService.sendKeyboardEvent(evt); // Propagate further @@ -731,23 +319,42 @@ } function focusEventHandler() { - while (runWhenFocusedQueue.length > 0) { - const fn = runWhenFocusedQueue.shift(); - fn?.(); + try { + while (runWhenFocusedQueue.length() > 0) { + const fn = runWhenFocusedQueue.shift(); + fn?.(); + } + } catch (err) { + console.error('Failed to run the function queued for execution when the window received focus: ' + err); + } + } + + function blurEventHandler() { + remoteDesktopService.focusLost(); + } + + function visibilityChangeHandler() { + if (document.visibilityState === 'hidden') { + remoteDesktopService.focusLost(); } } onMount(async () => { + isComponentDestroyed.set(false); loggingService.verbose = verbose === 'true'; loggingService.info('Dom ready'); await initcanvas(); - initClipboard(); + await clipboardService.initClipboard(); }); onDestroy(() => { window.removeEventListener('resize', resizeHandler); + window.removeEventListener('keydown', captureKeys, false); + window.removeEventListener('keyup', captureKeys, false); window.removeEventListener('focus', focusEventHandler); - componentDestroyed = true; + window.removeEventListener('blur', blurEventHandler); + document.removeEventListener('visibilitychange', visibilityChangeHandler); + isComponentDestroyed.set(true); }); @@ -759,14 +366,13 @@ class:capturing-inputs={capturingInputs} style={wrapperStyle} > -
+
setMouseButtonState(event, true)} onmouseup={(event) => setMouseButtonState(event, false)} onmouseleave={(event) => { - setMouseButtonState(event, false); setMouseOut(event); }} onmouseenter={(event) => { diff --git a/web-client/iron-remote-desktop/src/lib/scancodes.ts b/web-client/iron-remote-desktop/src/lib/scancodes.ts index 4c83d60b42..eb24ee2040 100644 --- a/web-client/iron-remote-desktop/src/lib/scancodes.ts +++ b/web-client/iron-remote-desktop/src/lib/scancodes.ts @@ -157,7 +157,7 @@ const scanCodeToKeyCode = { '0xE06D': 'MediaSelect', }; -const codeToScanCodeBlinkOverride = { +const scanCodeToKeyCodeExtras = { '0x0077': 'Lang4', '0x0078': 'Lang3', '0xE008': 'Undo', @@ -175,7 +175,7 @@ const codeToScanCodeBlinkOverride = { '0xE063': 'WakeUp', }; -const scanCodeToKeyCodeGeckoOverride = { +const scanCodeToKeyCodeGeckoExtras = { '0x0054': 'PrintScreen', '0xE020': 'VolumeMute', // The documentation says it's 'AudioVolumeMute', but the actual test shows that it's 'VolumeMute'. '0xE02E': 'VolumeDown', @@ -185,9 +185,9 @@ const scanCodeToKeyCodeGeckoOverride = { }; const KeyCodeToScanCode = { - blink: invertCodesMapping({ ...scanCodeToKeyCode, ...codeToScanCodeBlinkOverride }), - gecko: invertCodesMapping({ ...scanCodeToKeyCode, ...scanCodeToKeyCodeGeckoOverride }), - webkit: invertCodesMapping(scanCodeToKeyCode), + blink: invertCodesMapping({ ...scanCodeToKeyCode, ...scanCodeToKeyCodeExtras }), + gecko: invertCodesMapping({ ...scanCodeToKeyCode, ...scanCodeToKeyCodeGeckoExtras }), + webkit: invertCodesMapping({ ...scanCodeToKeyCode, ...scanCodeToKeyCodeExtras }), }; function invertCodesMapping(obj: CodeMap) { diff --git a/web-client/iron-remote-desktop/src/lib/stores/componentLifecycleStore.ts b/web-client/iron-remote-desktop/src/lib/stores/componentLifecycleStore.ts new file mode 100644 index 0000000000..ac63dad69c --- /dev/null +++ b/web-client/iron-remote-desktop/src/lib/stores/componentLifecycleStore.ts @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store'; + +export const isComponentDestroyed = writable(false); diff --git a/web-client/iron-remote-desktop/src/lib/stores/runWhenFocusedStore.ts b/web-client/iron-remote-desktop/src/lib/stores/runWhenFocusedStore.ts new file mode 100644 index 0000000000..f47804cb13 --- /dev/null +++ b/web-client/iron-remote-desktop/src/lib/stores/runWhenFocusedStore.ts @@ -0,0 +1,29 @@ +import { get, writable } from 'svelte/store'; + +function createQueueStore() { + const store = writable([]); + + return { + subscribe: store.subscribe, + + enqueue(item: T) { + store.update((queue) => [...queue, item]); + }, + + shift(): T | undefined { + let first: T | undefined; + store.update((queue) => { + if (queue.length == 0) return queue; + first = queue[0]; + return queue.slice(1); + }); + return first; + }, + + length(): number { + return get(store).length; + }, + }; +} + +export const runWhenFocusedQueue = createQueueStore<() => void>(); diff --git a/web-client/iron-remote-desktop/src/main.ts b/web-client/iron-remote-desktop/src/main.ts index 0ee6f0224c..cb91474193 100644 --- a/web-client/iron-remote-desktop/src/main.ts +++ b/web-client/iron-remote-desktop/src/main.ts @@ -1,8 +1,7 @@ export * as default from './iron-remote-desktop.svelte'; export type { ResizeEvent } from './interfaces/ResizeEvent'; export type { NewSessionInfo } from './interfaces/NewSessionInfo'; -export type { SessionEvent, IronError, IronErrorKind } from './interfaces/session-event'; -export type { SessionEventType } from './enums/SessionEventType'; +export type { IronError, IronErrorKind, RDCleanPathDetails } from './interfaces/Error'; export type { SessionTerminationInfo } from './interfaces/SessionTerminationInfo'; export type { ClipboardData } from './interfaces/ClipboardData'; export type { ClipboardItem } from './interfaces/ClipboardItem'; @@ -10,4 +9,8 @@ export type { DeviceEvent } from './interfaces/DeviceEvent'; export type { InputTransaction } from './interfaces/InputTransaction'; export type { Session } from './interfaces/Session'; export type { SessionBuilder } from './interfaces/SessionBuilder'; +export type { DesktopSize } from './interfaces/DesktopSize'; export type { UserInteraction } from './interfaces/UserInteraction'; +export type { FileTransferProvider } from './interfaces/FileTransferProvider'; +export { Config } from './services/Config'; +export { ConfigBuilder } from './services/ConfigBuilder'; diff --git a/web-client/iron-remote-desktop/src/services/PublicAPI.ts b/web-client/iron-remote-desktop/src/services/PublicAPI.ts index bea1058455..640fb1f7f3 100644 --- a/web-client/iron-remote-desktop/src/services/PublicAPI.ts +++ b/web-client/iron-remote-desktop/src/services/PublicAPI.ts @@ -7,12 +7,16 @@ import type { ScreenScale } from '../enums/ScreenScale'; import { ConfigBuilder } from './ConfigBuilder'; import { Config } from './Config'; import type { Extension } from '../interfaces/Extension'; +import type { ClipboardService } from './clipboard.service'; +import type { FileTransferProvider } from '../interfaces/FileTransferProvider'; export class PublicAPI { private remoteDesktopService: RemoteDesktopService; + private clipboardService: ClipboardService; - constructor(remoteDesktopService: RemoteDesktopService) { + constructor(remoteDesktopService: RemoteDesktopService, clipboardService: ClipboardService) { this.remoteDesktopService = remoteDesktopService; + this.clipboardService = clipboardService; } private configBuilder(): ConfigBuilder { @@ -32,6 +36,14 @@ export class PublicAPI { this.remoteDesktopService.sendSpecialCombination(SpecialCombination.META); } + private ctrlC() { + this.remoteDesktopService.sendSpecialCombination(SpecialCombination.CTRL_C); + } + + private ctrlV() { + this.remoteDesktopService.sendSpecialCombination(SpecialCombination.CTRL_V); + } + private setVisibility(state: boolean) { loggingService.info(`Change component visibility to: ${state}`); this.remoteDesktopService.setVisibility(state); @@ -61,27 +73,68 @@ export class PublicAPI { this.remoteDesktopService.setEnableClipboard(enable); } + private setEnableAutoClipboard(enable: boolean) { + this.remoteDesktopService.setEnableAutoClipboard(enable); + } + + private setOnWarningCallback(callback: (data: string) => void) { + this.remoteDesktopService.setOnWarningCallback(callback); + } + + private setOnClipboardRemoteUpdateCallback(callback: () => void) { + this.remoteDesktopService.setOnClipboardRemoteUpdate(callback); + } + + private async saveRemoteClipboardData(): Promise { + return await this.clipboardService.saveRemoteClipboardData(); + } + + private async sendClipboardData(): Promise { + return await this.clipboardService.sendClipboardData(); + } + private invokeExtension(ext: Extension) { this.remoteDesktopService.invokeExtension(ext); } + private enableFileTransfer(provider: FileTransferProvider): FileTransferProvider { + // Wire clipboard monitoring suppression so the polling loop does not + // clobber a file upload's FormatList with a text/image clipboard update. + const origStart = provider.onUploadStarted; + const origFinish = provider.onUploadFinished; + provider.onUploadStarted = () => { + origStart?.(); + this.clipboardService.suppressMonitoring(); + }; + provider.onUploadFinished = () => { + this.clipboardService.resumeMonitoring(); + origFinish?.(); + }; + return this.remoteDesktopService.enableFileTransfer(provider); + } + getExposedFunctions(): UserInteraction { return { setVisibility: this.setVisibility.bind(this), configBuilder: this.configBuilder.bind(this), connect: this.connect.bind(this), + onWarningCallback: this.setOnWarningCallback.bind(this), + onClipboardRemoteUpdateCallback: this.setOnClipboardRemoteUpdateCallback.bind(this), setScale: this.setScale.bind(this), - onSessionEvent: (callback) => { - this.remoteDesktopService.sessionEventObservable.subscribe(callback); - }, ctrlAltDel: this.ctrlAltDel.bind(this), metaKey: this.metaKey.bind(this), + ctrlC: this.ctrlC.bind(this), + ctrlV: this.ctrlV.bind(this), shutdown: this.shutdown.bind(this), setKeyboardUnicodeMode: this.setKeyboardUnicodeMode.bind(this), setCursorStyleOverride: this.setCursorStyleOverride.bind(this), resize: this.resize.bind(this), setEnableClipboard: this.setEnableClipboard.bind(this), + setEnableAutoClipboard: this.setEnableAutoClipboard.bind(this), + saveRemoteClipboardData: this.saveRemoteClipboardData.bind(this), + sendClipboardData: this.sendClipboardData.bind(this), invokeExtension: this.invokeExtension.bind(this), + enableFileTransfer: this.enableFileTransfer.bind(this), }; } } diff --git a/web-client/iron-remote-desktop/src/services/clipboard.service.ts b/web-client/iron-remote-desktop/src/services/clipboard.service.ts new file mode 100644 index 0000000000..c01095f87f --- /dev/null +++ b/web-client/iron-remote-desktop/src/services/clipboard.service.ts @@ -0,0 +1,488 @@ +import type { RemoteDesktopService } from './remote-desktop.service'; +import { isComponentDestroyed } from '../lib/stores/componentLifecycleStore'; +import { get } from 'svelte/store'; +import type { ClipboardData } from '../interfaces/ClipboardData'; +import type { RemoteDesktopModule } from '../interfaces/RemoteDesktopModule'; +import { runWhenFocusedQueue } from '../lib/stores/runWhenFocusedStore'; +import { ClipboardApiSupported } from '../enums/ClipboardApiSupported'; +import { IronErrorKind } from '../interfaces/Error'; + +const CLIPBOARD_MONITORING_INTERVAL_MS = 100; + +// Helper function to conveniently throw an `IronError`. +function throwIronError(message: string): never { + throw { + kind: () => IronErrorKind.General, + backtrace: () => message, + }; +} + +export class ClipboardService { + private remoteDesktopService: RemoteDesktopService; + private module: RemoteDesktopModule; + + private ClipboardApiSupported: ClipboardApiSupported = ClipboardApiSupported.None; + + private lastClientClipboardItems: Record = {}; + private lastReceivedClipboardData: Record = {}; + private lastSentClipboardData: ClipboardData | null = null; + private clipboardDataToSave: ClipboardData | null = null; + private lastClipboardMonitorLoopError: Error | null = null; + // When true, the clipboard monitoring loop skips reading/sending clipboard updates. + // Used to prevent the monitoring loop from clobbering an active file upload's + // FormatList with a text/image clipboard update. + private monitoringSuppressed: boolean = false; + + constructor(remoteDesktopService: RemoteDesktopService, module: RemoteDesktopModule) { + this.remoteDesktopService = remoteDesktopService; + this.module = module; + } + + /** + * Suppress clipboard monitoring. While suppressed, the 100ms monitoring + * loop will skip reading the local clipboard and sending updates to the + * remote. This prevents the monitor from clobbering a file upload's + * FormatList announcement with a text/image clipboard update. + */ + suppressMonitoring(): void { + this.monitoringSuppressed = true; + } + + /** + * Resume clipboard monitoring after a previous {@link suppressMonitoring} call. + */ + resumeMonitoring(): void { + this.monitoringSuppressed = false; + } + + async initClipboard() { + // Clipboard API is available only in secure contexts (HTTPS). + if (!window.isSecureContext) { + this.remoteDesktopService.emitWarningEvent('Clipboard is available only in secure contexts (HTTPS).'); + return; + } + + // Detect if browser supports async Clipboard API + if (navigator.clipboard != undefined) { + if (navigator.clipboard.read != undefined && navigator.clipboard.write != undefined) { + this.ClipboardApiSupported = ClipboardApiSupported.Full; + } else if (navigator.clipboard.readText != undefined) { + this.ClipboardApiSupported = ClipboardApiSupported.TextOnly; + this.remoteDesktopService.emitWarningEvent( + 'Clipboard is limited to text-only data types due to an outdated browser version!', + ); + } else if (navigator.clipboard.writeText != undefined) { + this.ClipboardApiSupported = ClipboardApiSupported.TextOnlyServerOnly; + this.remoteDesktopService.emitWarningEvent( + 'Clipboard reading is not supported and writing is limited to text-only data types due to an outdated browser version!', + ); + } + } + + // Gate the polling-based auto-clipboard loop behind a Permissions API + // check. Two cases are handled: + // + // 1. Chromium: The query succeeds and returns a PermissionStatus. + // - 'granted': Keep Full mode; auto-clipboard polling works. + // - 'prompt': Keep Full mode; Chromium will show a one-time + // permission prompt on the first clipboard.read() call. If + // the user denies it, the safety net in onMonitorClipboard + // catches the NotAllowedError and stops the loop. + // - 'denied': Downgrade to TextOnly; the polling loop would + // fail on every iteration. + // + // 2. Firefox v127+: Exposes clipboard.read()/write() but does not + // include "clipboard-read" in its PermissionName WebIDL enum, so + // the query throws. Without persistent permission, Firefox requires + // transient user activation for every clipboard.read() call, making + // the polling loop unusable. Downgrade to TextOnly so Firefox + // routes through the text-only fallback paths. + // + // When the permission query fails, a trial clipboard.read() checks + // whether Firefox's `dom.events.testing.asyncClipboard` about:config + // pref is active. If so, keep Full mode as clipboard works fully in + // this scenario, without any user-activation restrictions. + if (this.ClipboardApiSupported === ClipboardApiSupported.Full) { + try { + const permissionStatus = await navigator.permissions.query({ + name: 'clipboard-read' as PermissionName, + }); + + if (permissionStatus.state === 'denied') { + this.ClipboardApiSupported = ClipboardApiSupported.TextOnly; + } + } catch { + try { + // Try to read clipboard to check if the asyncClipboard pref is enabled + await navigator.clipboard.read(); + } catch { + this.ClipboardApiSupported = ClipboardApiSupported.TextOnly; + } + } + } + + // The basic Clipboard API is widely supported in modern browsers, + // so this condition should never be true in practice. + if (this.ClipboardApiSupported === ClipboardApiSupported.None) { + this.remoteDesktopService.emitWarningEvent( + 'Clipboard is not supported due to an outdated browser version!', + ); + return; + } + + this.remoteDesktopService.setOnForceClipboardUpdate(this.onForceClipboardUpdate.bind(this)); + + if (this.ClipboardApiSupported === ClipboardApiSupported.Full) { + if (this.remoteDesktopService.autoClipboard) { + this.remoteDesktopService.setOnRemoteClipboardChanged(this.onRemoteClipboardChangedAutoMode.bind(this)); + + // Start the clipboard monitoring loop after session has been started + this.remoteDesktopService.sessionStartedObservable.subscribe((_) => { + this.scheduleOnMonitorClipboardUpdate(); + }); + } else { + this.remoteDesktopService.setOnRemoteClipboardChanged( + this.onRemoteClipboardChangedManualMode.bind(this), + ); + } + } else { + this.remoteDesktopService.setOnRemoteClipboardChanged(this.ffOnRemoteClipboardChanged.bind(this)); + } + } + + // Copies clipboard content received from the server to the local clipboard. + // Returns the result of the operation. On failure, it additionally raises an error session event. + async saveRemoteClipboardData(): Promise { + if (this.ClipboardApiSupported !== ClipboardApiSupported.Full) { + return await this.ffSaveRemoteClipboardData(); + } + + if (this.clipboardDataToSave == null) { + throwIronError('The server did not send the clipboard data.'); + } + + try { + const mime_formats = this.clipboardDataToRecord(this.clipboardDataToSave); + const clipboard_item = new ClipboardItem(mime_formats); + await navigator.clipboard.write([clipboard_item]); + + this.clipboardDataToSave = null; + } catch (err) { + throwIronError('Failed to write to the clipboard: ' + err); + } + } + + // Sends local clipboard's content to the server. + // Returns the result of the operation. On failure, it additionally raises an error session event. + async sendClipboardData(): Promise { + if (this.ClipboardApiSupported !== ClipboardApiSupported.Full) { + return await this.ffSendClipboardData(); + } + + const value = await navigator.clipboard.read().catch((err) => { + throwIronError('Failed to read from the clipboard: ' + err); + }); + + // Clipboard is empty + if (value.length == 0) { + throwIronError('The clipboard has no data.'); + } + + // We only support one item at a time + const item = value[0]; + + if (!item.types.some((type) => type.startsWith('text/') || type.startsWith('image/png'))) { + // Unsupported types + throwIronError('The clipboard has no data of supported type (text or image).'); + } + + const clipboardData = new this.module.ClipboardData(); + + for (const kind of item.types) { + // Get blob + const blobIsString = kind.startsWith('text/'); + const blob = await item.getType(kind); + + if (blobIsString) { + clipboardData.addText(kind, await blob.text()); + } else { + clipboardData.addBinary(kind, new Uint8Array(await blob.arrayBuffer())); + } + } + + if (!clipboardData.isEmpty()) { + this.lastSentClipboardData = clipboardData; + await this.remoteDesktopService.onClipboardChanged(clipboardData); + } + } + + private scheduleOnMonitorClipboardUpdate() { + setTimeout(this.onMonitorClipboard.bind(this), CLIPBOARD_MONITORING_INTERVAL_MS); + } + + private runWhenWindowFocused(fn: () => void) { + if (document.hasFocus()) { + fn(); + } else { + runWhenFocusedQueue.enqueue(fn); + } + } + + // This function is required to convert `ClipboardData` to an object that can be used + // with `ClipboardItem` API. + private clipboardDataToRecord(data: ClipboardData): Record { + const result = {} as Record; + + for (const item of data.items()) { + const mime = item.mimeType(); + result[mime] = new Blob([item.value()], { type: mime }); + } + + return result; + } + + private clipboardDataToClipboardItemsRecord(data: ClipboardData): Record { + const result = {} as Record; + + for (const item of data.items()) { + const mime = item.mimeType(); + result[mime] = item.value(); + } + + return result; + } + + // This callback is required to send initial clipboard state if available. + private onForceClipboardUpdate() { + try { + if (this.lastSentClipboardData) { + this.remoteDesktopService.onClipboardChanged(this.lastSentClipboardData); + } else { + this.remoteDesktopService.onClipboardChangedEmpty(); + } + } catch (err) { + console.error('Failed to send initial clipboard state: ' + err); + } + } + + // This callback is required to update client clipboard state when remote side has changed. + private onRemoteClipboardChangedManualMode(data: ClipboardData) { + this.clipboardDataToSave = data; + this.remoteDesktopService.emitClipboardRemoteUpdateEvent(); + } + + // This callback is required to update client clipboard state when remote side has changed. + private onRemoteClipboardChangedAutoMode(data: ClipboardData) { + try { + const mime_formats = this.clipboardDataToRecord(data); + const clipboard_item = new ClipboardItem(mime_formats); + this.runWhenWindowFocused(() => { + this.lastReceivedClipboardData = this.clipboardDataToClipboardItemsRecord(data); + navigator.clipboard.write([clipboard_item]); + }); + } catch (err) { + console.error('Failed to set client clipboard: ' + err); + } + } + + // Called periodically to monitor clipboard changes + private async onMonitorClipboard(): Promise { + let stopped = false; + try { + if (this.monitoringSuppressed) { + return; + } + + if (!document.hasFocus()) { + return; + } + + const value = await navigator.clipboard.read(); + + // Clipboard is empty + if (value.length == 0) { + return; + } + + // We only support one item at a time + const item = value[0]; + + if (!item.types.some((type) => type.startsWith('text/') || type.startsWith('image/png'))) { + // Unsupported types + return; + } + + const values: Record = {}; + let sameValue = true; + + // Sadly, browsers build new `ClipboardItem` object for each `read` call, + // so we can't do reference comparison here :( + // + // For monitoring loop approach we also can't drop this logic, as it will result in + // very frequent network activity. + for (const kind of item.types) { + // Get blob + const blobIsString = kind.startsWith('text/'); + + const blob = await item.getType(kind); + const value = blobIsString ? await blob.text() : new Uint8Array(await blob.arrayBuffer()); + + const is_equal = blobIsString + ? function (a: string | Uint8Array | undefined, b: string | Uint8Array | undefined) { + return a === b; + } + : function (a: string | Uint8Array | undefined, b: string | Uint8Array | undefined) { + if (!(a instanceof Uint8Array) || !(b instanceof Uint8Array)) { + return false; + } + + return a.length === b.length && a.every((v, i) => v === b[i]); + }; + + const previousValue = this.lastClientClipboardItems[kind]; + + if (!is_equal(previousValue, value)) { + // When the local clipboard updates, we need to compare it with the last data received from the server. + // If it's identical, the clipboard was updated with the server's data, so we shouldn't send this data + // to the server. + if (is_equal(this.lastReceivedClipboardData[kind], value)) { + this.lastClientClipboardItems[kind] = this.lastReceivedClipboardData[kind]; + } + // One of mime types has changed, we need to update the clipboard cache + else { + sameValue = false; + } + } + + values[kind] = value; + } + + // Clipboard has changed, we need to acknowledge remote side about it. + if (!sameValue) { + this.lastClientClipboardItems = values; + + const clipboardData = new this.module.ClipboardData(); + + // Iterate over `Record` type + Object.entries(values).forEach(([key, value]: [string, string | Uint8Array]) => { + // skip null/undefined values + if (value == null) { + return; + } + + if (key.startsWith('text/') && typeof value === 'string') { + clipboardData.addText(key, value); + } else if (key.startsWith('image/') && value instanceof Uint8Array) { + clipboardData.addBinary(key, value); + } + }); + + if (!clipboardData.isEmpty()) { + this.lastSentClipboardData = clipboardData; + await this.remoteDesktopService.onClipboardChanged(clipboardData); + } + } + } catch (err) { + if (err instanceof DOMException && err.name === 'NotAllowedError') { + // The browser requires user activation for clipboard reads (e.g. Firefox v127+). + // The polling loop cannot work in this environment; fall back to manual mode. + console.warn('Clipboard monitoring disabled: browser requires user activation for clipboard read.'); + this.remoteDesktopService.setOnRemoteClipboardChanged( + this.onRemoteClipboardChangedManualMode.bind(this), + ); + stopped = true; + return; + } + + if (err instanceof Error) { + const printError = + this.lastClipboardMonitorLoopError === null || + this.lastClipboardMonitorLoopError.toString() !== err.toString(); + // Prevent spamming the console with the same error + if (printError) { + console.error('Clipboard monitoring error: ' + err); + } + this.lastClipboardMonitorLoopError = err; + } + } finally { + if (!stopped && !get(isComponentDestroyed)) { + this.scheduleOnMonitorClipboardUpdate(); + } + } + } + + // Firefox v126 and below does not support `navigator.clipboard.read` and `navigator.clipboard.write`. + // So, we need to define specific methods to handle text-only clipboard. + // + // Also, Firefox v124 and below does not support `navigator.clipboard.readText`. + // Because of this, we cannot read the data from the clipboard at all. + + private ffClipboardDataToSave: string | null = null; + + // This function is required to retrieve the text data from the `ClipboardData`. + private ffRetrieveTextData(data: ClipboardData): string { + for (const item of data.items()) { + if (item.mimeType().startsWith('text/')) { + const value = item.value(); + if (typeof value === 'string') return value; + } + } + + return ''; + } + + // Firefox specific function. + // This callback is required to update client clipboard state when remote side has changed. + private ffOnRemoteClipboardChanged(data: ClipboardData) { + const value = this.ffRetrieveTextData(data); + // Non-text clipboard data is ignored. + if (value === '') return; + + this.ffClipboardDataToSave = value; + this.remoteDesktopService.emitClipboardRemoteUpdateEvent(); + } + + // Firefox specific function. We are using text-only clipboard API here. + // + // Copies clipboard content received from the server to the local clipboard. + // Returns the result of the operation. On failure, it additionally raises an error session event. + private async ffSaveRemoteClipboardData(): Promise { + if (this.ffClipboardDataToSave == null) { + throwIronError('The server did not send the clipboard data.'); + } + + try { + await navigator.clipboard.writeText(this.ffClipboardDataToSave); + this.ffClipboardDataToSave = null; + } catch (err) { + throwIronError('Failed to write to the clipboard: ' + err); + } + } + + // Firefox specific function. We are using text-only clipboard API here. + // + // Sends local clipboard's content to the server. + // Returns the result of the operation. On failure, it additionally raises an error session event. + private async ffSendClipboardData(): Promise { + if (this.ClipboardApiSupported !== ClipboardApiSupported.TextOnly) { + throwIronError('The browser does not support clipboard read.'); + } + + const value = await navigator.clipboard.readText().catch((err) => { + throwIronError('Failed to read from the clipboard: ' + err); + }); + + // Clipboard is empty + if (value.length == 0) { + throwIronError('The clipboard has no data.'); + } + + const clipboardData = new this.module.ClipboardData(); + clipboardData.addText('text/plain', value); + + if (!clipboardData.isEmpty()) { + this.lastSentClipboardData = clipboardData; + await this.remoteDesktopService.onClipboardChanged(clipboardData); + } + } +} diff --git a/web-client/iron-remote-desktop/src/services/enableFileTransfer.test.ts b/web-client/iron-remote-desktop/src/services/enableFileTransfer.test.ts new file mode 100644 index 0000000000..75896a048a --- /dev/null +++ b/web-client/iron-remote-desktop/src/services/enableFileTransfer.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { RemoteDesktopService } from './remote-desktop.service'; +import { ClipboardService } from './clipboard.service'; +import { PublicAPI } from './PublicAPI'; +import type { Session } from '../interfaces/Session'; +import type { SessionBuilder } from '../interfaces/SessionBuilder'; +import type { RemoteDesktopModule } from '../interfaces/RemoteDesktopModule'; +import type { FileTransferProvider } from '../interfaces/FileTransferProvider'; + +/** + * Tests for the enableFileTransfer() integration between RemoteDesktopService + * and FileTransferProvider. + * + * These tests verify that: + * - enableFileTransfer() accepts a FileTransferProvider and enables clipboard + * - connect() passes provider extensions to the SessionBuilder + * - connect() calls setSession() on the provider after connection + * - PublicAPI composes monitoring suppression into the provider hooks + */ + +// Minimal mock SessionBuilder +class MockSessionBuilder { + extensions: unknown[] = []; + + connect = async (): Promise => { + return mockSession as unknown as Session; + }; + + proxyAddress(_address: string): this { + return this; + } + destination(_dest: string): this { + return this; + } + serverDomain(_domain: string): this { + return this; + } + password(_pw: string): this { + return this; + } + authToken(_token: string): this { + return this; + } + username(_name: string): this { + return this; + } + renderCanvas(_canvas: HTMLCanvasElement): this { + return this; + } + setCursorStyleCallback(_cb: unknown): this { + return this; + } + setCursorStyleCallbackContext(_ctx: unknown): this { + return this; + } + desktopSize(_size: unknown): this { + return this; + } + remoteClipboardChangedCallback(_cb: unknown): this { + return this; + } + forceClipboardUpdateCallback(_cb: unknown): this { + return this; + } + canvasResizedCallback(_cb: unknown): this { + return this; + } + extension(ext: unknown): this { + this.extensions.push(ext); + return this; + } +} + +// Minimal mock Session +const mockSession = { + desktopSize: vi.fn().mockReturnValue({ width: 1920, height: 1080 }), + run: vi.fn().mockResolvedValue({ reason: () => 'test' }), + invokeExtension: vi.fn(), + shutdown: vi.fn(), + releaseAllInputs: vi.fn(), +}; + +let mockBuilderInstance: MockSessionBuilder; + +// Mock RemoteDesktopModule +function createMockModule(): RemoteDesktopModule { + return { + SessionBuilder: class { + constructor() { + return mockBuilderInstance as unknown as SessionBuilder; + } + } as unknown as { new (): SessionBuilder }, + DesktopSize: class { + constructor( + public width: number, + public height: number, + ) {} + } as unknown as RemoteDesktopModule['DesktopSize'], + InputTransaction: class {} as unknown as RemoteDesktopModule['InputTransaction'], + ClipboardData: class {} as unknown as RemoteDesktopModule['ClipboardData'], + DeviceEvent: {} as unknown as RemoteDesktopModule['DeviceEvent'], + }; +} + +// Mock FileTransferProvider +function createMockProvider(): FileTransferProvider { + return { + getBuilderExtensions: vi.fn().mockReturnValue([{ id: 'ext1' }, { id: 'ext2' }]), + setSession: vi.fn(), + dispose: vi.fn(), + }; +} + +describe('enableFileTransfer integration', () => { + let service: RemoteDesktopService; + let mockModule: RemoteDesktopModule; + + beforeEach(() => { + vi.clearAllMocks(); + mockBuilderInstance = new MockSessionBuilder(); + mockModule = createMockModule(); + service = new RemoteDesktopService(mockModule); + const canvas = document.createElement('canvas'); + service.setCanvas(canvas); + }); + + it('should accept a FileTransferProvider and return it', () => { + const provider = createMockProvider(); + const result = service.enableFileTransfer(provider); + expect(result).toBe(provider); + }); + + describe('connect() with FileTransferProvider', () => { + it('should pass provider extensions to SessionBuilder', async () => { + const provider = createMockProvider(); + service.enableFileTransfer(provider); + + await service.connect({ + proxyAddress: 'wss://test', + destination: 'test:3389', + serverDomain: '', + password: 'pass', + authToken: 'token', + username: 'user', + desktopSize: { width: 1920, height: 1080 }, + extensions: [], + }); + + // Provider's extensions should have been registered on builder + expect(provider.getBuilderExtensions).toHaveBeenCalledTimes(1); + expect(mockBuilderInstance.extensions).toHaveLength(2); + }); + + it('should call setSession after connect', async () => { + const provider = createMockProvider(); + service.enableFileTransfer(provider); + + await service.connect({ + proxyAddress: 'wss://test', + destination: 'test:3389', + serverDomain: '', + password: 'pass', + authToken: 'token', + username: 'user', + desktopSize: { width: 1920, height: 1080 }, + extensions: [], + }); + + expect(provider.setSession).toHaveBeenCalledTimes(1); + expect(provider.setSession).toHaveBeenCalledWith(mockSession); + }); + }); + + describe('connect() without FileTransferProvider', () => { + it('should not register any file transfer extensions', async () => { + await service.connect({ + proxyAddress: 'wss://test', + destination: 'test:3389', + serverDomain: '', + password: 'pass', + authToken: 'token', + username: 'user', + desktopSize: { width: 1920, height: 1080 }, + extensions: [], + }); + + // No file transfer extensions + expect(mockBuilderInstance.extensions).toHaveLength(0); + }); + }); +}); + +describe('PublicAPI clipboard monitoring suppression', () => { + let service: RemoteDesktopService; + let clipboardService: ClipboardService; + let publicApi: PublicAPI; + let mockModule: RemoteDesktopModule; + + beforeEach(() => { + vi.clearAllMocks(); + mockBuilderInstance = new MockSessionBuilder(); + mockModule = createMockModule(); + service = new RemoteDesktopService(mockModule); + clipboardService = new ClipboardService(service, mockModule); + publicApi = new PublicAPI(service, clipboardService); + }); + + it('should wire suppressMonitoring/resumeMonitoring into provider hooks', () => { + const provider = createMockProvider(); + const api = publicApi.getExposedFunctions(); + api.enableFileTransfer(provider); + + const suppressSpy = vi.spyOn(clipboardService, 'suppressMonitoring'); + const resumeSpy = vi.spyOn(clipboardService, 'resumeMonitoring'); + + provider.onUploadStarted?.(); + expect(suppressSpy).toHaveBeenCalledTimes(1); + + provider.onUploadFinished?.(); + expect(resumeSpy).toHaveBeenCalledTimes(1); + }); + + it('should compose user-provided hooks with monitoring suppression', () => { + const userStarted = vi.fn(); + const userFinished = vi.fn(); + const provider = createMockProvider(); + provider.onUploadStarted = userStarted; + provider.onUploadFinished = userFinished; + + const suppressSpy = vi.spyOn(clipboardService, 'suppressMonitoring'); + const resumeSpy = vi.spyOn(clipboardService, 'resumeMonitoring'); + + const api = publicApi.getExposedFunctions(); + api.enableFileTransfer(provider); + + provider.onUploadStarted?.(); + expect(userStarted).toHaveBeenCalledTimes(1); + expect(suppressSpy).toHaveBeenCalledTimes(1); + + provider.onUploadFinished?.(); + expect(userFinished).toHaveBeenCalledTimes(1); + expect(resumeSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web-client/iron-remote-desktop/src/services/mouseInput.test.ts b/web-client/iron-remote-desktop/src/services/mouseInput.test.ts new file mode 100644 index 0000000000..20720a8cca --- /dev/null +++ b/web-client/iron-remote-desktop/src/services/mouseInput.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { RemoteDesktopService } from './remote-desktop.service'; +import type { RemoteDesktopModule } from '../interfaces/RemoteDesktopModule'; +import type { Session } from '../interfaces/Session'; + +/** + * Regression tests for the Firefox stuck right-click bug. + * + * Root cause: The RDP server received mouseButtonPressed(2) but never + * mouseButtonReleased(2) when the user right-clicked and then moved the + * cursor off the canvas before releasing. Two defects contributed: + * + * Defect 1 (iron-remote-desktop.svelte): onmouseleave sent a spurious + * mouseButtonReleased for a hardcoded button index instead of calling + * releaseAllInputs — fixed in the component. + * + * Defect 2 (remote-desktop.service.ts): mouseIn() did not reconcile the + * browser's event.buttons bitmask against the RDP session's assumed + * button state, so re-entering the canvas left stale "button held" + * state on the server — fixed by the mouseIn() implementation tested here. + * + * These tests also cover the mouseOut() path which must call releaseAllInputs. + */ + +// ── Helpers ────────────────────────────────────────────────────────────────── + +class MockInputTransaction { + addEvent = vi.fn(); +} + +function createMockModule(): RemoteDesktopModule { + return { + SessionBuilder: class {} as unknown as RemoteDesktopModule['SessionBuilder'], + DesktopSize: class {} as unknown as RemoteDesktopModule['DesktopSize'], + InputTransaction: MockInputTransaction as unknown as RemoteDesktopModule['InputTransaction'], + ClipboardData: class {} as unknown as RemoteDesktopModule['ClipboardData'], + DeviceEvent: { + mouseButtonPressed: vi.fn((id: number) => ({ type: 'pressed', id })), + mouseButtonReleased: vi.fn((id: number) => ({ type: 'released', id })), + mouseMove: vi.fn(), + wheelRotations: vi.fn(), + keyPressed: vi.fn(), + keyReleased: vi.fn(), + unicodePressed: vi.fn(), + unicodeReleased: vi.fn(), + }, + }; +} + +function createMockSession(): Session { + return { + run: vi.fn().mockResolvedValue({ reason: () => 'test' }), + desktopSize: vi.fn().mockReturnValue({ width: 1920, height: 1080 }), + applyInputs: vi.fn(), + releaseAllInputs: vi.fn(), + synchronizeLockKeys: vi.fn(), + shutdown: vi.fn(), + onClipboardPaste: vi.fn(), + resize: vi.fn(), + supportsUnicodeKeyboardShortcuts: vi.fn().mockReturnValue(false), + invokeExtension: vi.fn(), + } as unknown as Session; +} + +// ── mouseOut ───────────────────────────────────────────────────────────────── + +describe('mouseOut', () => { + let service: RemoteDesktopService; + let session: Session; + + beforeEach(() => { + vi.clearAllMocks(); + service = new RemoteDesktopService(createMockModule()); + session = createMockSession(); + service.session = session; + }); + + it('calls releaseAllInputs on the session', () => { + service.mouseOut(new MouseEvent('mouseleave')); + expect(session.releaseAllInputs).toHaveBeenCalledTimes(1); + }); + + it('does not throw when there is no active session', () => { + service.session = undefined; + expect(() => service.mouseOut(new MouseEvent('mouseleave'))).not.toThrow(); + }); +}); + +// ── focusLost ───────────────────────────────────────────────────────────────── + +describe('focusLost', () => { + let service: RemoteDesktopService; + let session: Session; + + beforeEach(() => { + vi.clearAllMocks(); + service = new RemoteDesktopService(createMockModule()); + session = createMockSession(); + service.session = session; + }); + + it('calls releaseAllInputs on the session', () => { + service.focusLost(); + expect(session.releaseAllInputs).toHaveBeenCalledTimes(1); + }); + + it('does not throw when there is no active session', () => { + service.session = undefined; + expect(() => service.focusLost()).not.toThrow(); + }); +}); + +// ── mouseIn button reconciliation ───────────────────────────────────────────── + +describe('mouseIn button reconciliation', () => { + let service: RemoteDesktopService; + let mockModule: RemoteDesktopModule; + let session: Session; + + beforeEach(() => { + vi.clearAllMocks(); + mockModule = createMockModule(); + service = new RemoteDesktopService(mockModule); + session = createMockSession(); + service.session = session; + }); + + function mouseIn(buttons: number) { + service.mouseIn(new MouseEvent('mouseenter', { buttons })); + } + + it('releases all three buttons when no buttons are physically held (buttons=0)', () => { + mouseIn(0); + const released = vi.mocked(mockModule.DeviceEvent.mouseButtonReleased); + expect(released).toHaveBeenCalledWith(0); // left + expect(released).toHaveBeenCalledWith(2); // right + expect(released).toHaveBeenCalledWith(1); // middle + expect(released).toHaveBeenCalledTimes(3); + }); + + it('does not release the right button when it is physically held (buttons=2)', () => { + mouseIn(2); + const released = vi.mocked(mockModule.DeviceEvent.mouseButtonReleased); + expect(released).toHaveBeenCalledWith(0); // left released + expect(released).toHaveBeenCalledWith(1); // middle released + expect(released).not.toHaveBeenCalledWith(2); // right NOT released + expect(released).toHaveBeenCalledTimes(2); + }); + + it('does not release the left button when it is physically held (buttons=1)', () => { + mouseIn(1); + const released = vi.mocked(mockModule.DeviceEvent.mouseButtonReleased); + expect(released).toHaveBeenCalledWith(2); // right released + expect(released).toHaveBeenCalledWith(1); // middle released + expect(released).not.toHaveBeenCalledWith(0); // left NOT released + expect(released).toHaveBeenCalledTimes(2); + }); + + it('does not release the middle button when it is physically held (buttons=4)', () => { + mouseIn(4); + const released = vi.mocked(mockModule.DeviceEvent.mouseButtonReleased); + expect(released).toHaveBeenCalledWith(0); // left released + expect(released).toHaveBeenCalledWith(2); // right released + expect(released).not.toHaveBeenCalledWith(1); // middle NOT released + expect(released).toHaveBeenCalledTimes(2); + }); + + it('releases no buttons when all three are physically held (buttons=7)', () => { + mouseIn(7); + expect(vi.mocked(mockModule.DeviceEvent.mouseButtonReleased)).not.toHaveBeenCalled(); + }); + + it('does nothing when there is no active session (buttons=0)', () => { + service.session = undefined; + mouseIn(0); + expect(vi.mocked(mockModule.DeviceEvent.mouseButtonReleased)).not.toHaveBeenCalled(); + expect(session.applyInputs).not.toHaveBeenCalled(); + }); + + it('sends all releases in a single applyInputs transaction', () => { + mouseIn(0); // all three released → 1 batched transaction + expect(session.applyInputs).toHaveBeenCalledTimes(1); + }); + + it('sends no transactions when all buttons are held', () => { + mouseIn(7); + expect(session.applyInputs).not.toHaveBeenCalled(); + }); +}); diff --git a/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts b/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts index f3325f11f5..3ef5aa91cb 100644 --- a/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts +++ b/web-client/iron-remote-desktop/src/services/remote-desktop.service.ts @@ -2,15 +2,14 @@ import { loggingService } from './logging.service'; import { scanCode } from '../lib/scancodes'; import { ModifierKey } from '../enums/ModifierKey'; import { LockKey } from '../enums/LockKey'; -import { SessionEventType } from '../enums/SessionEventType'; import type { NewSessionInfo } from '../interfaces/NewSessionInfo'; import { SpecialCombination } from '../enums/SpecialCombination'; import type { ResizeEvent } from '../interfaces/ResizeEvent'; import { ScreenScale } from '../enums/ScreenScale'; import type { MousePosition } from '../interfaces/MousePosition'; -import type { IronError, IronErrorKind, SessionEvent } from '../interfaces/session-event'; import type { ClipboardData } from '../interfaces/ClipboardData'; import type { Session } from '../interfaces/Session'; +import { RotationUnit } from '../interfaces/DeviceEvent'; import type { DeviceEvent } from '../interfaces/DeviceEvent'; import type { RemoteDesktopModule } from '../interfaces/RemoteDesktopModule'; import { ConfigBuilder } from './ConfigBuilder'; @@ -18,11 +17,13 @@ import type { Config } from './Config'; import type { Extension } from '../interfaces/Extension'; import { Observable } from '../lib/Observable'; import type { SessionTerminationInfo } from '../interfaces/SessionTerminationInfo'; +import type { FileTransferProvider } from '../interfaces/FileTransferProvider'; type OnRemoteClipboardChanged = (data: ClipboardData) => void; -type OnRemoteReceivedFormatsList = () => void; type OnForceClipboardUpdate = () => void; type OnCanvasResized = () => void; +type OnWarning = (data: string) => void; +type OnClipboardRemoteUpdate = () => void; export class RemoteDesktopService { private module: RemoteDesktopModule; @@ -30,12 +31,17 @@ export class RemoteDesktopService { private keyboardUnicodeMode: boolean = false; private backendSupportsUnicodeKeyboardShortcuts: boolean | undefined = undefined; private onRemoteClipboardChanged?: OnRemoteClipboardChanged; - private onRemoteReceivedFormatList?: OnRemoteReceivedFormatsList; private onForceClipboardUpdate?: OnForceClipboardUpdate; private onCanvasResized?: OnCanvasResized; + private onWarningCallback?: OnWarning; + private onClipboardRemoteUpdate?: OnClipboardRemoteUpdate; + private fileTransferProvider?: FileTransferProvider; private cursorHasOverride: boolean = false; private lastCursorStyle: string = 'default'; private enableClipboard: boolean = true; + private _autoClipboard: boolean = true; + + sessionStartedObservable: Observable = new Observable(); resizeObservable: Observable = new Observable(); @@ -44,7 +50,6 @@ export class RemoteDesktopService { mousePositionObservable: Observable = new Observable(); changeVisibilityObservable: Observable = new Observable(); - sessionEventObservable: Observable = new Observable(); scaleObservable: Observable = new Observable(); dynamicResizeObservable: Observable<{ width: number; height: number }> = new Observable(); @@ -54,21 +59,28 @@ export class RemoteDesktopService { loggingService.info('Web bridge initialized.'); } + get autoClipboard(): boolean { + return this._autoClipboard; + } + // If set to false, the clipboard will not be enabled and the callbacks will not be registered to the Rust side setEnableClipboard(enable: boolean) { this.enableClipboard = enable; } + // If set to true, automatic clipboard synchronization with the server is enabled. + // + // If set to false, then the client must invoke `PublicAPI.saveRemoteClipboardData` and + // `PublicAPI.sendClipboardData` to write to clipboard and to send clipboard data to the server. + setEnableAutoClipboard(enable: boolean) { + this._autoClipboard = enable; + } + /// Callback to set the local clipboard content to data received from the remote. setOnRemoteClipboardChanged(callback: OnRemoteClipboardChanged) { this.onRemoteClipboardChanged = callback; } - /// Callback which is called when the remote sends a list of supported clipboard formats. - setOnRemoteReceivedFormatList(callback: OnRemoteReceivedFormatsList) { - this.onRemoteReceivedFormatList = callback; - } - /// Callback which is called when the remote requests a forced clipboard update (e.g. on /// clipboard initialization sequence) setOnForceClipboardUpdate(callback: OnForceClipboardUpdate) { @@ -80,19 +92,62 @@ export class RemoteDesktopService { this.onCanvasResized = callback; } + /// Callback which is called when the warning event is emitted. + setOnWarningCallback(callback: OnWarning) { + this.onWarningCallback = callback; + } + + /// Callback which is called when the clipboard remote update event is emitted. + setOnClipboardRemoteUpdate(callback: OnClipboardRemoteUpdate) { + this.onClipboardRemoteUpdate = callback; + } + + /** + * Enable file transfer support. Must be called before connect(). + * Implicitly enables clipboard (required for file transfer protocol). + * + * @param provider - Protocol-specific file transfer provider (e.g., RdpFileTransferProvider) + * @returns The same provider, for chaining + */ + enableFileTransfer(provider: FileTransferProvider): FileTransferProvider { + this.fileTransferProvider?.dispose(); + this.fileTransferProvider = provider; + this.enableClipboard = true; + return provider; + } + mouseIn(event: MouseEvent) { + if (!this.session) return; this.syncModifier(event); + // Release any button the session thinks is held but the browser no longer reports, + // clearing stale state from buttons released outside the canvas (e.g. off-canvas mouseup). + const buttonsMap: [number, number][] = [ + [1, 0], // left button + [2, 2], // right button + [4, 1], // middle button + ]; + const releases = buttonsMap + .filter(([mask]) => (event.buttons & mask) === 0) + .map(([, buttonId]) => this.module.DeviceEvent.mouseButtonReleased(buttonId)); + if (releases.length > 0) { + this.doTransactionFromDeviceEvents(releases); + } } mouseOut(_event: MouseEvent) { this.releaseAllInputs(); } + focusLost() { + this.releaseAllInputs(); + } + sendKeyboardEvent(evt: KeyboardEvent) { this.sendKeyboard(evt); } shutdown() { + this.fileTransferProvider?.dispose(); this.session?.shutdown(); } @@ -135,12 +190,16 @@ export class RemoteDesktopService { if (this.onRemoteClipboardChanged != null && this.enableClipboard) { sessionBuilder.remoteClipboardChangedCallback(this.onRemoteClipboardChanged); } - if (this.onRemoteReceivedFormatList != null && this.enableClipboard) { - sessionBuilder.remoteReceivedFormatListCallback(this.onRemoteReceivedFormatList); - } if (this.onForceClipboardUpdate != null && this.enableClipboard) { sessionBuilder.forceClipboardUpdateCallback(this.onForceClipboardUpdate); } + // File transfer callbacks are protocol-specific and routed through + // the extension mechanism. The provider supplies the extensions. + if (this.fileTransferProvider != null && this.enableClipboard) { + for (const ext of this.fileTransferProvider.getBuilderExtensions()) { + sessionBuilder.extension(ext); + } + } if (this.onCanvasResized != null) { sessionBuilder.canvasResizedCallback(this.onCanvasResized); } @@ -151,60 +210,35 @@ export class RemoteDesktopService { ); } - const session = await sessionBuilder.connect().catch((err: IronError) => { - this.raiseSessionEvent({ - type: SessionEventType.TERMINATED, - data: { - backtrace: () => err.backtrace(), - kind: () => err.kind() as number as IronErrorKind, - }, - }); - // The client must ignore this error and use session events for error handling. - throw new Error(); - }); - - this.run(session); - - loggingService.info('Session started.'); + const session = await sessionBuilder.connect(); this.session = session; + this.fileTransferProvider?.setSession(session); this.resizeObservable.publish({ desktopSize: session.desktopSize(), sessionId: 0, }); - this.raiseSessionEvent({ - type: SessionEventType.STARTED, - data: 'Session started', - }); + + this.sessionStartedObservable.publish(null); + + const run = async (): Promise => { + try { + loggingService.info('Starting the session.'); + return await session.run(); + } finally { + this.setVisibility(false); + } + }; return { sessionId: 0, initialDesktopSize: session.desktopSize(), websocketPort: 0, + run, }; } - run(session: Session) { - session - .run() - .then((terminationInfo: SessionTerminationInfo) => { - this.setVisibility(false); - this.raiseSessionEvent({ - type: SessionEventType.TERMINATED, - data: 'Session was terminated: ' + terminationInfo.reason() + '.', - }); - }) - .catch((err: IronError) => { - this.setVisibility(false); - - this.raiseSessionEvent({ - type: SessionEventType.TERMINATED, - data: 'Session was terminated with an error: ' + err.backtrace() + '.', - }); - }); - } - sendSpecialCombination(specialCombination: SpecialCombination): void { switch (specialCombination) { case SpecialCombination.CTRL_ALT_DEL: @@ -213,13 +247,44 @@ export class RemoteDesktopService { case SpecialCombination.META: this.sendMeta(); break; + case SpecialCombination.CTRL_C: + this.sendCtrlC(); + break; + case SpecialCombination.CTRL_V: + this.sendCtrlV(); + break; + } + } + + rotation_unit_from_wheel_event(event: WheelEvent): RotationUnit { + switch (event.deltaMode) { + case event.DOM_DELTA_PIXEL: + return RotationUnit.Pixel; + case event.DOM_DELTA_LINE: + return RotationUnit.Line; + case event.DOM_DELTA_PAGE: + return RotationUnit.Page; + default: + return RotationUnit.Pixel; } } mouseWheel(event: WheelEvent) { const vertical = event.deltaY !== 0; const rotation = vertical ? event.deltaY : event.deltaX; - this.doTransactionFromDeviceEvents([this.module.DeviceEvent.wheelRotations(vertical, -rotation)]); + const rotation_unit = this.rotation_unit_from_wheel_event(event); + + this.doTransactionFromDeviceEvents([ + this.module.DeviceEvent.wheelRotations(vertical, -rotation, rotation_unit), + ]); + } + + emitWarningEvent(data: string): void { + this.onWarningCallback?.(data); + } + + emitClipboardRemoteUpdateEvent(): void { + this.onClipboardRemoteUpdate?.(); } setVisibility(state: boolean) { @@ -419,10 +484,6 @@ export class RemoteDesktopService { ); } - private raiseSessionEvent(event: SessionEvent) { - this.sessionEventObservable.publish(event); - } - private updateModifierKeyState(evt: KeyboardEvent) { const modKey: ModifierKey = ModifierKey[evt.code as keyof typeof ModifierKey]; @@ -462,4 +523,28 @@ export class RemoteDesktopService { this.module.DeviceEvent.keyReleased(meta), ]); } + + private sendCtrlC() { + const ctrl = parseInt('0x001D', 16); + const c = parseInt('0x002E', 16); + + this.doTransactionFromDeviceEvents([ + this.module.DeviceEvent.keyPressed(ctrl), + this.module.DeviceEvent.keyPressed(c), + this.module.DeviceEvent.keyReleased(c), + this.module.DeviceEvent.keyReleased(ctrl), + ]); + } + + private sendCtrlV() { + const ctrl = parseInt('0x001D', 16); + const v = parseInt('0x002F', 16); + + this.doTransactionFromDeviceEvents([ + this.module.DeviceEvent.keyPressed(ctrl), + this.module.DeviceEvent.keyPressed(v), + this.module.DeviceEvent.keyReleased(v), + this.module.DeviceEvent.keyReleased(ctrl), + ]); + } } diff --git a/web-client/iron-remote-desktop/src/test/setup.ts b/web-client/iron-remote-desktop/src/test/setup.ts new file mode 100644 index 0000000000..edcfbf19d4 --- /dev/null +++ b/web-client/iron-remote-desktop/src/test/setup.ts @@ -0,0 +1,9 @@ +// Test setup file for vitest +// This file is run before all tests + +import { beforeEach } from 'vitest'; + +// Reset any global state before each test +beforeEach(() => { + // Clear any mocks or global state if needed +}); diff --git a/web-client/iron-remote-desktop/vite.config.ts b/web-client/iron-remote-desktop/vite.config.ts index 1576414f79..d54de00fb4 100644 --- a/web-client/iron-remote-desktop/vite.config.ts +++ b/web-client/iron-remote-desktop/vite.config.ts @@ -26,4 +26,9 @@ export default defineConfig({ rollupTypes: true, }), ], + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + }, }); diff --git a/web-client/iron-svelte-client/package-lock.json b/web-client/iron-svelte-client/package-lock.json index 332afa21b5..d100f8eefc 100644 --- a/web-client/iron-svelte-client/package-lock.json +++ b/web-client/iron-svelte-client/package-lock.json @@ -36,20 +36,35 @@ "vite-plugin-wasm": "^3.1.0" } }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", - "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -57,6 +72,20 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/android-arm": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", @@ -65,6 +94,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -81,6 +111,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -97,6 +128,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -113,6 +145,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -129,6 +162,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -145,6 +179,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -161,6 +196,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -177,6 +213,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -193,6 +230,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -209,6 +247,7 @@ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -225,6 +264,7 @@ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -241,6 +281,7 @@ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -257,6 +298,7 @@ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -273,6 +315,7 @@ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -289,6 +332,7 @@ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -305,6 +349,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -321,6 +366,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -337,6 +383,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -353,6 +400,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" @@ -369,6 +417,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -385,6 +434,7 @@ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -401,6 +451,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -410,34 +461,40 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, + "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, + "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/eslintrc": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.3.tgz", - "integrity": "sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -457,31 +514,35 @@ } }, "node_modules/@eslint/js": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.53.0.tgz", - "integrity": "sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/@fastify/busboy": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.0.0.tgz", - "integrity": "sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", "dev": true, + "license": "MIT", "engines": { "node": ">=14" } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", - "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.1", - "debug": "^4.1.1", + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", "minimatch": "^3.0.5" }, "engines": { @@ -493,6 +554,7 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=12.22" }, @@ -502,47 +564,54 @@ } }, "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", - "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", - "dev": true + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", - "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@material-design-icons/font": { - "version": "0.14.13", - "resolved": "https://registry.npmjs.org/@material-design-icons/font/-/font-0.14.13.tgz", - "integrity": "sha512-qU5Zms9VUdxxq4oC7lY+Eibz+O6YjmctGZKToUfqQOC4qVkbUNksu+0UizFBdbtx8euERZ3GGcFoN6F9LeXSMw==", - "dev": true + "version": "0.14.15", + "resolved": "https://registry.npmjs.org/@material-design-icons/font/-/font-0.14.15.tgz", + "integrity": "sha512-h4YFZnYxNuciEKR0jAOekaQmIg2UGUSxbnoyzo4OdE42gy9QB3UnrjLcASiDy9ra8fqrcHy+NqxTHx7F86BH0A==", + "dev": true, + "license": "Apache-2.0" }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -556,6 +625,7 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, + "license": "MIT", "engines": { "node": ">= 8" } @@ -565,6 +635,7 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -573,37 +644,342 @@ "node": ">= 8" } }, - "node_modules/@pkgr/utils": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.2.tgz", - "integrity": "sha512-POgTXhjrTfbTV63DiFXav4lBHiICLKKwDeaKn9Nphwj7WH6m0hMMCaJkMyRWjgtPFyRKRVoMXXjczsTQRDEhYw==", + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, "dependencies": { - "cross-spawn": "^7.0.3", - "fast-glob": "^3.3.0", + "detect-libc": "^2.0.3", "is-glob": "^4.0.3", - "open": "^9.1.0", - "picocolors": "^1.0.0", - "tslib": "^2.6.0" + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", "engines": { "node": "^12.20.0 || ^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/unts" + "url": "https://opencollective.com/pkgr" } }, "node_modules/@polka/url": { - "version": "1.0.0-next.23", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.23.tgz", - "integrity": "sha512-C16M+IYz0rgRhWZdCmK+h58JMv8vijAA61gmz2rspCSwKwzBebpdcsiUmwrtJRdphuY30i6BSLEOP8ppbNLyLg==", - "dev": true + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" }, "node_modules/@rollup/plugin-replace": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.5.tgz", - "integrity": "sha512-rYO4fOi8lMaTg/z5Jb+hKnrHHVn8j2lwkqwyS4kTRhKyWOLf2wST2sWXr4WzWiTcoHTp2sTjqUbqIj2E39slKQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.7.tgz", + "integrity": "sha512-PqxSfuorkHz/SPpyngLyg5GCEkOcee9M1bkxiVDr41Pd61mqP1PLOoDPbpl44SB2mQGKwV/In74gqQmGITOhEQ==", "dev": true, + "license": "MIT", "dependencies": { "@rollup/pluginutils": "^5.0.1", "magic-string": "^0.30.3" @@ -625,6 +1001,7 @@ "resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz", "integrity": "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" }, @@ -638,14 +1015,15 @@ } }, "node_modules/@rollup/pluginutils": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.5.tgz", - "integrity": "sha512-6aEYR910NyP73oHiJglti74iRyOwgFU4x3meH/H8OJx6Ry0j6cOVZ5X/wTvub7G7Ao6qaHBEaNsV3GLJkSsF+Q==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", - "picomatch": "^2.3.1" + "picomatch": "^4.0.2" }, "engines": { "node": ">=14.0.0" @@ -664,6 +1042,7 @@ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-2.1.1.tgz", "integrity": "sha512-nzi6x/7/3Axh5VKQ8Eed3pYxastxoa06Y/bFhWb7h3Nu+nGRVxKAy3+hBJgmPCwWScy8n0TsstZjSVKfyrIHkg==", "dev": true, + "license": "MIT", "dependencies": { "import-meta-resolve": "^4.0.0" }, @@ -676,18 +1055,20 @@ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-2.0.3.tgz", "integrity": "sha512-VUqTfXsxYGugCpMqQv1U0LIdbR3S5nBkMMDmpjGVJyM6Q2jHVMFtdWJCkeHMySc6mZxJ+0eZK3T7IgmUCDrcUQ==", "dev": true, + "license": "MIT", "peerDependencies": { "@sveltejs/kit": "^1.5.0" } }, "node_modules/@sveltejs/kit": { - "version": "1.27.3", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.27.3.tgz", - "integrity": "sha512-pd7qwX6ww5noA0/FLk45B0aKUeOXWR+pfZsGTrv3dRmj3lTmnki9UTmTdWzHJGrje+BBkGUZHfgGrsSOQQBQpQ==", + "version": "1.30.4", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.30.4.tgz", + "integrity": "sha512-JSQIQT6XvdchCRQEm7BABxPC56WP5RYVONAi+09S8tmzeP43fBsRlr95bFmsTQM2RHBldfgQk+jgdnsKI75daA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "dependencies": { - "@sveltejs/vite-plugin-svelte": "^2.4.1", + "@sveltejs/vite-plugin-svelte": "^2.5.0", "@types/cookie": "^0.5.1", "cookie": "^0.5.0", "devalue": "^4.3.1", @@ -699,7 +1080,7 @@ "set-cookie-parser": "^2.6.0", "sirv": "^2.0.2", "tiny-glob": "^0.2.9", - "undici": "~5.26.2" + "undici": "^5.28.3" }, "bin": { "svelte-kit": "svelte-kit.js" @@ -708,15 +1089,16 @@ "node": "^16.14 || >=18" }, "peerDependencies": { - "svelte": "^3.54.0 || ^4.0.0-next.0", + "svelte": "^3.54.0 || ^4.0.0-next.0 || ^5.0.0-next.0", "vite": "^4.0.0" } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.4.6.tgz", - "integrity": "sha512-zO79p0+DZnXPnF0ltIigWDx/ux7Ni+HRaFOw720Qeivc1azFUrJxTl0OryXVibYNx1hCboGia1NRV3x8RNv4cA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.5.3.tgz", + "integrity": "sha512-erhNtXxE5/6xGZz/M9eXsmI7Pxa6MS7jyTy06zN3Ck++ldrppOnOlJwHHTsMC7DHDQdgUp4NAc4cDNQ9eGdB/w==", "dev": true, + "license": "MIT", "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^1.0.4", "debug": "^4.3.4", @@ -730,7 +1112,7 @@ "node": "^14.18.0 || >= 16" }, "peerDependencies": { - "svelte": "^3.54.0 || ^4.0.0", + "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0-next.0", "vite": "^4.0.0" } }, @@ -739,6 +1121,7 @@ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-1.0.4.tgz", "integrity": "sha512-zjiuZ3yydBtwpF3bj0kQNV0YXe+iKE545QGZVTaylW3eAzFr+pJ/cwK8lZEaRp4JtaJXhD5DyWAV4AxLh6DgaQ==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.3.4" }, @@ -752,14 +1135,15 @@ } }, "node_modules/@swc/core": { - "version": "1.3.95", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.95.tgz", - "integrity": "sha512-PMrNeuqIusq9DPDooV3FfNEbZuTu5jKAc04N3Hm6Uk2Fl49cqElLFQ4xvl4qDmVDz97n3n/C1RE0/f6WyGPEiA==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.24.tgz", + "integrity": "sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==", "dev": true, "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { - "@swc/counter": "^0.1.1", - "@swc/types": "^0.1.5" + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.26" }, "engines": { "node": ">=10" @@ -769,19 +1153,21 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.3.95", - "@swc/core-darwin-x64": "1.3.95", - "@swc/core-linux-arm-gnueabihf": "1.3.95", - "@swc/core-linux-arm64-gnu": "1.3.95", - "@swc/core-linux-arm64-musl": "1.3.95", - "@swc/core-linux-x64-gnu": "1.3.95", - "@swc/core-linux-x64-musl": "1.3.95", - "@swc/core-win32-arm64-msvc": "1.3.95", - "@swc/core-win32-ia32-msvc": "1.3.95", - "@swc/core-win32-x64-msvc": "1.3.95" + "@swc/core-darwin-arm64": "1.15.24", + "@swc/core-darwin-x64": "1.15.24", + "@swc/core-linux-arm-gnueabihf": "1.15.24", + "@swc/core-linux-arm64-gnu": "1.15.24", + "@swc/core-linux-arm64-musl": "1.15.24", + "@swc/core-linux-ppc64-gnu": "1.15.24", + "@swc/core-linux-s390x-gnu": "1.15.24", + "@swc/core-linux-x64-gnu": "1.15.24", + "@swc/core-linux-x64-musl": "1.15.24", + "@swc/core-win32-arm64-msvc": "1.15.24", + "@swc/core-win32-ia32-msvc": "1.15.24", + "@swc/core-win32-x64-msvc": "1.15.24" }, "peerDependencies": { - "@swc/helpers": "^0.5.0" + "@swc/helpers": ">=0.5.17" }, "peerDependenciesMeta": { "@swc/helpers": { @@ -790,13 +1176,14 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.3.95", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.95.tgz", - "integrity": "sha512-VAuBAP3MNetO/yBIBzvorUXq7lUBwhfpJxYViSxyluMwtoQDhE/XWN598TWMwMl1ZuImb56d7eUsuFdjgY7pJw==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.24.tgz", + "integrity": "sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==", "cpu": [ "arm64" ], "dev": true, + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "darwin" @@ -806,13 +1193,14 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.3.95", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.95.tgz", - "integrity": "sha512-20vF2rvUsN98zGLZc+dsEdHvLoCuiYq/1B+TDeE4oolgTFDmI1jKO+m44PzWjYtKGU9QR95sZ6r/uec0QC5O4Q==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.24.tgz", + "integrity": "sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==", "cpu": [ "x64" ], "dev": true, + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "darwin" @@ -822,13 +1210,14 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.3.95", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.95.tgz", - "integrity": "sha512-oEudEM8PST1MRNGs+zu0cx5i9uP8TsLE4/L9HHrS07Ck0RJ3DCj3O2fU832nmLe2QxnAGPwBpSO9FntLfOiWEQ==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.24.tgz", + "integrity": "sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==", "cpu": [ "arm" ], "dev": true, + "license": "Apache-2.0", "optional": true, "os": [ "linux" @@ -838,13 +1227,14 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.3.95", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.95.tgz", - "integrity": "sha512-pIhFI+cuC1aYg+0NAPxwT/VRb32f2ia8oGxUjQR6aJg65gLkUYQzdwuUmpMtFR2WVf7WVFYxUnjo4UyMuyh3ng==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.24.tgz", + "integrity": "sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==", "cpu": [ "arm64" ], "dev": true, + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" @@ -854,13 +1244,48 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.3.95", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.95.tgz", - "integrity": "sha512-ZpbTr+QZDT4OPJfjPAmScqdKKaT+wGurvMU5AhxLaf85DuL8HwUwwlL0n1oLieLc47DwIJEMuKQkYhXMqmJHlg==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.24.tgz", + "integrity": "sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==", "cpu": [ "arm64" ], "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.24.tgz", + "integrity": "sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.24.tgz", + "integrity": "sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" @@ -870,13 +1295,14 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.3.95", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.95.tgz", - "integrity": "sha512-n9SuHEFtdfSJ+sHdNXNRuIOVprB8nbsz+08apKfdo4lEKq6IIPBBAk5kVhPhkjmg2dFVHVo4Tr/OHXM1tzWCCw==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.24.tgz", + "integrity": "sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==", "cpu": [ "x64" ], "dev": true, + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" @@ -886,13 +1312,14 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.3.95", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.95.tgz", - "integrity": "sha512-L1JrVlsXU3LC0WwmVnMK9HrOT2uhHahAoPNMJnZQpc18a0paO9fqifPG8M/HjNRffMUXR199G/phJsf326UvVg==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.24.tgz", + "integrity": "sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==", "cpu": [ "x64" ], "dev": true, + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "linux" @@ -902,13 +1329,14 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.3.95", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.95.tgz", - "integrity": "sha512-YaP4x/aZbUyNdqCBpC2zL8b8n58MEpOUpmOIZK6G1SxGi+2ENht7gs7+iXpWPc0sy7X3YPKmSWMAuui0h8lgAA==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.24.tgz", + "integrity": "sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==", "cpu": [ "arm64" ], "dev": true, + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" @@ -918,13 +1346,14 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.3.95", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.95.tgz", - "integrity": "sha512-w0u3HI916zT4BC/57gOd+AwAEjXeUlQbGJ9H4p/gzs1zkSHtoDQghVUNy3n/ZKp9KFod/95cA8mbVF9t1+6epQ==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.24.tgz", + "integrity": "sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==", "cpu": [ "ia32" ], "dev": true, + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" @@ -934,13 +1363,14 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.3.95", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.95.tgz", - "integrity": "sha512-5RGnMt0S6gg4Gc6QtPUJ3Qs9Un4sKqccEzgH/tj7V/DVTJwKdnBKxFZfgQ34OR2Zpz7zGOn889xwsFVXspVWNA==", + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.24.tgz", + "integrity": "sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==", "cpu": [ "x64" ], "dev": true, + "license": "Apache-2.0 AND MIT", "optional": true, "os": [ "win32" @@ -950,62 +1380,90 @@ } }, "node_modules/@swc/counter": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.2.tgz", - "integrity": "sha512-9F4ys4C74eSTEUNndnER3VJ15oru2NumfQxS8geE+f3eB5xvfxpWyqE5XlVnxb/R14uoXi6SLbBwwiDSkv+XEw==", - "dev": true + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" }, "node_modules/@swc/types": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.5.tgz", - "integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==", - "dev": true + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@swc/wasm": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.24.tgz", + "integrity": "sha512-vFjzOE8dhJcfeTbM4+HO9Qy58IINV0ysqStAgw81uds+KqCeUDM9huN+SZ5lWZ6U+5nf8VcZoEw5N81xMtAidg==", + "dev": true, + "license": "Apache-2.0" }, "node_modules/@types/cookie": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.3.tgz", - "integrity": "sha512-SLg07AS9z1Ab2LU+QxzU8RCmzsja80ywjf/t5oqw+4NSH20gIGlhLOrBDm1L3PBWzPa4+wkgFQVZAjE6Ioj2ug==", - "dev": true + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.4.tgz", + "integrity": "sha512-7z/eR6O859gyWIAjuvBWFzNURmf2oPBmJlfVWkwehU5nzIyjwBsTh7WMmEEV4JFnHuQ3ex4oyTvfKzcyJVDBNA==", + "dev": true, + "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.4.tgz", - "integrity": "sha512-2JwWnHK9H+wUZNorf2Zr6ves96WHoWDJIftkcxPKsS7Djta6Zu519LarhRNljPXkpsZR2ZMwNCPeW7omW07BJw==", - "dev": true + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" }, "node_modules/@types/json-schema": { - "version": "7.0.14", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.14.tgz", - "integrity": "sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==", - "dev": true + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } }, "node_modules/@types/pug": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.8.tgz", - "integrity": "sha512-QzhsZ1dMGyJbn/D9V80zp4GIA4J4rfAjCCxc3MP+new0E8dyVdSkR735Lx+n3LIaHNFcjHL5+TbziccuT+fdoQ==", - "dev": true + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", + "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", + "dev": true, + "license": "MIT" }, "node_modules/@types/sass": { - "version": "1.45.0", - "resolved": "https://registry.npmjs.org/@types/sass/-/sass-1.45.0.tgz", - "integrity": "sha512-jn7qwGFmJHwUSphV8zZneO3GmtlgLsmhs/LQyVvQbIIa+fzGMUiHI4HXJZL3FT8MJmgXWbLGiVVY7ElvHq6vDA==", - "deprecated": "This is a stub types definition. sass provides its own type definitions, so you do not need this installed.", + "version": "1.43.1", + "resolved": "https://registry.npmjs.org/@types/sass/-/sass-1.43.1.tgz", + "integrity": "sha512-BPdoIt1lfJ6B7rw35ncdwBZrAssjcwzI5LByIrYs+tpXlj/CAkuVdRsgZDdP4lq5EjyWzwxZCqAoFyHKFwp32g==", "dev": true, + "license": "MIT", "dependencies": { - "sass": "*" + "@types/node": "*" } }, "node_modules/@types/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-MMzuxN3GdFwskAnb6fz0orFvhfqi752yjaXylr0Rp4oDg5H0Zn1IuyRhDVvYOwAXoJirx2xuS16I3WjxnAIHiQ==", - "dev": true + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", @@ -1040,6 +1498,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -1067,6 +1526,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/types": "5.62.0", "@typescript-eslint/visitor-keys": "5.62.0" @@ -1084,6 +1544,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/typescript-estree": "5.62.0", "@typescript-eslint/utils": "5.62.0", @@ -1111,6 +1572,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", "dev": true, + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -1124,6 +1586,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "@typescript-eslint/types": "5.62.0", "@typescript-eslint/visitor-keys": "5.62.0", @@ -1151,6 +1614,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", @@ -1177,6 +1641,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", "dev": true, + "license": "MIT", "dependencies": { "@typescript-eslint/types": "5.62.0", "eslint-visitor-keys": "^3.3.0" @@ -1190,128 +1655,140 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" }, "node_modules/@vue/compiler-core": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.7.tgz", - "integrity": "sha512-pACdY6YnTNVLXsB86YD8OF9ihwpolzhhtdLVHhBL6do/ykr6kKXNYABRtNMGrsQXpEXXyAdwvWWkuTbs4MFtPQ==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/parser": "^7.23.0", - "@vue/shared": "3.3.7", + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", "estree-walker": "^2.0.2", - "source-map-js": "^1.0.2" + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/@vue/compiler-dom": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.7.tgz", - "integrity": "sha512-0LwkyJjnUPssXv/d1vNJ0PKfBlDoQs7n81CbO6Q0zdL7H1EzqYRrTVXDqdBVqro0aJjo/FOa1qBAPVI4PGSHBw==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", "dev": true, + "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.3.7", - "@vue/shared": "3.3.7" + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.7.tgz", - "integrity": "sha512-7pfldWy/J75U/ZyYIXRVqvLRw3vmfxDo2YLMwVtWVNew8Sm8d6wodM+OYFq4ll/UxfqVr0XKiVwti32PCrruAw==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/parser": "^7.23.0", - "@vue/compiler-core": "3.3.7", - "@vue/compiler-dom": "3.3.7", - "@vue/compiler-ssr": "3.3.7", - "@vue/reactivity-transform": "3.3.7", - "@vue/shared": "3.3.7", + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", "estree-walker": "^2.0.2", - "magic-string": "^0.30.5", - "postcss": "^8.4.31", - "source-map-js": "^1.0.2" + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.7.tgz", - "integrity": "sha512-TxOfNVVeH3zgBc82kcUv+emNHo+vKnlRrkv8YvQU5+Y5LJGJwSNzcmLUoxD/dNzv0bhQ/F0s+InlgV0NrApJZg==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", "dev": true, + "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.3.7", - "@vue/shared": "3.3.7" + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" } }, "node_modules/@vue/reactivity": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.7.tgz", - "integrity": "sha512-cZNVjWiw00708WqT0zRpyAgduG79dScKEPYJXq2xj/aMtk3SKvL3FBt2QKUlh6EHBJ1m8RhBY+ikBUzwc7/khg==", - "dev": true, - "dependencies": { - "@vue/shared": "3.3.7" - } - }, - "node_modules/@vue/reactivity-transform": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.7.tgz", - "integrity": "sha512-APhRmLVbgE1VPGtoLQoWBJEaQk4V8JUsqrQihImVqKT+8U6Qi3t5ATcg4Y9wGAPb3kIhetpufyZ1RhwbZCIdDA==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/parser": "^7.23.0", - "@vue/compiler-core": "3.3.7", - "@vue/shared": "3.3.7", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.5" + "@vue/shared": "3.5.32" } }, "node_modules/@vue/runtime-core": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.3.7.tgz", - "integrity": "sha512-LHq9du3ubLZFdK/BP0Ysy3zhHqRfBn80Uc+T5Hz3maFJBGhci1MafccnL3rpd5/3wVfRHAe6c+PnlO2PAavPTQ==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", "dev": true, + "license": "MIT", "dependencies": { - "@vue/reactivity": "3.3.7", - "@vue/shared": "3.3.7" + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" } }, "node_modules/@vue/runtime-dom": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.3.7.tgz", - "integrity": "sha512-PFQU1oeJxikdDmrfoNQay5nD4tcPNYixUBruZzVX/l0eyZvFKElZUjW4KctCcs52nnpMGO6UDK+jF5oV4GT5Lw==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", "dev": true, + "license": "MIT", "dependencies": { - "@vue/runtime-core": "3.3.7", - "@vue/shared": "3.3.7", - "csstype": "^3.1.2" + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.3.7.tgz", - "integrity": "sha512-UlpKDInd1hIZiNuVVVvLgxpfnSouxKQOSE2bOfQpBuGwxRV/JqqTCyyjXUWiwtVMyeRaZhOYYqntxElk8FhBhw==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", "dev": true, + "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.3.7", - "@vue/shared": "3.3.7" + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" }, "peerDependencies": { - "vue": "3.3.7" + "vue": "3.5.32" } }, "node_modules/@vue/shared": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.7.tgz", - "integrity": "sha512-N/tbkINRUDExgcPTBvxNkvHGu504k8lzlNQRITVnm6YjOjwa4r0nnbd4Jb01sNpur5hAllyRJzSK5PvB9PPwRg==", - "dev": true + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "dev": true, + "license": "MIT" }, "node_modules/acorn": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", - "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -1324,15 +1801,17 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1349,6 +1828,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -1358,6 +1838,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -1373,6 +1854,7 @@ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, + "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -1381,17 +1863,32 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "dev": true, + "license": "Python-2.0" }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -1400,13 +1897,15 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/beercss": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/beercss/-/beercss-2.3.0.tgz", "integrity": "sha512-ZG4wY52Bjd3aY8Mzh/fiOVDQH+Csv9tF2XJETtd7pTWl+/qIhdPxYqb4Nz2UxqgxZu5B8udCyj1EG0EEMEji0A==", "dev": true, + "license": "MIT", "dependencies": { "material-dynamic-colors": "^0.0.10", "sass": "^1.49.9", @@ -1414,53 +1913,38 @@ "vue": "^3.2.31" } }, - "node_modules/big-integer": { - "version": "1.6.51", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", - "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==", - "dev": true, - "engines": { - "node": ">=0.6" - } - }, "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" - } - }, - "node_modules/bplist-parser": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz", - "integrity": "sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==", - "dev": true, - "dependencies": { - "big-integer": "^1.6.44" }, - "engines": { - "node": ">= 5.10.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -1471,30 +1955,17 @@ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", "dev": true, + "license": "MIT", "engines": { "node": "*" } }, - "node_modules/bundle-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-3.0.0.tgz", - "integrity": "sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==", - "dev": true, - "dependencies": { - "run-applescript": "^5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -1504,6 +1975,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -1516,42 +1988,19 @@ } }, "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], + "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" + "node": ">= 14.16.0" }, - "engines": { - "node": ">= 6" + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/color-convert": { @@ -1559,6 +2008,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -1570,19 +2020,22 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -1592,6 +2045,7 @@ "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", "dev": true, + "license": "MIT", "dependencies": { "cross-spawn": "^7.0.1" }, @@ -1606,10 +2060,11 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1624,6 +2079,7 @@ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, + "license": "MIT", "bin": { "cssesc": "bin/cssesc" }, @@ -1632,18 +2088,20 @@ } }, "node_modules/csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "dev": true + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -1658,83 +2116,53 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/default-browser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", - "integrity": "sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==", - "dev": true, - "dependencies": { - "bundle-name": "^3.0.0", - "default-browser-id": "^3.0.0", - "execa": "^7.1.1", - "titleize": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-3.0.0.tgz", - "integrity": "sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==", - "dev": true, - "dependencies": { - "bplist-parser": "^0.2.0", - "untildify": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, "engines": { "node": ">=8" } }, "node_modules/devalue": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.2.tgz", - "integrity": "sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==", - "dev": true + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.3.tgz", + "integrity": "sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg==", + "dev": true, + "license": "MIT" }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "dev": true, + "license": "MIT", "dependencies": { "path-type": "^4.0.0" }, @@ -1747,6 +2175,7 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, + "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -1759,6 +2188,7 @@ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "dev": true, + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -1778,13 +2208,15 @@ "type": "github", "url": "https://github.com/sponsors/fb55" } - ] + ], + "license": "BSD-2-Clause" }, "node_modules/domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" }, @@ -1796,10 +2228,11 @@ } }, "node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -1814,6 +2247,7 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=0.12" }, @@ -1825,7 +2259,8 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/esbuild": { "version": "0.18.20", @@ -1833,6 +2268,7 @@ "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -1869,6 +2305,7 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -1877,16 +2314,18 @@ } }, "node_modules/eslint": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.53.0.tgz", - "integrity": "sha512-N4VuiPjXDUa4xVeV/GC/RV3hQW9Nw+Y463lkWaKKXKYMvmRiRDAtfpuPFLN+E1/6ZhyR8J2ig+eVREnYgUsiag==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.3", - "@eslint/js": "8.53.0", - "@humanwhocodes/config-array": "^0.11.13", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -1931,11 +2370,28 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", + "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, "node_modules/eslint-config-prettier": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", - "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, + "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -1948,28 +2404,31 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-html/-/eslint-plugin-html-7.1.0.tgz", "integrity": "sha512-fNLRraV/e6j8e3XYOC9xgND4j+U7b1Rq+OygMlLcMg+wI/IpVbF+ubQa3R78EjKB9njT6TQOlcK5rFKBVVtdfg==", "dev": true, + "license": "ISC", "dependencies": { "htmlparser2": "^8.0.1" } }, "node_modules/eslint-plugin-prettier": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.0.1.tgz", - "integrity": "sha512-m3u5RnR56asrwV/lDC4GHorlW75DsFfmUcjfCYylTUs85dBRnB7VM6xG8eCMJdeDRnppzmxZVf1GEPJvl1JmNg==", + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", "dev": true, + "license": "MIT", "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.8.5" + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" }, "engines": { "node": "^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/prettier" + "url": "https://opencollective.com/eslint-plugin-prettier" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "peerDependenciesMeta": { @@ -1982,22 +2441,23 @@ } }, "node_modules/eslint-plugin-svelte": { - "version": "2.34.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.34.1.tgz", - "integrity": "sha512-HnLzYevh9bLL0Rj2d4dmZY9EutN0BL5JsJRHqtJFIyaEmdxxd3ZuY5zNoSjIFhctFMSntsClbd6TwYjgaOY0Xw==", + "version": "2.46.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.46.1.tgz", + "integrity": "sha512-7xYr2o4NID/f9OEYMqxsEQsCsj4KaMy4q5sANaKkAb6/QeCjYFxRmDm2S3YC3A3pl1kyPZ/syOx/i7LcWYSbIw==", "dev": true, + "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@jridgewell/sourcemap-codec": "^1.4.14", - "debug": "^4.3.1", + "@eslint-community/eslint-utils": "^4.4.0", + "@jridgewell/sourcemap-codec": "^1.4.15", + "eslint-compat-utils": "^0.5.1", "esutils": "^2.0.3", - "known-css-properties": "^0.29.0", - "postcss": "^8.4.5", + "known-css-properties": "^0.35.0", + "postcss": "^8.4.38", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^6.0.0", - "postcss-selector-parser": "^6.0.11", - "semver": "^7.5.3", - "svelte-eslint-parser": ">=0.33.0 <1.0.0" + "postcss-selector-parser": "^6.1.0", + "semver": "^7.6.2", + "svelte-eslint-parser": "^0.43.0" }, "engines": { "node": "^14.17.0 || >=16.0.0" @@ -2006,8 +2466,8 @@ "url": "https://github.com/sponsors/ota-meshi" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0-0", - "svelte": "^3.37.0 || ^4.0.0" + "eslint": "^7.0.0 || ^8.0.0-0 || ^9.0.0-0", + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { "svelte": { @@ -2020,6 +2480,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -2033,6 +2494,7 @@ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -2045,6 +2507,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -2061,21 +2524,24 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, "node_modules/esm-env": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz", - "integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==", - "dev": true + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -2089,10 +2555,11 @@ } }, "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -2105,6 +2572,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } @@ -2114,6 +2582,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -2126,6 +2595,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } @@ -2135,6 +2605,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } @@ -2143,63 +2614,45 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" } }, - "node_modules/execa": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", - "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.1", - "human-signals": "^4.3.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^3.0.7", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": "^14.18.0 || ^16.14.0 || >=18.0.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-diff": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/fast-glob": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", - "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.4" + "micromatch": "^4.0.8" }, "engines": { "node": ">=8.6.0" @@ -2210,6 +2663,7 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, + "license": "ISC", "dependencies": { "is-glob": "^4.0.1" }, @@ -2221,19 +2675,22 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, + "license": "ISC", "dependencies": { "reusify": "^1.0.4" } @@ -2243,6 +2700,7 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, + "license": "MIT", "dependencies": { "flat-cache": "^3.0.4" }, @@ -2251,10 +2709,11 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -2267,6 +2726,7 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, + "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -2279,30 +2739,33 @@ } }, "node_modules/flat-cache": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz", - "integrity": "sha512-/qM2b3LUIaIgviBQovTLvijfyOQXPtSRnRK26ksj2J7rzPIecePUIpJsZ4T02Qg+xiAEKIs5K8dsHEd+VaKa/Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, + "license": "MIT", "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" }, "engines": { - "node": ">=12.0.0" + "node": "^10.12.0 || >=12.0.0" } }, "node_modules/flatted": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", - "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", - "dev": true + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" }, "node_modules/fs-extra": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", - "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -2316,7 +2779,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", @@ -2324,6 +2788,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -2332,23 +2797,13 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -2369,6 +2824,7 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, + "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, @@ -2377,10 +2833,11 @@ } }, "node_modules/globals": { - "version": "13.23.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", - "integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -2395,13 +2852,15 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, + "license": "MIT", "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", @@ -2421,31 +2880,36 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/guid-typescript": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2462,6 +2926,7 @@ "url": "https://github.com/sponsors/fb55" } ], + "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", @@ -2469,35 +2934,29 @@ "entities": "^4.4.0" } }, - "node_modules/human-signals": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", - "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", - "dev": true, - "engines": { - "node": ">=14.18.0" - } - }, "node_modules/ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/immutable": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.4.tgz", - "integrity": "sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==", - "dev": true + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" }, "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, + "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -2510,10 +2969,11 @@ } }, "node_modules/import-meta-resolve": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz", - "integrity": "sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", "dev": true, + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -2524,6 +2984,7 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.19" } @@ -2532,7 +2993,9 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, + "license": "ISC", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -2542,13 +3005,15 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, + "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" }, @@ -2556,26 +3021,12 @@ "node": ">=8" } }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -2585,6 +3036,7 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, + "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" }, @@ -2592,29 +3044,12 @@ "node": ">=0.10.0" } }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -2624,60 +3059,24 @@ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-wsl/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -2689,25 +3088,29 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "dev": true, + "license": "MIT", "dependencies": { "universalify": "^2.0.0" }, @@ -2720,6 +3123,7 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, + "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -2729,21 +3133,24 @@ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/known-css-properties": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.29.0.tgz", - "integrity": "sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==", - "dev": true + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.35.0.tgz", + "integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==", + "dev": true, + "license": "MIT" }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -2757,6 +3164,7 @@ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } @@ -2766,6 +3174,7 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, + "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -2780,76 +3189,61 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } + "dev": true, + "license": "MIT" }, "node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/material-dynamic-colors": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/material-dynamic-colors/-/material-dynamic-colors-0.0.10.tgz", "integrity": "sha512-UGgmkqcHfYxHeK4cuZMSw2Ymme4E+GO0jt+0CpQ8Chw+2Aad+n85l0+NK8xHKP4Fb9ATpDVWdCYMxsKngKtIoA==", - "dev": true - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 8" } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { "node": ">=8.6" } }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8.6" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/min-indent": { @@ -2857,15 +3251,17 @@ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2878,6 +3274,7 @@ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2887,6 +3284,7 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, + "license": "MIT", "dependencies": { "minimist": "^1.2.6" }, @@ -2899,6 +3297,7 @@ "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -2908,20 +3307,22 @@ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -2929,6 +3330,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -2940,104 +3342,57 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/natural-compare-lite": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/npm-run-path": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", - "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, + "license": "ISC", "dependencies": { "wrappy": "1" } }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/open": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/open/-/open-9.1.0.tgz", - "integrity": "sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==", - "dev": true, - "dependencies": { - "default-browser": "^4.0.0", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, + "license": "MIT", "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { "node": ">= 0.8.0" @@ -3048,6 +3403,7 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, + "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -3063,6 +3419,7 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, + "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -3078,6 +3435,7 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, + "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -3090,6 +3448,7 @@ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3099,6 +3458,7 @@ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -3108,6 +3468,7 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3117,32 +3478,35 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, + "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -3158,10 +3522,11 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -3172,6 +3537,7 @@ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", "dev": true, + "license": "MIT", "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" @@ -3201,6 +3567,7 @@ "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12.0" }, @@ -3231,6 +3598,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "engines": { "node": ">=12.0" }, @@ -3239,10 +3607,11 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.0.13", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", - "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, + "license": "MIT", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3256,15 +3625,17 @@ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8.0" } }, "node_modules/prettier": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.0.tgz", - "integrity": "sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -3276,10 +3647,11 @@ } }, "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", "dev": true, + "license": "MIT", "dependencies": { "fast-diff": "^1.1.2" }, @@ -3288,10 +3660,11 @@ } }, "node_modules/prettier-plugin-svelte": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.1.0.tgz", - "integrity": "sha512-96+AZxs2ESqIFA9j+o+DHqY+BsUglezfl553LQd6VOtTyJq5GPuBEb3ElxF2cerFzKlYKttlH/VcVmRNj5oc3A==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.1.tgz", + "integrity": "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==", "dev": true, + "license": "MIT", "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" @@ -3302,6 +3675,7 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -3324,18 +3698,21 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, + "license": "MIT", "engines": { - "node": ">=8.10.0" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/resolve-from": { @@ -3343,15 +3720,17 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } }, "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, + "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -3361,7 +3740,9 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, + "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -3373,10 +3754,11 @@ } }, "node_modules/rollup": { - "version": "3.29.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", - "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", + "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", "dev": true, + "license": "MIT", "bin": { "rollup": "dist/bin/rollup" }, @@ -3388,110 +3770,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/run-applescript": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-5.0.0.tgz", - "integrity": "sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==", - "dev": true, - "dependencies": { - "execa": "^5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-applescript/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/run-applescript/node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/run-applescript/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-applescript/node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/run-applescript/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/run-applescript/node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-applescript/node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -3511,6 +3789,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } @@ -3520,6 +3799,7 @@ "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", "dev": true, + "license": "MIT", "dependencies": { "mri": "^1.1.0" }, @@ -3532,6 +3812,7 @@ "resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz", "integrity": "sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==", "dev": true, + "license": "MIT", "dependencies": { "es6-promise": "^3.1.2", "graceful-fs": "^4.1.3", @@ -3543,7 +3824,9 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, + "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -3552,13 +3835,14 @@ } }, "node_modules/sass": { - "version": "1.69.5", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz", - "integrity": "sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==", + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", + "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", "dev": true, + "license": "MIT", "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", + "chokidar": "^4.0.0", + "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { @@ -3566,16 +3850,17 @@ }, "engines": { "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" } }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -3584,16 +3869,18 @@ } }, "node_modules/set-cookie-parser": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", - "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", - "dev": true + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "dev": true, + "license": "MIT" }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, + "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -3606,35 +3893,42 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, "node_modules/sirv": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.3.tgz", - "integrity": "sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", "dev": true, + "license": "MIT", "dependencies": { - "@polka/url": "^1.0.0-next.20", - "mrmime": "^1.0.0", + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", "totalist": "^3.0.0" }, "engines": { "node": ">= 10" } }, + "node_modules/sirv/node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3644,6 +3938,7 @@ "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.10.0.tgz", "integrity": "sha512-R5ocFmKZQFfSTstfOtHjJuAwbpGyf9qjQa1egyhvXSbM7emjrtLXtGdZsDJDABC85YBfVvrOiGWKSYXPKdvP1g==", "dev": true, + "license": "MIT", "dependencies": { "buffer-crc32": "^0.2.5", "minimist": "^1.2.0", @@ -3655,10 +3950,11 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -3668,13 +3964,15 @@ "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", "deprecated": "Please use @jridgewell/sourcemap-codec instead", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, + "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" }, @@ -3682,23 +3980,12 @@ "node": ">=8" } }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, + "license": "MIT", "dependencies": { "min-indent": "^1.0.0" }, @@ -3711,6 +3998,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -3723,6 +4011,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -3735,6 +4024,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.59.2.tgz", "integrity": "sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 8" } @@ -3744,6 +4034,7 @@ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-2.10.3.tgz", "integrity": "sha512-Nt1aWHTOKFReBpmJ1vPug0aGysqPwJh2seM1OvICfM2oeyaA62mOiy5EvkXhltGfhCcIQcq2LoE0l1CwcWPjlw==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.9", "chokidar": "^3.4.1", @@ -3761,17 +4052,82 @@ "svelte": "^3.24.0" } }, + "node_modules/svelte-check/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/svelte-check/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/svelte-check/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/svelte-check/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/svelte-eslint-parser": { - "version": "0.33.1", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.33.1.tgz", - "integrity": "sha512-vo7xPGTlKBGdLH8T5L64FipvTrqv3OQRx9d2z5X05KKZDlF4rQk8KViZO4flKERY+5BiVdOh7zZ7JGJWo5P0uA==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz", + "integrity": "sha512-GpU52uPKKcVnh8tKN5P4UZpJ/fUDndmq7wfsvoVXsyP+aY0anol7Yqo01fyrlaWGMFfm4av5DyrjlaXdLRJvGA==", "dev": true, + "license": "MIT", "dependencies": { - "eslint-scope": "^7.0.0", - "eslint-visitor-keys": "^3.0.0", - "espree": "^9.0.0", - "postcss": "^8.4.29", - "postcss-scss": "^4.0.8" + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "postcss": "^8.4.39", + "postcss-scss": "^4.0.9" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -3780,7 +4136,7 @@ "url": "https://github.com/sponsors/ota-meshi" }, "peerDependencies": { - "svelte": "^3.37.0 || ^4.0.0" + "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { "svelte": { @@ -3793,6 +4149,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -3809,6 +4166,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } @@ -3818,6 +4176,7 @@ "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz", "integrity": "sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==", "dev": true, + "license": "ISC", "engines": { "node": "^12.20 || ^14.13.1 || >= 16" }, @@ -3831,6 +4190,7 @@ "integrity": "sha512-sNPBnqYD6FnmdBrUmBCaqS00RyCsCpj2BG58A1JBswNF7b0OKviwxqVrOL/CKyJrLSClrSeqQv5BXNg2RUbPOw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "dependencies": { "@types/pug": "^2.0.4", "@types/sass": "^1.16.0", @@ -3896,59 +4256,51 @@ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", "dev": true, + "license": "MIT", "dependencies": { "sourcemap-codec": "^1.4.8" } }, "node_modules/synckit": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.5.tgz", - "integrity": "sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, + "license": "MIT", "dependencies": { - "@pkgr/utils": "^2.3.1", - "tslib": "^2.5.0" + "@pkgr/core": "^0.2.9" }, "engines": { "node": "^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/unts" + "url": "https://opencollective.com/synckit" } }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/tiny-glob": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", "dev": true, + "license": "MIT", "dependencies": { "globalyzer": "0.1.0", "globrex": "^0.1.2" } }, - "node_modules/titleize": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", - "integrity": "sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -3961,21 +4313,24 @@ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" }, "node_modules/tsutils": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", "dev": true, + "license": "MIT", "dependencies": { "tslib": "^1.8.1" }, @@ -3990,13 +4345,15 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true + "dev": true, + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, @@ -4009,6 +4366,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -4021,6 +4379,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4030,10 +4389,11 @@ } }, "node_modules/undici": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.26.5.tgz", - "integrity": "sha512-cSb4bPFd5qgR7qr2jYAi0hlX9n5YKK2ONKkLFkxl+v/9BvC0sOpZjBHDBSXc5lWAf5ty9oZdRXytBIHzgUcerw==", + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", "dev": true, + "license": "MIT", "dependencies": { "@fastify/busboy": "^2.0.0" }, @@ -4041,29 +4401,29 @@ "node": ">=14.0" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, + "license": "MIT", "engines": { "node": ">= 10.0.0" } }, - "node_modules/untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } @@ -4072,22 +4432,25 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "dev": true, + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } }, "node_modules/vite": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz", - "integrity": "sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==", + "version": "4.5.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", + "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", "dev": true, + "license": "MIT", "dependencies": { "esbuild": "^0.18.10", "postcss": "^8.4.27", @@ -4139,39 +4502,43 @@ } }, "node_modules/vite-plugin-top-level-await": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.3.1.tgz", - "integrity": "sha512-55M1h4NAwkrpxPNOJIBzKZFihqLUzIgnElLSmPNPMR2Fn9+JHKaNg3sVX1Fq+VgvuBksQYxiD3OnwQAUu7kaPQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.6.0.tgz", + "integrity": "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww==", "dev": true, + "license": "MIT", "dependencies": { - "@rollup/plugin-virtual": "^3.0.1", - "@swc/core": "^1.3.10", - "uuid": "^9.0.0" + "@rollup/plugin-virtual": "^3.0.2", + "@swc/core": "^1.12.14", + "@swc/wasm": "^1.12.14", + "uuid": "10.0.0" }, "peerDependencies": { "vite": ">=2.8" } }, "node_modules/vite-plugin-top-level-await/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], + "license": "MIT", "bin": { "uuid": "dist/bin/uuid" } }, "node_modules/vite-plugin-wasm": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.2.2.tgz", - "integrity": "sha512-cdbBUNR850AEoMd5nvLmnyeq63CSfoP1ctD/L2vLk/5+wsgAPlAVAzUK5nGKWO/jtehNlrSSHLteN+gFQw7VOA==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz", + "integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==", "dev": true, + "license": "MIT", "peerDependencies": { - "vite": "^2 || ^3 || ^4" + "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8" } }, "node_modules/vitefu": { @@ -4179,6 +4546,7 @@ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", "dev": true, + "license": "MIT", "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" }, @@ -4189,16 +4557,17 @@ } }, "node_modules/vue": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.3.7.tgz", - "integrity": "sha512-YEMDia1ZTv1TeBbnu6VybatmSteGOS3A3YgfINOfraCbf85wdKHzscD6HSS/vB4GAtI7sa1XPX7HcQaJ1l24zA==", + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", "dev": true, + "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.3.7", - "@vue/compiler-sfc": "3.3.7", - "@vue/runtime-dom": "3.3.7", - "@vue/server-renderer": "3.3.7", - "@vue/shared": "3.3.7" + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" }, "peerDependencies": { "typescript": "*" @@ -4214,6 +4583,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -4224,23 +4594,29 @@ "node": ">= 8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "dev": true, + "license": "ISC", "engines": { "node": ">= 6" } @@ -4250,6 +4626,7 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, diff --git a/web-client/iron-svelte-client/src/lib/login/login.svelte b/web-client/iron-svelte-client/src/lib/login/login.svelte index 22e725cdba..78a823edfd 100644 --- a/web-client/iron-svelte-client/src/lib/login/login.svelte +++ b/web-client/iron-svelte-client/src/lib/login/login.svelte @@ -1,6 +1,6 @@