feat: add usbip-rs CLI tool with vsock transport

Convert to cargo workspace with lib/ and cli/ crates. Add Nix flake
for building and development. Extract handle_urb_loop and add
read_urb_command to the library for CLI consumption.

Implement the usbip-rs CLI binary with clap subcommands:
- client listen: accept incoming connections via vhci_hcd sysfs
- host connect: passthrough real USB devices via nusb
- test_hid connect: export a simulated HID keyboard for testing

Add vsock transport layer and vhci_hcd sysfs interaction module.
Apply rustfmt formatting project-wide and add rustfmt/clippy to devShell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Davíð Steinn Geirsson 2026-03-22 10:41:42 +00:00
parent 0878920532
commit 30d3c9532e
31 changed files with 4360 additions and 199 deletions

View file

@ -1,49 +0,0 @@
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build --all-features --verbose
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- name: Check code format
run: cargo fmt -- --check
- name: Check clippy
run: cargo clippy --all-features -- --deny warnings
test:
runs-on: '${{ matrix.os }}'
strategy:
matrix:
include:
- os: macos-latest
- os: ubuntu-latest
- os: windows-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --all-features
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Build docs
run: cargo doc --verbose

View file

@ -1,15 +0,0 @@
name: 'Close stale issues and PRs'
on:
schedule:
- cron: '0 0 * * *'
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
stale-pr-message: 'This pull request is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
days-before-stale: 60
days-before-close: 7

2
.gitignore vendored
View file

@ -1,2 +1,2 @@
/target
Cargo.lock
/result

836
Cargo.lock generated Normal file
View file

@ -0,0 +1,836 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse 0.2.7",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse 1.0.0",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "bytes"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cc"
version = "1.2.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream 1.0.0",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "env_filter"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
dependencies = [
"anstream 0.6.21",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "io-kit-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06d3a048d09fbb6597dbf7c69f40d14df4a49487db1487191618c893fc3b1c26"
dependencies = [
"core-foundation-sys",
"mach2",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "jiff"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "libc"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "libusb1-sys"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da050ade7ac4ff1ba5379af847a10a10a8e284181e060105bf8d86960ce9ce0f"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "mach2"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
dependencies = [
"libc",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "mio"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "nix"
version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
"memoffset",
]
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "nusb"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a330b3bc7f8b4fc729a4c63164b3927eeeaced198222a3ce6b8b6e034851b7a"
dependencies = [
"core-foundation",
"core-foundation-sys",
"futures-core",
"io-kit-sys",
"linux-raw-sys",
"log",
"once_cell",
"rustix",
"slab",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
dependencies = [
"portable-atomic",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rusb"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab9f9ff05b63a786553a4c02943b74b34a988448671001e9a27e2f0565cc05a4"
dependencies = [
"libc",
"libusb1-sys",
"serde",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[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.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tokio"
version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-vsock"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b319ef9394889dab2e1b4f0085b45ba11d0c79dc9d1a9d1afc057d009d0f1c7"
dependencies = [
"bytes",
"futures",
"libc",
"tokio",
"vsock",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "usbip-rs"
version = "0.8.0"
dependencies = [
"env_logger",
"log",
"num-derive",
"num-traits",
"nusb",
"rusb",
"serde",
"tokio",
]
[[package]]
name = "usbip-rs-cli"
version = "0.1.0"
dependencies = [
"clap",
"env_logger",
"log",
"nusb",
"tokio",
"tokio-vsock",
"usbip-rs",
]
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vsock"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b82aeb12ad864eb8cd26a6c21175d0bdc66d398584ee6c93c76964c3bcfc78ff"
dependencies = [
"libc",
"nix",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]

View file

@ -1,27 +1,3 @@
[package]
name = "usbip"
version = "0.8.0"
authors = ["Jiajie Chen <c@jia.je>"]
edition = "2024"
license = "MIT"
repository = "https://github.com/jiegec/usbip"
description = "A library to run USB/IP server"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
tokio = { version = "1.22.0", features = ["rt", "net", "io-util", "sync"] }
log = "0.4.17"
num-traits = "0.2.15"
num-derive = "0.4.2"
rusb = "0.9.3"
serde = { version = "1.0", features = ["derive"], optional = true }
nusb = "0.2.1"
[dev-dependencies]
tokio = { version = "1.22.0", features = ["full"] }
env_logger = "0.11.7"
[features]
default = []
serde = ["dep:serde", "rusb/serde"]
[workspace]
members = ["lib", "cli"]
resolver = "2"

19
cli/Cargo.toml Normal file
View file

@ -0,0 +1,19 @@
[package]
name = "usbip-rs-cli"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "USB/IP over vsock CLI tool"
[[bin]]
name = "usbip-rs"
path = "src/main.rs"
[dependencies]
usbip-rs = { path = "../lib" }
clap = { version = "4", features = ["derive"] }
tokio-vsock = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time"] }
log = "0.4"
env_logger = "0.11"
nusb = "0.2.1"

107
cli/src/client.rs Normal file
View file

@ -0,0 +1,107 @@
use log::{debug, error, info, trace, warn};
use std::os::unix::io::IntoRawFd;
use tokio::io::AsyncReadExt;
use tokio::signal;
use usbip_rs::UsbDevice;
use usbip_rs::usbip_protocol::{OP_REP_IMPORT, USBIP_VERSION};
use crate::transport;
use crate::vhci;
const HANDSHAKE_SIZE: usize = 320;
pub async fn run(port: u32) -> std::io::Result<()> {
let listener = transport::listen_vsock(port)?;
info!("Client listening on vsock port {port}");
loop {
tokio::select! {
accept_result = listener.accept() => {
match accept_result {
Ok((stream, addr)) => {
info!("Accepted connection from CID={}", addr.cid());
tokio::spawn(async move {
if let Err(e) = handle_connection(stream).await {
error!("Connection handler error: {e}");
}
});
}
Err(e) => {
error!("Accept error: {e}");
}
}
}
_ = signal::ctrl_c() => {
info!("Received shutdown signal, stopping listener");
break;
}
}
}
Ok(())
}
async fn handle_connection(mut stream: tokio_vsock::VsockStream) -> std::io::Result<()> {
// Read handshake (320 bytes)
let mut handshake = [0u8; HANDSHAKE_SIZE];
stream
.read_exact(&mut handshake)
.await
.map_err(|e| std::io::Error::other(format!("Failed to read handshake: {e}")))?;
trace!("Handshake bytes: {:02x?}", &handshake[..]);
// Validate header
let version = u16::from_be_bytes(handshake[0..2].try_into().unwrap());
let command = u16::from_be_bytes(handshake[2..4].try_into().unwrap());
let status = u32::from_be_bytes(handshake[4..8].try_into().unwrap());
if version != USBIP_VERSION {
warn!("Invalid handshake version: {version:#06x}, expected {USBIP_VERSION:#06x}");
return Err(std::io::Error::other("Invalid handshake version"));
}
if command != OP_REP_IMPORT {
warn!("Invalid handshake command: {command:#06x}, expected {OP_REP_IMPORT:#06x}");
return Err(std::io::Error::other("Invalid handshake command"));
}
if status != 0 {
warn!("Handshake status indicates failure: {status}");
return Err(std::io::Error::other("Handshake status indicates failure"));
}
// Parse device info
let device = UsbDevice::from_bytes(&handshake[8..]);
info!(
"Importing device {:04x}:{:04x} (bus_id={}, speed={})",
device.vendor_id, device.product_id, device.bus_id, device.speed
);
debug!(
"Device details: class={:02x} subclass={:02x} protocol={:02x} configs={}",
device.device_class,
device.device_subclass,
device.device_protocol,
device.num_configurations
);
trace!("Full device: {:?}", device);
// Find free vhci_hcd port
let (vhci_port, attach_path) = vhci::find_free_port(device.speed)?;
// Extract raw fd — into_raw_fd() takes ownership without closing
// The kernel's vhci_hcd will own the fd after the sysfs write
let raw_fd = stream.into_raw_fd();
debug!("Extracted raw fd={raw_fd} for vhci handoff");
// Attach to vhci_hcd (devid=0 for simplified protocol)
vhci::attach(vhci_port, raw_fd, 0, device.speed, &attach_path)?;
info!(
"Device {:04x}:{:04x} attached on vhci port {vhci_port}",
device.vendor_id, device.product_id
);
Ok(())
}

281
cli/src/host.rs Normal file
View file

@ -0,0 +1,281 @@
use log::{debug, error, info, warn};
use nusb::MaybeFuture;
use std::io::Result;
use std::sync::{Arc, Mutex};
use tokio::io::AsyncWriteExt;
use usbip_rs::{
EndpointAttributes, NusbUsbHostDeviceHandler, NusbUsbHostInterfaceHandler, UsbDevice,
UsbEndpoint, UsbInterface, UsbInterfaceHandler, usbip_protocol::UsbIpResponse,
};
use crate::transport;
/// Parse "/dev/bus/usb/BBB/DDD" to (bus_number, device_address).
fn parse_dev_bus_usb(path: &str) -> Result<(u8, u8)> {
let rest = path
.strip_prefix("/dev/bus/usb/")
.ok_or_else(|| std::io::Error::other(format!("Not a /dev/bus/usb/ path: {path}")))?;
let parts: Vec<&str> = rest.split('/').collect();
if parts.len() != 2 {
return Err(std::io::Error::other(format!(
"Expected /dev/bus/usb/BBB/DDD, got: {path}"
)));
}
let bus: u8 = parts[0]
.parse()
.map_err(|e| std::io::Error::other(format!("Invalid bus number '{}': {e}", parts[0])))?;
let dev: u8 = parts[1]
.parse()
.map_err(|e| std::io::Error::other(format!("Invalid device number '{}': {e}", parts[1])))?;
Ok((bus, dev))
}
/// Parse bus ID like "1-2" by reading sysfs at `/sys/bus/usb/devices/<bus_id>/busnum` and `devnum`.
fn parse_bus_id(bus_id: &str) -> Result<(u8, u8)> {
let base = format!("/sys/bus/usb/devices/{bus_id}");
let busnum_str = std::fs::read_to_string(format!("{base}/busnum")).map_err(|e| {
std::io::Error::other(format!("Failed to read busnum for device '{bus_id}': {e}"))
})?;
let devnum_str = std::fs::read_to_string(format!("{base}/devnum")).map_err(|e| {
std::io::Error::other(format!("Failed to read devnum for device '{bus_id}': {e}"))
})?;
let bus: u8 = busnum_str.trim().parse().map_err(|e| {
std::io::Error::other(format!("Invalid busnum '{}': {e}", busnum_str.trim()))
})?;
let dev: u8 = devnum_str.trim().parse().map_err(|e| {
std::io::Error::other(format!("Invalid devnum '{}': {e}", devnum_str.trim()))
})?;
Ok((bus, dev))
}
/// Route to the right parser based on format.
fn parse_device_arg(device: &str) -> Result<(u8, u8)> {
if device.starts_with("/dev/bus/usb/") {
parse_dev_bus_usb(device)
} else {
parse_bus_id(device)
}
}
/// Enumerate nusb devices, find by bus number and device address.
fn open_device(bus: u8, dev: u8) -> Result<(nusb::Device, nusb::DeviceInfo)> {
let device_list = nusb::list_devices()
.wait()
.map_err(|e| std::io::Error::other(format!("Failed to enumerate USB devices: {e}")))?;
for dev_info in device_list {
let matches;
#[cfg(target_os = "linux")]
{
matches = dev_info.busnum() == bus && dev_info.device_address() == dev;
}
#[cfg(not(target_os = "linux"))]
{
let _ = (bus, dev);
matches = false;
}
if matches {
debug!(
"Found device: bus={} dev={} {:04x}:{:04x}",
bus,
dev,
dev_info.vendor_id(),
dev_info.product_id()
);
let device = dev_info.open().wait().map_err(|e| {
std::io::Error::other(format!(
"Failed to open USB device bus={bus} dev={dev}: {e}"
))
})?;
return Ok((device, dev_info));
}
}
Err(std::io::Error::other(format!(
"USB device not found: bus={bus} dev={dev}"
)))
}
/// Build UsbDevice from nusb device. Adapted from the library's
/// `UsbIpServer::with_nusb_devices()` logic but for a single device
/// with better error handling.
fn build_usb_device(dev: nusb::Device, dev_info: nusb::DeviceInfo) -> Result<UsbDevice> {
let cfg = dev
.active_configuration()
.map_err(|e| std::io::Error::other(format!("Failed to get active configuration: {e}")))?;
let mut interfaces = vec![];
for intf in cfg.interfaces() {
let intf_num = intf.interface_number();
let claimed = match dev.claim_interface(intf_num).wait() {
Ok(claimed) => claimed,
Err(e) => {
warn!("Failed to claim interface {intf_num}: {e} (may be in use by kernel driver)");
continue;
}
};
let alt_setting = match claimed.descriptors().next() {
Some(alt) => alt,
None => {
warn!("Interface {intf_num} has no alt settings, skipping");
continue;
}
};
let mut endpoints = vec![];
for ep_desc in alt_setting.endpoints() {
endpoints.push(UsbEndpoint {
address: ep_desc.address(),
attributes: ep_desc.transfer_type() as u8,
max_packet_size: ep_desc.max_packet_size() as u16,
interval: ep_desc.interval(),
});
}
let handler = Arc::new(Mutex::new(
Box::new(NusbUsbHostInterfaceHandler::new(Arc::new(Mutex::new(
claimed.clone(),
)))) as Box<dyn UsbInterfaceHandler + Send>,
));
interfaces.push(UsbInterface {
interface_class: alt_setting.class(),
interface_subclass: alt_setting.subclass(),
interface_protocol: alt_setting.protocol(),
endpoints,
string_interface: alt_setting.string_index().map(|nz| nz.get()).unwrap_or(0),
class_specific_descriptor: Vec::new(),
handler,
});
debug!(
"Claimed interface {intf_num}: class={:02x} subclass={:02x} protocol={:02x}",
alt_setting.class(),
alt_setting.subclass(),
alt_setting.protocol()
);
}
if interfaces.is_empty() {
return Err(std::io::Error::other(
"No interfaces could be claimed on the device",
));
}
// Platform-specific bus number (Linux-only)
#[cfg(target_os = "linux")]
let bus_num_val: u32 = dev_info.busnum() as u32;
#[cfg(not(target_os = "linux"))]
let bus_num_val: u32 = 0;
let device_address = dev_info.device_address();
let mut device = UsbDevice {
path: format!("/sys/bus/{}/{}/{}", bus_num_val, device_address, 0),
bus_id: format!("{}-{}-{}", bus_num_val, device_address, 0),
bus_num: bus_num_val,
dev_num: 0,
speed: dev_info.speed().unwrap_or(nusb::Speed::Super) as u32,
vendor_id: dev_info.vendor_id(),
product_id: dev_info.product_id(),
device_class: dev_info.class(),
device_subclass: dev_info.subclass(),
device_protocol: dev_info.protocol(),
device_bcd: dev_info.device_version().into(),
configuration_value: cfg.configuration_value(),
num_configurations: dev.configurations().count() as u8,
ep0_in: UsbEndpoint {
address: 0x80,
attributes: EndpointAttributes::Control as u8,
max_packet_size: 16,
interval: 0,
},
ep0_out: UsbEndpoint {
address: 0x00,
attributes: EndpointAttributes::Control as u8,
max_packet_size: 16,
interval: 0,
},
interfaces,
device_handler: Some(Arc::new(Mutex::new(Box::new(
NusbUsbHostDeviceHandler::new(Arc::new(Mutex::new(dev))),
)))),
..UsbDevice::default()
};
// Set strings
if let Some(s) = dev_info.manufacturer_string() {
device.string_manufacturer = device.new_string(s);
}
if let Some(s) = dev_info.product_string() {
device.string_product = device.new_string(s);
}
if let Some(s) = dev_info.serial_number() {
device.string_serial = device.new_string(s);
}
info!(
"Built UsbDevice: {:04x}:{:04x} '{}' '{}' with {} interface(s)",
device.vendor_id,
device.product_id,
dev_info.manufacturer_string().unwrap_or(""),
dev_info.product_string().unwrap_or(""),
device.interfaces.len()
);
Ok(device)
}
/// Main entry point for the host connect command.
pub async fn run(cid: u32, port: u32, device_arg: &str) -> Result<()> {
info!("Host connect: device={device_arg} -> vsock CID={cid} port={port}");
// 1. Parse device argument
let (bus, dev) = parse_device_arg(device_arg)?;
info!("Resolved device: bus={bus} dev={dev}");
// 2. Open the USB device via nusb
let (nusb_dev, dev_info) = open_device(bus, dev)?;
info!(
"Opened USB device: {:04x}:{:04x}",
dev_info.vendor_id(),
dev_info.product_id()
);
// 3. Build a UsbDevice with interface handlers
let device = build_usb_device(nusb_dev, dev_info)?;
// 4. Connect via vsock
let mut stream = transport::connect_vsock(cid, port).await?;
info!("Connected to vsock CID={cid} port={port}");
// 5. Send OP_REP_IMPORT with device info (simplified handshake)
let response = UsbIpResponse::op_rep_import_success(&device);
let response_bytes = response.to_bytes();
debug!("Sending OP_REP_IMPORT ({} bytes)", response_bytes.len());
stream
.write_all(&response_bytes)
.await
.map_err(|e| std::io::Error::other(format!("Failed to send device info: {e}")))?;
info!("Sent device info to client");
// 6. Enter URB handling loop
info!("Entering URB handling loop");
let result = usbip_rs::handle_urb_loop(&mut stream, &device).await;
match &result {
Ok(()) => info!("URB loop ended normally"),
Err(e) => error!("URB loop ended with error: {e}"),
}
result
}

98
cli/src/main.rs Normal file
View file

@ -0,0 +1,98 @@
use clap::{Parser, Subcommand};
mod client;
mod host;
mod test_hid;
mod transport;
mod vhci;
#[derive(Parser)]
#[command(name = "usbip-rs", about = "USB/IP over vsock")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Import USB devices from a remote host
Client {
#[command(subcommand)]
action: ClientAction,
},
/// Export a USB device to a remote client
Host {
#[command(subcommand)]
action: HostAction,
},
/// Export a simulated HID keyboard for testing
#[command(name = "test_hid")]
TestHid {
#[command(subcommand)]
action: TestHidAction,
},
}
#[derive(Subcommand)]
enum ClientAction {
/// Listen for incoming connections
Listen {
/// Vsock address: vsock:<port>
address: String,
},
}
#[derive(Subcommand)]
enum HostAction {
/// Connect to a listening client
Connect {
/// Vsock address: vsock:[<cid>:]<port>
address: String,
/// USB device: /dev/bus/usb/BBB/DDD or bus ID (e.g. 1-2)
device: String,
},
}
#[derive(Subcommand)]
enum TestHidAction {
/// Connect to a listening client with a simulated HID keyboard
Connect {
/// Vsock address: vsock:[<cid>:]<port>
address: String,
},
}
#[tokio::main]
async fn main() {
env_logger::Builder::from_default_env()
.format_timestamp(None)
.init();
let cli = Cli::parse();
let result = match cli.command {
Commands::Client { action } => match action {
ClientAction::Listen { address } => {
let addr = transport::parse_vsock_addr(&address).expect("Invalid vsock address");
client::run(addr.port).await
}
},
Commands::Host { action } => match action {
HostAction::Connect { address, device } => {
let addr = transport::parse_vsock_addr(&address).expect("Invalid vsock address");
host::run(addr.cid, addr.port, &device).await
}
},
Commands::TestHid { action } => match action {
TestHidAction::Connect { address } => {
let addr = transport::parse_vsock_addr(&address).expect("Invalid vsock address");
test_hid::run(addr.cid, addr.port).await
}
},
};
if let Err(e) = result {
log::error!("{e}");
std::process::exit(1);
}
}

64
cli/src/test_hid.rs Normal file
View file

@ -0,0 +1,64 @@
use log::info;
use std::sync::{Arc, Mutex};
use tokio::io::AsyncWriteExt;
use usbip_rs::{
ClassCode, UsbDevice, UsbEndpoint, UsbInterfaceHandler, hid::UsbHidKeyboardHandler,
usbip_protocol::UsbIpResponse,
};
use crate::transport;
pub async fn run(cid: u32, port: u32) -> std::io::Result<()> {
// Create simulated HID keyboard
let handler = Arc::new(Mutex::new(
Box::new(UsbHidKeyboardHandler::new_keyboard()) as Box<dyn UsbInterfaceHandler + Send>
));
let device = UsbDevice::new(0).with_interface(
ClassCode::HID as u8,
0x00,
0x00,
Some("Test HID Keyboard"),
vec![UsbEndpoint {
address: 0x81, // IN
attributes: 0x03, // Interrupt
max_packet_size: 0x08, // 8 bytes
interval: 10,
}],
handler.clone(),
);
info!(
"Created simulated HID keyboard {:04x}:{:04x}",
device.vendor_id, device.product_id
);
// Connect via vsock
let mut stream = transport::connect_vsock(cid, port).await?;
// Send device info (simplified handshake)
let handshake = UsbIpResponse::op_rep_import_success(&device).to_bytes();
stream
.write_all(&handshake)
.await
.map_err(|e| std::io::Error::other(format!("Failed to send handshake: {e}")))?;
info!("Handshake sent, entering URB loop");
// Spawn key event simulator
let handler_clone = handler.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
let mut h = handler_clone.lock().unwrap();
if let Some(hid) = h.as_any().downcast_mut::<UsbHidKeyboardHandler>() {
hid.pending_key_events
.push_back(usbip_rs::hid::UsbHidKeyboardReport::from_ascii(b'1'));
info!("Simulated key event '1'");
}
}
});
// Handle URBs
usbip_rs::handle_urb_loop(&mut stream, &device).await
}

62
cli/src/transport.rs Normal file
View file

@ -0,0 +1,62 @@
use std::io::Result;
use tokio_vsock::{VMADDR_CID_ANY, VsockListener, VsockStream};
#[derive(Debug, Clone)]
pub struct VsockAddr {
pub cid: u32,
pub port: u32,
}
const DEFAULT_CID: u32 = 2;
/// Parse "vsock:<port>" or "vsock:<cid>:<port>". CID defaults to 2.
pub fn parse_vsock_addr(addr: &str) -> Result<VsockAddr> {
let rest = addr.strip_prefix("vsock:").ok_or_else(|| {
std::io::Error::other(format!("Address must start with 'vsock:', got '{addr}'"))
})?;
let parts: Vec<&str> = rest.split(':').collect();
match parts.len() {
1 => {
let port = parts[0]
.parse::<u32>()
.map_err(|e| std::io::Error::other(format!("Invalid port '{}': {e}", parts[0])))?;
Ok(VsockAddr {
cid: DEFAULT_CID,
port,
})
}
2 => {
let cid = parts[0]
.parse::<u32>()
.map_err(|e| std::io::Error::other(format!("Invalid CID '{}': {e}", parts[0])))?;
let port = parts[1]
.parse::<u32>()
.map_err(|e| std::io::Error::other(format!("Invalid port '{}': {e}", parts[1])))?;
Ok(VsockAddr { cid, port })
}
_ => Err(std::io::Error::other(format!(
"Invalid vsock address format: '{addr}'. Expected 'vsock:<port>' or 'vsock:<cid>:<port>'"
))),
}
}
pub async fn connect_vsock(cid: u32, port: u32) -> Result<VsockStream> {
log::info!("Connecting to vsock CID={cid} port={port}");
let addr = tokio_vsock::VsockAddr::new(cid, port);
let stream = VsockStream::connect(addr).await.map_err(|e| {
std::io::Error::other(format!(
"Failed to connect to vsock CID={cid} port={port}: {e}"
))
})?;
log::info!("Connected to vsock CID={cid} port={port}");
Ok(stream)
}
pub fn listen_vsock(port: u32) -> Result<VsockListener> {
log::info!("Listening on vsock port={port}");
let addr = tokio_vsock::VsockAddr::new(VMADDR_CID_ANY, port);
let listener = VsockListener::bind(addr)
.map_err(|e| std::io::Error::other(format!("Failed to bind vsock port={port}: {e}")))?;
Ok(listener)
}

120
cli/src/vhci.rs Normal file
View file

@ -0,0 +1,120 @@
use log::{debug, info};
use std::fs;
use std::io::Result;
use std::path::{Path, PathBuf};
/// vhci_hcd device status: port is available (4th enum value in usbip_device_status)
const VDEV_ST_NULL: u32 = 4;
/// USB speed: Super speed and above use SS hub ports
const USB_SPEED_SUPER: u32 = 5;
fn find_vhci_hcd_path() -> Result<PathBuf> {
let platform = Path::new("/sys/devices/platform");
for entry in fs::read_dir(platform).map_err(|e| {
std::io::Error::other(format!(
"Cannot read /sys/devices/platform: {e}. Is vhci_hcd loaded?"
))
})? {
let entry = entry?;
let name = entry.file_name();
if name.to_string_lossy().starts_with("vhci_hcd") {
debug!("Found vhci_hcd at {:?}", entry.path());
return Ok(entry.path());
}
}
Err(std::io::Error::other(
"vhci_hcd not found in /sys/devices/platform. Is the vhci-hcd kernel module loaded?",
))
}
/// Parse vhci_hcd status file. Fields are hex-formatted:
/// ```text
/// hub port sta spd dev sockfd local_busid
/// hs 0000 004 000 00000000 000000 0-0
/// ss 0008 004 000 00000000 000000 0-0
/// ```
fn parse_status_for_free_port(status_content: &str, need_ss: bool) -> Option<u32> {
for line in status_content.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 4 {
continue;
}
let hub = parts[0];
// Status file fields are zero-padded hex
let port: u32 = match u32::from_str_radix(parts[1], 16) {
Ok(p) => p,
Err(_) => continue,
};
let status: u32 = match u32::from_str_radix(parts[2], 16) {
Ok(s) => s,
Err(_) => continue,
};
let is_ss = hub == "ss";
if is_ss != need_ss {
continue;
}
if status == VDEV_ST_NULL {
debug!("Found free {hub} port {port}");
return Some(port);
}
}
None
}
/// Find a free vhci_hcd port matching the device speed.
/// Returns (port_number, attach_file_path).
pub fn find_free_port(speed: u32) -> Result<(u32, PathBuf)> {
let vhci_path = find_vhci_hcd_path()?;
let need_ss = speed >= USB_SPEED_SUPER;
let hub_type = if need_ss { "super-speed" } else { "high-speed" };
debug!("Looking for free {hub_type} port (device speed={speed})");
// Try status, status.0, status.1, etc.
for i in 0..16 {
let status_name = if i == 0 {
"status".to_string()
} else {
format!("status.{}", i - 1)
};
let status_path = vhci_path.join(&status_name);
let content = match fs::read_to_string(&status_path) {
Ok(c) => c,
Err(_) => {
if i == 0 {
continue;
}
break;
}
};
if let Some(port) = parse_status_for_free_port(&content, need_ss) {
let attach_path = vhci_path.join("attach");
info!("Selected {hub_type} port {port}");
return Ok((port, attach_path));
}
}
Err(std::io::Error::other(format!(
"No free {hub_type} vhci_hcd port available"
)))
}
/// Attach a USB device to vhci_hcd.
/// Format: "<port> <sockfd> <devid> <speed>"
pub fn attach(port: u32, fd: i32, devid: u32, speed: u32, attach_path: &Path) -> Result<()> {
let attach_str = format!("{port} {fd} {devid} {speed}");
debug!("Writing to {}: {}", attach_path.display(), attach_str);
fs::write(attach_path, &attach_str).map_err(|e| {
std::io::Error::other(format!("Failed to write to {}: {e}", attach_path.display()))
})?;
info!("Device attached on vhci port {port}");
Ok(())
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,236 @@
# usbip-rs CLI Tool Design
## Overview
A CLI binary (`usbip-rs`) with subcommands implementing USB/IP functionality over vsock with reversed socket flow: the client (untrusted, inside VM) listens, and the host (trusted) connects. All USB traffic is handled in userspace on the host side — no kernel-to-untrusted-client communication.
## Security Model
- **Host = trusted**, **client = untrusted**
- All actions (attach/detach) are host-initiated
- Host connects out; client only listens
- Simplified handshake: host sends device info immediately, no client-initiated requests
- No device listing, no multi-device-per-connection
- Host rejects any unexpected commands from client with a logged warning and disconnect
- Host-side USB handling is entirely in userspace (nusb), no kernel stub driver
- **Bounded allocations**: `handle_urb_loop` enforces maximum limits on `transfer_buffer_length` (16 MB) and `number_of_packets` (256) before allocating buffers, preventing a malicious client from OOM-ing the host
## Commands
```
usbip-rs client listen vsock:<port>
usbip-rs host connect vsock:[<cid>:]<port> <device>
usbip-rs test_hid connect vsock:[<cid>:]<port>
```
- `<device>`: `/dev/bus/usb/BBB/DDD` format or bus ID (`1-2`)
- `<cid>`: vsock CID, defaults to 2 (`VMADDR_CID_HOST`) if omitted
## Project Structure
```
usbip-rs/
├── Cargo.toml # workspace root
├── lib/
│ ├── Cargo.toml # package: usbip-rs (library)
│ └── src/ # current src/ contents, moved here
├── cli/
│ ├── Cargo.toml # package: usbip-rs-cli, binary: usbip-rs
│ └── src/
│ ├── main.rs # clap CLI entry point
│ ├── client.rs # client listen command
│ ├── host.rs # host connect command
│ ├── test_hid.rs # test_hid connect command
│ ├── transport.rs # vsock connect/listen functions
│ └── vhci.rs # vhci_hcd sysfs interaction
├── examples/ # existing examples (updated imports)
├── flake.nix # updated: add cli build target + app output
```
## Simplified Handshake Protocol
Standard USB/IP requires client→host `OP_REQ_IMPORT` then host→client `OP_REP_IMPORT`. In our model, the host sends `OP_REP_IMPORT` immediately after connecting — no request from the client.
**Wire format (320 bytes, sent by host):**
| Offset | Size | Field |
|--------|------|-------|
| 0 | 2 | version (0x0111) |
| 2 | 2 | command (0x0003 = OP_REP_IMPORT) |
| 4 | 4 | status (0 = success) |
| 8 | 256 | device path (null-padded) |
| 264 | 32 | bus_id (null-padded) |
| 296 | 4 | bus_num (BE) |
| 300 | 4 | dev_num (BE) |
| 304 | 4 | speed (BE) |
| 308 | 2 | vendor_id (BE) |
| 310 | 2 | product_id (BE) |
| 312 | 2 | device_bcd (major, minor — matches BE BCD encoding) |
| 314 | 1 | device_class |
| 315 | 1 | device_subclass |
| 316 | 1 | device_protocol |
| 317 | 1 | configuration_value |
| 318 | 1 | num_configurations |
| 319 | 1 | num_interfaces |
After the handshake, the socket carries USB/IP URB traffic (CMD_SUBMIT / RET_SUBMIT / CMD_UNLINK / RET_UNLINK) between vhci_hcd (kernel, client side) and the host's userspace handler.
The `devid` field in URB headers is set to 0 — with one device per connection, routing is unambiguous.
The client only needs the `speed` field to find a compatible vhci_hcd port. Other fields (vendor_id, product_id, bus_id) are used for logging.
## Library Refactoring
### Extract `handle_urb_loop`
The current `handler()` (lib.rs) mixes protocol negotiation with URB processing. Split into:
**`handle_urb_loop(socket, device) -> Result<()>`** (new, public):
- Generic over `T: AsyncReadExt + AsyncWriteExt + Unpin`
- Uses a dedicated `read_urb_command(socket)` that only parses `CMD_SUBMIT` and `CMD_UNLINK` at the parsing level — rejects all other commands (including `OP_REQ_DEVLIST`, `OP_REQ_IMPORT`) before allocating any buffers or reading payload data
- Enforces maximum bounds before allocation: `transfer_buffer_length` capped at 16 MB, `number_of_packets` capped at 256
- `CMD_SUBMIT`: process URB via `device.handle_urb()`, send `RET_SUBMIT`
- `CMD_UNLINK`: send `RET_UNLINK` success
- Any other command: log warning with command code, return error (disconnect)
- On `UnexpectedEof`: log info "connection closed", return Ok
**`handler()`** (refactored):
- Handles `OP_REQ_DEVLIST` / `OP_REQ_IMPORT` as before
- After successful import, delegates to `handle_urb_loop()`
- Retains device-return-on-disconnect cleanup: moves device from `used_devices` back to `available_devices` when `handle_urb_loop` returns
- No behavior change for existing users
### Add `UsbDevice::from_bytes`
Parse the 312-byte device portion of the handshake (bytes 8..320, after the 8-byte OP_REP_IMPORT header). Used by the client to extract speed and device metadata for logging. Parse all fixed-layout fields for completeness.
### Make `to_bytes` / `find_ep` / `handle_urb` public
Currently `pub(crate)` — the binary crate needs access to `to_bytes` (for handshake) and `handle_urb` is called via `handle_urb_loop`. Adjust visibility as needed.
## Detach / Disconnect Lifecycle
Detach is implicit — there is no explicit detach command.
- **Host exits or disconnects**: The vsock connection drops. The kernel's vhci_hcd detects the dead socket and transitions the virtual device to an error state. The device disappears from the client's USB bus.
- **Client daemon exits**: The daemon has already handed socket fds to the kernel via sysfs. The kernel owns these sockets, so they remain open regardless of whether the daemon process is alive. Attached devices continue working. If the daemon is restarted, it can accept new connections.
- **Client daemon receives SIGTERM**: Stop accepting new connections and exit. Existing device attachments are unaffected (kernel owns the fds).
## Client Command
### `usbip-rs client listen vsock:<port>`
Long-running daemon. Listens on `VMADDR_CID_ANY:<port>`, accepts multiple connections (one per device).
**Per-connection flow:**
1. Accept connection, log peer CID at info level
2. Read 320 bytes (OP_REP_IMPORT handshake)
3. Validate version (0x0111), command (0x0003), status (0)
4. Parse device descriptor, extract speed, vendor_id, product_id for logging
5. Log device info: "Importing device VVVV:PPPP, speed=S"
6. Find free vhci_hcd port matching device speed
7. Extract raw fd from tokio-vsock socket (`into_raw_fd()` — releases ownership without closing)
8. Write `<port> <fd> 0 <speed>` to `/sys/devices/platform/vhci_hcd.0/attach`
9. Log "Device attached on vhci port N"
**Error handling (per-connection, listener keeps running):**
- vhci_hcd not loaded (sysfs missing): log error, reject
- No free ports: log error, reject
- Invalid handshake: log warning with details, close
- sysfs write failure: log error with errno
### vhci_hcd Interaction (`vhci.rs`)
**`find_free_port(speed) -> Result<(u32, PathBuf)>`:**
- Read `/sys/devices/platform/vhci_hcd*/status*`
- Parse port table, find port with `VDEV_ST_NULL` (0x004) matching speed category
- High-speed devices use high-speed hub ports, super-speed use super-speed ports
- Return port number and path to the correct `attach` file
**`attach(port, raw_fd, devid, speed, attach_path) -> Result<()>`:**
- Write `<port> <raw_fd> <devid> <speed>` to sysfs attach file
## Host Command
### `usbip-rs host connect vsock:[<cid>:]<port> <device>`
**Flow:**
1. Parse device path: `/dev/bus/usb/BBB/DDD` → bus BBB, device DDD. Or bus ID `N-N...` → look up bus/device numbers.
2. Open USB device via nusb (enumerate, match by bus number + device address)
3. Build `UsbDevice` using existing nusb device conversion logic (claim interfaces, read descriptors, set up handlers)
4. Connect to `vsock:<cid>:<port>` (CID defaults to 2)
5. Send `OP_REP_IMPORT` with device info
6. Enter `handle_urb_loop(socket, device)`
7. On disconnect or error, log and exit
**Error handling:**
- Device not found / can't open: log error, exit non-zero
- Interface claim failure: log error per interface, continue with remaining
- vsock connect failure: log error with CID/port, exit
- Handshake write failure: log error, exit
- Individual URB failures: log at warn, return error in RET_SUBMIT, continue serving
- Connection-level errors (broken pipe, reset): log, exit
## Test HID Command
### `usbip-rs test_hid connect vsock:[<cid>:]<port>`
Developer/testing tool. Simulates a HID keyboard.
**Flow:**
1. Create simulated HID keyboard (same setup as `examples/hid_keyboard.rs`)
2. Connect to vsock (CID defaults to 2)
3. Send `OP_REP_IMPORT` with device info
4. Enter `handle_urb_loop(socket, device)`
5. Concurrently simulate key events on a timer
No special defensive hardening beyond what `handle_urb_loop` provides.
## Transport
No trait abstraction. Simple functions in `transport.rs`:
- `connect_vsock(cid: u32, port: u32) -> Result<VsockStream>`
- `listen_vsock(port: u32) -> Result<VsockListener>`
CLI parser handles `vsock:[<cid>:]<port>` format. When a second transport is needed, add new functions and a new match arm.
`handle_urb_loop` is already transport-agnostic (generic over `AsyncRead + AsyncWrite`).
## Dependencies
### Library (`lib/Cargo.toml`)
Same as current — no new dependencies. Package renamed to `usbip-rs`.
### Binary (`cli/Cargo.toml`)
- `usbip-rs` (path = "../lib")
- `clap` (derive feature)
- `tokio-vsock`
- `tokio` (rt-multi-thread, macros)
- `log` + `env_logger` (no timestamps — journald provides them)
- `nusb`
### `flake.nix`
- Add cli crate build target
- Add package/app output for the `usbip-rs` binary
- Keep existing library build working
## Implementation Risks
**vsock fd handoff to vhci_hcd**: The kernel's vhci_hcd uses `sockfd_lookup()` which is socket-family-agnostic, and communicates via `kernel_sendmsg`/`kernel_recvmsg` which are also family-agnostic. This should work with `AF_VSOCK` sockets, but it has not been tested before. Validate this early in implementation.
**tokio-vsock fd extraction**: The client needs to pass the raw fd to the kernel without closing it. `tokio-vsock` wraps the fd in tokio's async reactor. Extracting the fd requires proper deregistration from the epoll reactor — may need `into_std()` before `into_raw_fd()`, or direct access to the underlying `vsock::VsockStream`. Prototype this early.
**Bus ID resolution**: When the host command receives a bus ID like `1-2` instead of `/dev/bus/usb/BBB/DDD`, it needs to resolve to bus number + device address. This requires either parsing sysfs (`/sys/bus/usb/devices/<busid>/busnum` and `devnum`) or matching against nusb's device enumeration. Implementation should try nusb enumeration first.
## Logging
Output to stderr, no timestamps (systemd/journald adds them).
| Level | What |
|-------|------|
| error | vhci_hcd not available, sysfs write failures, device open failures |
| warn | invalid handshake, unexpected commands, individual URB failures |
| info | connection accepted (with peer CID), device info, attach/detach, connect/disconnect |
| debug | interface claims, port selection, sysfs paths, individual URB processing |
| trace | raw handshake bytes, full device descriptor dump, URB data/setup packets |

61
flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1773628058,
"narHash": "sha256-hpXH0z3K9xv0fHaje136KY872VT2T5uwxtezlAskQgY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f8573b9c935cfaa162dd62cc9e75ae2db86f85df",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

58
flake.nix Normal file
View file

@ -0,0 +1,58 @@
{
description = "USB/IP server library and CLI tool";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
nativeBuildInputs = with pkgs; [
rustc
cargo
rustfmt
clippy
pkg-config
];
buildInputs = with pkgs; [
libusb1
] ++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [
udev
];
usbip-rs = pkgs.rustPlatform.buildRustPackage {
pname = "usbip-rs";
version = "0.8.0";
src = self;
cargoHash = "sha256-GzISI2C2rJsqMtnmWZFz/XPlVK5BS23+mDVMsCpqJGA=";
inherit nativeBuildInputs buildInputs;
buildFeatures = [ "usbip-rs/serde" ];
meta = with pkgs.lib; {
description = "USB/IP server library and CLI tool";
homepage = "https://github.com/jiegec/usbip";
license = licenses.mit;
mainProgram = "usbip-rs";
};
};
in
{
packages = {
default = usbip-rs;
inherit usbip-rs;
};
devShells.default = pkgs.mkShell {
inherit nativeBuildInputs buildInputs;
};
});
}

25
lib/Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
[package]
name = "usbip-rs"
version = "0.8.0"
authors = ["Jiajie Chen <c@jia.je>"]
edition = "2024"
license = "MIT"
repository = "https://github.com/jiegec/usbip"
description = "A library to run USB/IP server"
[dependencies]
tokio = { version = "1.22.0", features = ["rt", "net", "io-util", "sync"] }
log = "0.4.17"
num-traits = "0.2.15"
num-derive = "0.4.2"
rusb = "0.9.3"
serde = { version = "1.0", features = ["derive"], optional = true }
nusb = "0.2.1"
[dev-dependencies]
tokio = { version = "1.22.0", features = ["full"] }
env_logger = "0.11.7"
[features]
default = []
serde = ["dep:serde", "rusb/serde"]

View file

@ -6,21 +6,20 @@ use std::time::Duration;
#[tokio::main]
async fn main() {
env_logger::init();
let handler =
Arc::new(Mutex::new(Box::new(usbip::cdc::UsbCdcAcmHandler::new())
as Box<dyn usbip::UsbInterfaceHandler + Send>));
let server = Arc::new(usbip::UsbIpServer::new_simulated(vec![
usbip::UsbDevice::new(0).with_interface(
usbip::ClassCode::CDC as u8,
usbip::cdc::CDC_ACM_SUBCLASS,
let handler = Arc::new(Mutex::new(Box::new(usbip_rs::cdc::UsbCdcAcmHandler::new())
as Box<dyn usbip_rs::UsbInterfaceHandler + Send>));
let server = Arc::new(usbip_rs::UsbIpServer::new_simulated(vec![
usbip_rs::UsbDevice::new(0).with_interface(
usbip_rs::ClassCode::CDC as u8,
usbip_rs::cdc::CDC_ACM_SUBCLASS,
0x00,
Some("Test CDC ACM"),
usbip::cdc::UsbCdcAcmHandler::endpoints(),
usbip_rs::cdc::UsbCdcAcmHandler::endpoints(),
handler.clone(),
),
]));
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 3240);
tokio::spawn(usbip::server(addr, server));
tokio::spawn(usbip_rs::server(addr, server));
loop {
// sleep 1s
@ -28,7 +27,7 @@ async fn main() {
let mut handler = handler.lock().unwrap();
if let Some(acm) = handler
.as_any()
.downcast_mut::<usbip::cdc::UsbCdcAcmHandler>()
.downcast_mut::<usbip_rs::cdc::UsbCdcAcmHandler>()
{
acm.tx_buffer.push(b'a');
info!("Simulate a char input");

View file

@ -7,16 +7,16 @@ use std::time::Duration;
async fn main() {
env_logger::init();
let handler = Arc::new(Mutex::new(
Box::new(usbip::hid::UsbHidKeyboardHandler::new_keyboard())
as Box<dyn usbip::UsbInterfaceHandler + Send>,
Box::new(usbip_rs::hid::UsbHidKeyboardHandler::new_keyboard())
as Box<dyn usbip_rs::UsbInterfaceHandler + Send>,
));
let server = Arc::new(usbip::UsbIpServer::new_simulated(vec![
usbip::UsbDevice::new(0).with_interface(
usbip::ClassCode::HID as u8,
let server = Arc::new(usbip_rs::UsbIpServer::new_simulated(vec![
usbip_rs::UsbDevice::new(0).with_interface(
usbip_rs::ClassCode::HID as u8,
0x00,
0x00,
Some("Test HID"),
vec![usbip::UsbEndpoint {
vec![usbip_rs::UsbEndpoint {
address: 0x81, // IN
attributes: 0x03, // Interrupt
max_packet_size: 0x08, // 8 bytes
@ -26,7 +26,7 @@ async fn main() {
),
]));
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 3240);
tokio::spawn(usbip::server(addr, server));
tokio::spawn(usbip_rs::server(addr, server));
loop {
// sleep 1s
@ -34,10 +34,10 @@ async fn main() {
let mut handler = handler.lock().unwrap();
if let Some(hid) = handler
.as_any()
.downcast_mut::<usbip::hid::UsbHidKeyboardHandler>()
.downcast_mut::<usbip_rs::hid::UsbHidKeyboardHandler>()
{
hid.pending_key_events
.push_back(usbip::hid::UsbHidKeyboardReport::from_ascii(b'1'));
.push_back(usbip_rs::hid::UsbHidKeyboardReport::from_ascii(b'1'));
info!("Simulate a key event");
}
}

View file

@ -5,9 +5,9 @@ use std::time::Duration;
#[tokio::main]
async fn main() {
env_logger::init();
let server = Arc::new(usbip::UsbIpServer::new_from_host());
let server = Arc::new(usbip_rs::UsbIpServer::new_from_host());
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 3240);
tokio::spawn(usbip::server(addr, server));
tokio::spawn(usbip_rs::server(addr, server));
loop {
// sleep 1s

View file

@ -60,14 +60,14 @@ pub struct UsbDevice {
pub usb_version: Version,
pub(crate) ep0_in: UsbEndpoint,
pub(crate) ep0_out: UsbEndpoint,
pub ep0_in: UsbEndpoint,
pub ep0_out: UsbEndpoint,
// strings
pub(crate) string_pool: HashMap<u8, String>,
pub(crate) string_configuration: u8,
pub(crate) string_manufacturer: u8,
pub(crate) string_product: u8,
pub(crate) string_serial: u8,
pub string_pool: HashMap<u8, String>,
pub string_configuration: u8,
pub string_manufacturer: u8,
pub string_product: u8,
pub string_serial: u8,
}
impl UsbDevice {
@ -204,7 +204,7 @@ impl UsbDevice {
self
}
pub(crate) fn new_string(&mut self, s: &str) -> u8 {
pub fn new_string(&mut self, s: &str) -> u8 {
for i in 1.. {
if let std::collections::hash_map::Entry::Vacant(e) = self.string_pool.entry(i) {
e.insert(s.to_string());
@ -214,7 +214,7 @@ impl UsbDevice {
panic!("string poll exhausted")
}
pub(crate) fn find_ep(&self, ep: u8) -> Option<(UsbEndpoint, Option<&UsbInterface>)> {
pub fn find_ep(&self, ep: u8) -> Option<(UsbEndpoint, Option<&UsbInterface>)> {
if ep == self.ep0_in.address {
Some((self.ep0_in, None))
} else if ep == self.ep0_out.address {
@ -231,7 +231,7 @@ impl UsbDevice {
}
}
pub(crate) fn to_bytes(&self) -> Vec<u8> {
pub fn to_bytes(&self) -> Vec<u8> {
let mut result = Vec::with_capacity(312);
let mut path = self.path.as_bytes().to_vec();
@ -261,7 +261,7 @@ impl UsbDevice {
result
}
pub(crate) fn to_bytes_with_interfaces(&self) -> Vec<u8> {
pub fn to_bytes_with_interfaces(&self) -> Vec<u8> {
let mut result = self.to_bytes();
result.reserve(4 * self.interfaces.len());
@ -275,7 +275,60 @@ impl UsbDevice {
result
}
pub(crate) async fn handle_urb(
/// Parse a 312-byte USB/IP device descriptor.
/// This is the inverse of `to_bytes()`. Used by the client to extract
/// device metadata from the simplified handshake.
pub fn from_bytes(bytes: &[u8]) -> Self {
assert!(
bytes.len() >= 312,
"device descriptor must be at least 312 bytes"
);
let path = std::str::from_utf8(&bytes[0..256])
.unwrap_or("")
.trim_end_matches('\0')
.to_string();
let bus_id = std::str::from_utf8(&bytes[256..288])
.unwrap_or("")
.trim_end_matches('\0')
.to_string();
let bus_num = u32::from_be_bytes(bytes[288..292].try_into().unwrap());
let dev_num = u32::from_be_bytes(bytes[292..296].try_into().unwrap());
let speed = u32::from_be_bytes(bytes[296..300].try_into().unwrap());
let vendor_id = u16::from_be_bytes(bytes[300..302].try_into().unwrap());
let product_id = u16::from_be_bytes(bytes[302..304].try_into().unwrap());
let device_bcd = Version {
major: bytes[304],
minor: bytes[305],
patch: 0,
};
let device_class = bytes[306];
let device_subclass = bytes[307];
let device_protocol = bytes[308];
let configuration_value = bytes[309];
let num_configurations = bytes[310];
Self {
path,
bus_id,
bus_num,
dev_num,
speed,
vendor_id,
product_id,
device_bcd,
device_class,
device_subclass,
device_protocol,
configuration_value,
num_configurations,
..Self::default()
}
}
pub async fn handle_urb(
&self,
ep: UsbEndpoint,
intf: Option<&UsbInterface>,
@ -611,4 +664,22 @@ mod test {
assert!(res.is_err());
}
#[test]
fn test_from_bytes_round_trip() {
setup_test_logger();
let device = UsbDevice::new(0);
let bytes = device.to_bytes();
assert_eq!(bytes.len(), 312);
let parsed = UsbDevice::from_bytes(&bytes);
assert_eq!(parsed.bus_num, device.bus_num);
assert_eq!(parsed.dev_num, device.dev_num);
assert_eq!(parsed.speed, device.speed);
assert_eq!(parsed.vendor_id, device.vendor_id);
assert_eq!(parsed.product_id, device.product_id);
assert_eq!(parsed.device_class, device.device_class);
assert_eq!(parsed.num_configurations, device.num_configurations);
assert_eq!(parsed.path, device.path);
assert_eq!(parsed.bus_id, device.bus_id);
}
}

View file

@ -382,77 +382,25 @@ impl UsbIpServer {
}
}
pub async fn handler<T: AsyncReadExt + AsyncWriteExt + Unpin>(
mut socket: &mut T,
server: Arc<UsbIpServer>,
/// Handle USB/IP URB commands for a single device on a connected socket.
pub async fn handle_urb_loop<T: AsyncReadExt + AsyncWriteExt + Unpin>(
socket: &mut T,
device: &UsbDevice,
) -> Result<()> {
let mut current_import_device_id: Option<String> = None;
loop {
let command = UsbIpCommand::read_from_socket(&mut socket).await;
if let Err(err) = command {
if let Some(dev_id) = current_import_device_id {
let mut used_devices = server.used_devices.write().await;
let mut available_devices = server.available_devices.write().await;
match used_devices.remove(&dev_id) {
Some(dev) => available_devices.push(dev),
None => unreachable!(),
let command = match UsbIpCommand::read_urb_command(socket).await {
Ok(cmd) => cmd,
Err(err) => {
if err.kind() == ErrorKind::UnexpectedEof {
info!("Connection closed");
return Ok(());
}
}
if err.kind() == ErrorKind::UnexpectedEof {
info!("Remote closed the connection");
return Ok(());
} else {
warn!("Error reading URB command: {err}");
return Err(err);
}
}
};
let used_devices = server.used_devices.read().await;
let mut current_import_device = current_import_device_id
.clone()
.and_then(|ref id| used_devices.get(id));
match command.unwrap() {
UsbIpCommand::OpReqDevlist { .. } => {
trace!("Got OP_REQ_DEVLIST");
let devices = server.available_devices.read().await;
// OP_REP_DEVLIST
UsbIpResponse::op_rep_devlist(&devices)
.write_to_socket(socket)
.await?;
trace!("Sent OP_REP_DEVLIST");
}
UsbIpCommand::OpReqImport { busid, .. } => {
trace!("Got OP_REQ_IMPORT");
current_import_device_id = None;
current_import_device = None;
std::mem::drop(used_devices);
let mut used_devices = server.used_devices.write().await;
let mut available_devices = server.available_devices.write().await;
let busid_compare =
&busid[..busid.iter().position(|&x| x == 0).unwrap_or(busid.len())];
for (i, dev) in available_devices.iter().enumerate() {
if busid_compare == dev.bus_id.as_bytes() {
let dev = available_devices.remove(i);
let dev_id = dev.bus_id.clone();
used_devices.insert(dev.bus_id.clone(), dev);
current_import_device_id = dev_id.clone().into();
current_import_device = Some(used_devices.get(&dev_id).unwrap());
break;
}
}
let res = if let Some(dev) = current_import_device {
UsbIpResponse::op_rep_import_success(dev)
} else {
UsbIpResponse::op_rep_import_fail()
};
res.write_to_socket(socket).await?;
trace!("Sent OP_REP_IMPORT");
}
match command {
UsbIpCommand::UsbIpCmdSubmit {
mut header,
transfer_buffer_length,
@ -461,11 +409,8 @@ pub async fn handler<T: AsyncReadExt + AsyncWriteExt + Unpin>(
..
} => {
trace!("Got USBIP_CMD_SUBMIT");
let device = current_import_device.unwrap();
let out = header.direction == 0;
let real_ep = if out { header.ep } else { header.ep | 0x80 };
header.command = USBIP_RET_SUBMIT.into();
let res = match device.find_ep(real_ep as u8) {
@ -486,7 +431,6 @@ pub async fn handler<T: AsyncReadExt + AsyncWriteExt + Unpin>(
&data,
)
.await;
match resp {
Ok(resp) => {
if out {
@ -511,17 +455,111 @@ pub async fn handler<T: AsyncReadExt + AsyncWriteExt + Unpin>(
unlink_seqnum,
} => {
trace!("Got USBIP_CMD_UNLINK for {unlink_seqnum:10x?}");
header.command = USBIP_RET_UNLINK.into();
let res = UsbIpResponse::usbip_ret_unlink_success(&header);
res.write_to_socket(socket).await?;
trace!("Sent USBIP_RET_UNLINK");
}
_ => {
warn!("Unexpected command in URB loop");
return Err(std::io::Error::other("Unexpected command in URB loop"));
}
}
}
}
pub async fn handler<T: AsyncReadExt + AsyncWriteExt + Unpin>(
mut socket: &mut T,
server: Arc<UsbIpServer>,
) -> Result<()> {
let mut current_import_device_id: Option<String> = None;
// Phase 1: Protocol negotiation (devlist/import)
loop {
let command = match UsbIpCommand::read_from_socket(&mut socket).await {
Ok(cmd) => cmd,
Err(err) => {
if err.kind() == ErrorKind::UnexpectedEof {
info!("Remote closed the connection");
return Ok(());
}
return Err(err);
}
};
match command {
UsbIpCommand::OpReqDevlist { .. } => {
trace!("Got OP_REQ_DEVLIST");
let devices = server.available_devices.read().await;
UsbIpResponse::op_rep_devlist(&devices)
.write_to_socket(socket)
.await?;
trace!("Sent OP_REP_DEVLIST");
}
UsbIpCommand::OpReqImport { busid, .. } => {
trace!("Got OP_REQ_IMPORT");
let mut used_devices = server.used_devices.write().await;
let mut available_devices = server.available_devices.write().await;
let busid_compare =
&busid[..busid.iter().position(|&x| x == 0).unwrap_or(busid.len())];
let mut found = false;
for (i, dev) in available_devices.iter().enumerate() {
if busid_compare == dev.bus_id.as_bytes() {
let dev = available_devices.remove(i);
let dev_id = dev.bus_id.clone();
used_devices.insert(dev.bus_id.clone(), dev);
current_import_device_id = Some(dev_id.clone());
let device = used_devices.get(&dev_id).unwrap();
UsbIpResponse::op_rep_import_success(device)
.write_to_socket(socket)
.await?;
found = true;
break;
}
}
if !found {
UsbIpResponse::op_rep_import_fail()
.write_to_socket(socket)
.await?;
}
trace!("Sent OP_REP_IMPORT");
if found {
break; // Move to URB handling phase
}
}
_ => {
warn!("Unexpected command during negotiation phase");
return Err(std::io::Error::other(
"Unexpected command during negotiation",
));
}
}
}
// Phase 2: URB handling
// Clone the device out so we don't hold a read lock for the entire URB loop.
let dev_id = current_import_device_id.unwrap();
let device = {
let used_devices = server.used_devices.read().await;
used_devices.get(&dev_id).unwrap().clone()
};
let result = handle_urb_loop(socket, &device).await;
// Cleanup: return device to available pool
let mut used_devices = server.used_devices.write().await;
let mut available_devices = server.available_devices.write().await;
if let Some(dev) = used_devices.remove(&dev_id) {
available_devices.push(dev);
}
result
}
/// Spawn a USB/IP server at `addr` using [TcpListener]
pub async fn server(addr: SocketAddr, server: Arc<UsbIpServer>) {
let listener = TcpListener::bind(addr).await.expect("bind to addr");
@ -811,4 +849,38 @@ mod tests {
// OP_REQ_IMPORT + USBIP_CMD_SUBMIT + Device Descriptor
assert_eq!(mock_socket.output.len(), 0x140 + 0x30 + 0x12);
}
#[tokio::test]
async fn handle_urb_loop_processes_cmd_submit() {
setup_test_logger();
let device = new_server_with_single_device()
.available_devices
.into_inner()
.pop()
.unwrap();
// Build a CMD_SUBMIT: GetDescriptor Device
let cmd = UsbIpCommand::UsbIpCmdSubmit {
header: UsbIpHeaderBasic {
command: USBIP_CMD_SUBMIT.into(),
seqnum: 1,
devid: 0,
direction: 1, // IN
ep: 0,
},
transfer_flags: 0,
transfer_buffer_length: 64,
start_frame: 0,
number_of_packets: 0,
interval: 0,
setup: [0x80, 0x06, 0x00, 0x01, 0x00, 0x00, 0x40, 0x00],
data: vec![],
iso_packet_descriptor: vec![],
};
let mut mock_socket = MockSocket::new(cmd.to_bytes());
handle_urb_loop(&mut mock_socket, &device).await.ok();
// Should have written a USBIP_RET_SUBMIT (48 bytes header) + device descriptor (18 bytes)
assert_eq!(mock_socket.output.len(), 0x30 + 0x12);
}
}

View file

@ -21,6 +21,11 @@ use crate::UsbDevice;
/// for this library.
pub const USBIP_VERSION: u16 = 0x0111;
/// Maximum transfer buffer length accepted by `read_urb_command` (16 MiB)
pub const MAX_TRANSFER_BUFFER_LENGTH: u32 = 16 * 1024 * 1024;
/// Maximum number of ISO packets accepted by `read_urb_command`
pub const MAX_NUMBER_OF_PACKETS: u32 = 256;
/// Command code: Retrieve the list of exported USB devices
pub const OP_REQ_DEVLIST: u16 = 0x8005;
/// Command code: import a remote USB device
@ -247,6 +252,102 @@ impl UsbIpCommand {
}
}
/// Reads a URB command from a socket, accepting only CMD_SUBMIT and CMD_UNLINK.
///
/// This is the security-critical path used by the host to read from an untrusted client.
/// It rejects OP_REQ_DEVLIST, OP_REQ_IMPORT, and any unknown commands, and enforces
/// allocation bounds before reading any variable-length data.
pub async fn read_urb_command<T: AsyncReadExt + Unpin>(socket: &mut T) -> Result<UsbIpCommand> {
let version: u16 = socket.read_u16().await?;
let command: u16 = socket.read_u16().await?;
if version != 0 {
return Err(std::io::Error::other(format!(
"Unexpected version for URB command: {version:#04X} (expected 0x0000)"
)));
}
match command {
USBIP_CMD_SUBMIT => {
let header =
UsbIpHeaderBasic::read_from_socket_with_command(socket, USBIP_CMD_SUBMIT)
.await?;
let transfer_flags = socket.read_u32().await?;
let transfer_buffer_length = socket.read_u32().await?;
let start_frame = socket.read_u32().await?;
let number_of_packets = socket.read_u32().await?;
let interval = socket.read_u32().await?;
// Bounds check BEFORE any allocation
if transfer_buffer_length > MAX_TRANSFER_BUFFER_LENGTH {
return Err(std::io::Error::other(format!(
"transfer_buffer_length {transfer_buffer_length} exceeds maximum \
{MAX_TRANSFER_BUFFER_LENGTH}"
)));
}
if number_of_packets != 0
&& number_of_packets != 0xFFFFFFFF
&& number_of_packets > MAX_NUMBER_OF_PACKETS
{
return Err(std::io::Error::other(format!(
"number_of_packets {number_of_packets} exceeds maximum \
{MAX_NUMBER_OF_PACKETS}"
)));
}
let mut setup = [0; 8];
socket.read_exact(&mut setup).await?;
let data = if header.direction == Direction::In as u32 {
vec![]
} else {
let mut data = vec![0; transfer_buffer_length as usize];
socket.read_exact(&mut data).await?;
data
};
let iso_packet_descriptor =
if number_of_packets != 0 && number_of_packets != 0xFFFFFFFF {
let mut result = vec![0; 16 * number_of_packets as usize];
socket.read_exact(&mut result).await?;
result
} else {
vec![]
};
Ok(UsbIpCommand::UsbIpCmdSubmit {
header,
transfer_flags,
transfer_buffer_length,
start_frame,
number_of_packets,
interval,
setup,
data,
iso_packet_descriptor,
})
}
USBIP_CMD_UNLINK => {
let header =
UsbIpHeaderBasic::read_from_socket_with_command(socket, USBIP_CMD_UNLINK)
.await?;
let unlink_seqnum = socket.read_u32().await?;
let mut _padding = [0; 24];
socket.read_exact(&mut _padding).await?;
Ok(UsbIpCommand::UsbIpCmdUnlink {
header,
unlink_seqnum,
})
}
other => Err(std::io::Error::other(format!(
"Unexpected command for URB context: {other:#04X} \
(only CMD_SUBMIT and CMD_UNLINK are accepted)"
))),
}
}
/// Converts the [UsbIpCommand] into a byte vector
pub fn to_bytes(&self) -> Vec<u8> {
match *self {
@ -384,8 +485,8 @@ impl UsbIpResponse {
let mut result =
Vec::with_capacity(48 + transfer_buffer.len() + iso_packet_descriptor.len());
debug_assert!(header.command == USBIP_RET_SUBMIT.into());
debug_assert!(if header.direction == Direction::In as u32 {
assert!(header.command == USBIP_RET_SUBMIT.into());
assert!(if header.direction == Direction::In as u32 {
actual_length == transfer_buffer.len() as u32
} else {
actual_length == 0
@ -405,7 +506,7 @@ impl UsbIpResponse {
Self::UsbIpRetUnlink { ref header, status } => {
let mut result = Vec::with_capacity(48);
debug_assert!(header.command == USBIP_RET_UNLINK.into());
assert!(header.command == USBIP_RET_UNLINK.into());
result.extend_from_slice(&header.to_bytes());
result.extend_from_slice(&status.to_be_bytes());
@ -927,4 +1028,135 @@ mod tests {
"Unknown command: 0x1005".to_string()
);
}
// Helper to build raw CMD_SUBMIT bytes with given transfer_buffer_length and number_of_packets
// Wire format: version(2) + command(2) + seqnum(4) + devid(4) + direction(4) + ep(4)
// + transfer_flags(4) + transfer_buffer_length(4) + start_frame(4)
// + number_of_packets(4) + interval(4) + setup(8)
fn build_cmd_submit_bytes(
direction: u32,
transfer_buffer_length: u32,
number_of_packets: u32,
data: &[u8],
iso_packet_descriptor: &[u8],
) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&0u16.to_be_bytes()); // version = 0 (URB)
bytes.extend_from_slice(&USBIP_CMD_SUBMIT.to_be_bytes()); // command
bytes.extend_from_slice(&1u32.to_be_bytes()); // seqnum
bytes.extend_from_slice(&2u32.to_be_bytes()); // devid
bytes.extend_from_slice(&direction.to_be_bytes()); // direction
bytes.extend_from_slice(&0u32.to_be_bytes()); // ep
bytes.extend_from_slice(&0u32.to_be_bytes()); // transfer_flags
bytes.extend_from_slice(&transfer_buffer_length.to_be_bytes());
bytes.extend_from_slice(&0u32.to_be_bytes()); // start_frame
bytes.extend_from_slice(&number_of_packets.to_be_bytes());
bytes.extend_from_slice(&0u32.to_be_bytes()); // interval
bytes.extend_from_slice(&[0u8; 8]); // setup
bytes.extend_from_slice(data);
bytes.extend_from_slice(iso_packet_descriptor);
bytes
}
#[tokio::test]
async fn read_urb_command_accepts_cmd_submit() -> Result<()> {
setup_test_logger();
let cmd = UsbIpCommand::UsbIpCmdSubmit {
header: UsbIpHeaderBasic {
command: USBIP_CMD_SUBMIT.into(),
seqnum: 1,
devid: 2,
direction: Direction::Out as u32,
ep: 0,
},
transfer_flags: 0,
transfer_buffer_length: 4,
start_frame: 0,
number_of_packets: 0,
interval: 0,
setup: [0u8; 8],
data: vec![0xAA, 0xBB, 0xCC, 0xDD],
iso_packet_descriptor: vec![],
};
let bytes = cmd.to_bytes();
let result = UsbIpCommand::read_urb_command(&mut MockSocket::new(bytes)).await;
assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
Ok(())
}
#[tokio::test]
async fn read_urb_command_accepts_cmd_unlink() -> Result<()> {
setup_test_logger();
let cmd = UsbIpCommand::UsbIpCmdUnlink {
header: UsbIpHeaderBasic {
command: USBIP_CMD_UNLINK.into(),
seqnum: 1,
devid: 2,
direction: 0,
ep: 0,
},
unlink_seqnum: 42,
};
let bytes = cmd.to_bytes();
let result = UsbIpCommand::read_urb_command(&mut MockSocket::new(bytes)).await;
assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
Ok(())
}
#[tokio::test]
async fn read_urb_command_rejects_op_req_devlist() {
setup_test_logger();
let cmd = UsbIpCommand::OpReqDevlist { status: 0 };
let bytes = cmd.to_bytes();
let result = UsbIpCommand::read_urb_command(&mut MockSocket::new(bytes)).await;
assert!(result.is_err(), "Expected Err, got Ok");
assert!(
result.unwrap_err().to_string().contains("Unexpected"),
"Error should contain 'Unexpected'"
);
}
#[tokio::test]
async fn read_urb_command_rejects_op_req_import() {
setup_test_logger();
let cmd = UsbIpCommand::OpReqImport {
status: 0,
busid: [0u8; 32],
};
let bytes = cmd.to_bytes();
let result = UsbIpCommand::read_urb_command(&mut MockSocket::new(bytes)).await;
assert!(result.is_err(), "Expected Err, got Ok");
assert!(
result.unwrap_err().to_string().contains("Unexpected"),
"Error should contain 'Unexpected'"
);
}
#[tokio::test]
async fn read_urb_command_rejects_oversized_transfer_buffer() {
setup_test_logger();
// 17 MB > 16 MB limit
let oversized: u32 = 17 * 1024 * 1024;
// direction = Out so transfer_buffer_length applies, but we want to be rejected before allocation
let bytes = build_cmd_submit_bytes(Direction::Out as u32, oversized, 0, &[], &[]);
let result = UsbIpCommand::read_urb_command(&mut MockSocket::new(bytes)).await;
assert!(result.is_err(), "Expected Err, got Ok");
assert!(
result.unwrap_err().to_string().contains("exceeds maximum"),
"Error should contain 'exceeds maximum'"
);
}
#[tokio::test]
async fn read_urb_command_rejects_oversized_number_of_packets() {
setup_test_logger();
// number_of_packets = 1000 > MAX_NUMBER_OF_PACKETS = 256
let bytes = build_cmd_submit_bytes(Direction::In as u32, 0, 1000, &[], &[]);
let result = UsbIpCommand::read_urb_command(&mut MockSocket::new(bytes)).await;
assert!(result.is_err(), "Expected Err, got Ok");
assert!(
result.unwrap_err().to_string().contains("exceeds maximum"),
"Error should contain 'exceeds maximum'"
);
}
}