diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index bcfb6b3..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index e2a5920..0000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore index 96ef6c0..d787b70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ /target -Cargo.lock +/result diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..292798f --- /dev/null +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml index 6ebb2cd..1695043 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,27 +1,3 @@ -[package] -name = "usbip" -version = "0.8.0" -authors = ["Jiajie Chen "] -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" diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..501d6cb --- /dev/null +++ b/cli/Cargo.toml @@ -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" diff --git a/cli/src/client.rs b/cli/src/client.rs new file mode 100644 index 0000000..d91b66a --- /dev/null +++ b/cli/src/client.rs @@ -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(()) +} diff --git a/cli/src/host.rs b/cli/src/host.rs new file mode 100644 index 0000000..34e280a --- /dev/null +++ b/cli/src/host.rs @@ -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//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 { + 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, + )); + + 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 +} diff --git a/cli/src/main.rs b/cli/src/main.rs new file mode 100644 index 0000000..5f68ab5 --- /dev/null +++ b/cli/src/main.rs @@ -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: + address: String, + }, +} + +#[derive(Subcommand)] +enum HostAction { + /// Connect to a listening client + Connect { + /// Vsock address: vsock:[:] + 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:[:] + 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); + } +} diff --git a/cli/src/test_hid.rs b/cli/src/test_hid.rs new file mode 100644 index 0000000..c7fa752 --- /dev/null +++ b/cli/src/test_hid.rs @@ -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 + )); + + 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::() { + 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 +} diff --git a/cli/src/transport.rs b/cli/src/transport.rs new file mode 100644 index 0000000..3956b64 --- /dev/null +++ b/cli/src/transport.rs @@ -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:" or "vsock::". CID defaults to 2. +pub fn parse_vsock_addr(addr: &str) -> Result { + 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::() + .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::() + .map_err(|e| std::io::Error::other(format!("Invalid CID '{}': {e}", parts[0])))?; + let port = parts[1] + .parse::() + .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:' or 'vsock::'" + ))), + } +} + +pub async fn connect_vsock(cid: u32, port: u32) -> Result { + 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 { + 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) +} diff --git a/cli/src/vhci.rs b/cli/src/vhci.rs new file mode 100644 index 0000000..cc46e26 --- /dev/null +++ b/cli/src/vhci.rs @@ -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 { + 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 { + 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: " " +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(()) +} diff --git a/docs/superpowers/plans/2026-03-19-usbip-rs-cli.md b/docs/superpowers/plans/2026-03-19-usbip-rs-cli.md new file mode 100644 index 0000000..e545935 --- /dev/null +++ b/docs/superpowers/plans/2026-03-19-usbip-rs-cli.md @@ -0,0 +1,1908 @@ +# usbip-rs CLI Tool Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a `usbip-rs` CLI binary with `client listen`, `host connect`, and `test_hid connect` subcommands over vsock transport. + +**Architecture:** Convert repo to a Cargo workspace with `lib/` (library, renamed to `usbip-rs`) and `cli/` (binary). Refactor the library to extract a reusable URB handling loop from the existing `handler()`. The CLI binary handles vsock transport, vhci_hcd sysfs interaction, and the simplified handshake protocol. + +**Tech Stack:** Rust 2024 edition, tokio async runtime, clap (derive) for CLI, tokio-vsock for vsock transport, nusb for host USB device access, env_logger for logging. + +**Spec:** `docs/superpowers/specs/2026-03-19-usbip-rs-cli-design.md` + +--- + +## File Map + +### Library (`lib/`) +All files currently in `src/` move to `lib/src/`. The package is renamed from `usbip` to `usbip-rs`. + +| File | Responsibility | Changes | +|------|---------------|---------| +| `lib/Cargo.toml` | Library package manifest | New file (based on current `Cargo.toml`, renamed package) | +| `lib/src/lib.rs` | Server + URB loop | Extract `handle_urb_loop()`, refactor `handler()` to delegate | +| `lib/src/usbip_protocol.rs` | Protocol types + serialization | Add `read_urb_command()` with bounded allocation | +| `lib/src/device.rs` | UsbDevice struct + URB handling | Add `from_bytes()`, make `to_bytes`/`find_ep`/`handle_urb`/`new_string`/`ep0_in`/`ep0_out`/string fields pub | +| `lib/src/util.rs` | Test utilities | No changes (moves with directory) | +| All other `lib/src/*.rs` | Various modules | No changes (move with directory) | + +### Binary (`cli/`) + +| File | Responsibility | +|------|---------------| +| `cli/Cargo.toml` | Binary package manifest | +| `cli/src/main.rs` | Clap CLI entry point, subcommand dispatch | +| `cli/src/transport.rs` | Vsock address parsing, connect/listen helpers | +| `cli/src/vhci.rs` | vhci_hcd sysfs: find free port, attach device | +| `cli/src/client.rs` | `client listen` command implementation | +| `cli/src/host.rs` | `host connect` command implementation | +| `cli/src/test_hid.rs` | `test_hid connect` command implementation | + +### Root + +| File | Changes | +|------|---------| +| `Cargo.toml` | Replace package manifest with workspace manifest | +| `flake.nix` | Add cli build target, update for workspace | +| `examples/*.rs` | Update imports from `usbip::` to `usbip_rs::` | + +--- + +## Task 1: Convert to Cargo Workspace + +Move existing library source into `lib/` subdirectory, create workspace root, verify everything still compiles and tests pass. + +**Files:** +- Modify: `Cargo.toml` (replace package with workspace) +- Create: `lib/Cargo.toml` (library package, renamed to `usbip-rs`) +- Move: `src/` → `lib/src/` +- Move: `Cargo.lock` stays at root (workspace-level) +- Modify: `examples/hid_keyboard.rs`, `examples/cdc_acm_serial.rs`, `examples/host.rs` + +- [ ] **Step 1: Move source files** + +```bash +mkdir -p lib +mv src lib/ +``` + +- [ ] **Step 2: Create library Cargo.toml** + +Create `lib/Cargo.toml`: +```toml +[package] +name = "usbip-rs" +version = "0.8.0" +authors = ["Jiajie Chen "] +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"] +``` + +- [ ] **Step 3: Convert root Cargo.toml to workspace** + +Replace root `Cargo.toml` with: +```toml +[workspace] +members = ["lib", "cli"] +resolver = "2" +``` + +- [ ] **Step 4: Move examples to reference library path** + +Update each example file's imports. In `examples/hid_keyboard.rs`, `examples/cdc_acm_serial.rs`, and `examples/host.rs`, replace all `usbip::` with `usbip_rs::`. + +Move examples into `lib/examples/` since they depend on the library crate: +```bash +mv examples lib/examples +``` + +- [ ] **Step 5: Build and test** + +```bash +cd /home/nixos/git/usbip-rs +cargo build --workspace +cargo test --workspace +``` + +Expected: all compile, all 28 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "Convert to cargo workspace, rename library to usbip-rs" +``` + +--- + +## Task 2: Add `read_urb_command()` with Bounded Allocation + +Add a restricted URB command parser in the protocol module that only accepts `CMD_SUBMIT` and `CMD_UNLINK`, with allocation bounds. This is security-critical: the host uses this to read from the untrusted client. + +**Files:** +- Modify: `lib/src/usbip_protocol.rs` + +- [ ] **Step 1: Write tests for `read_urb_command`** + +Add to the `tests` module in `lib/src/usbip_protocol.rs`: + +```rust +#[tokio::test] +async fn read_urb_command_accepts_cmd_submit() { + setup_test_logger(); + let cmd = UsbIpCommand::UsbIpCmdSubmit { + header: UsbIpHeaderBasic { + command: USBIP_CMD_SUBMIT.into(), + seqnum: 1, + devid: 0, + direction: 0, // OUT + ep: 2, + }, + transfer_flags: 0, + transfer_buffer_length: 8, + start_frame: 0, + number_of_packets: 0, + interval: 0, + setup: [0; 8], + data: vec![1, 2, 3, 4, 5, 6, 7, 8], + iso_packet_descriptor: vec![], + }; + let mut socket = MockSocket::new(cmd.to_bytes()); + let result = UsbIpCommand::read_urb_command(&mut socket).await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn read_urb_command_accepts_cmd_unlink() { + setup_test_logger(); + let cmd = UsbIpCommand::UsbIpCmdUnlink { + header: UsbIpHeaderBasic { + command: USBIP_CMD_UNLINK.into(), + seqnum: 1, + devid: 0, + direction: 0, + ep: 0, + }, + unlink_seqnum: 42, + }; + let mut socket = MockSocket::new(cmd.to_bytes()); + let result = UsbIpCommand::read_urb_command(&mut socket).await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn read_urb_command_rejects_op_req_devlist() { + setup_test_logger(); + let cmd = UsbIpCommand::OpReqDevlist { status: 0 }; + let mut socket = MockSocket::new(cmd.to_bytes()); + let result = UsbIpCommand::read_urb_command(&mut socket).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Unexpected")); +} + +#[tokio::test] +async fn read_urb_command_rejects_op_req_import() { + setup_test_logger(); + let cmd = UsbIpCommand::OpReqImport { + status: 0, + busid: [0; 32], + }; + let mut socket = MockSocket::new(cmd.to_bytes()); + let result = UsbIpCommand::read_urb_command(&mut socket).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Unexpected")); +} + +#[tokio::test] +async fn read_urb_command_rejects_oversized_transfer_buffer() { + setup_test_logger(); + // Craft a CMD_SUBMIT with transfer_buffer_length > 16MB + let header = UsbIpHeaderBasic { + command: USBIP_CMD_SUBMIT.into(), + seqnum: 1, + devid: 0, + direction: 0, // OUT — would trigger allocation + ep: 2, + }; + let mut bytes = Vec::new(); + bytes.extend_from_slice(&header.to_bytes()); + bytes.extend_from_slice(&0u32.to_be_bytes()); // transfer_flags + bytes.extend_from_slice(&(17 * 1024 * 1024u32).to_be_bytes()); // transfer_buffer_length > 16MB + bytes.extend_from_slice(&0u32.to_be_bytes()); // start_frame + bytes.extend_from_slice(&0u32.to_be_bytes()); // number_of_packets + bytes.extend_from_slice(&0u32.to_be_bytes()); // interval + bytes.extend_from_slice(&[0u8; 8]); // setup + + let mut socket = MockSocket::new(bytes); + let result = UsbIpCommand::read_urb_command(&mut socket).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("exceeds maximum")); +} + +#[tokio::test] +async fn read_urb_command_rejects_oversized_number_of_packets() { + setup_test_logger(); + let header = UsbIpHeaderBasic { + command: USBIP_CMD_SUBMIT.into(), + seqnum: 1, + devid: 0, + direction: 1, // IN — no data allocation + ep: 2, + }; + let mut bytes = Vec::new(); + bytes.extend_from_slice(&header.to_bytes()); + bytes.extend_from_slice(&0u32.to_be_bytes()); // transfer_flags + bytes.extend_from_slice(&64u32.to_be_bytes()); // transfer_buffer_length + bytes.extend_from_slice(&0u32.to_be_bytes()); // start_frame + bytes.extend_from_slice(&1000u32.to_be_bytes()); // number_of_packets > 256 + bytes.extend_from_slice(&0u32.to_be_bytes()); // interval + bytes.extend_from_slice(&[0u8; 8]); // setup + + let mut socket = MockSocket::new(bytes); + let result = UsbIpCommand::read_urb_command(&mut socket).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("exceeds maximum")); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cargo test --package usbip-rs -- usbip_protocol::tests::read_urb_command 2>&1 +``` + +Expected: compilation error — `read_urb_command` doesn't exist yet. + +- [ ] **Step 3: Implement `read_urb_command`** + +Add constants and method to `lib/src/usbip_protocol.rs`: + +```rust +/// Maximum transfer buffer length (16 MB) to prevent OOM from malicious clients +pub const MAX_TRANSFER_BUFFER_LENGTH: u32 = 16 * 1024 * 1024; + +/// Maximum number of ISO packets to prevent OOM from malicious clients +pub const MAX_NUMBER_OF_PACKETS: u32 = 256; +``` + +Add method to `impl UsbIpCommand`: + +```rust +/// Read a URB command from a socket, rejecting non-URB commands. +/// +/// This is a restricted parser that only accepts CMD_SUBMIT and CMD_UNLINK. +/// All other commands (OP_REQ_DEVLIST, OP_REQ_IMPORT, unknown) are rejected +/// before reading any payload data. Allocation sizes are bounded. +/// +/// Used by `handle_urb_loop` to minimize parsing of untrusted client data. +pub async fn read_urb_command(socket: &mut T) -> Result { + let version: u16 = socket.read_u16().await?; + let command: u16 = socket.read_u16().await?; + + // URB commands have version=0x0000 (high 16 bits of the u32 command field). + // OP_REQ_* commands have version=USBIP_VERSION (0x0111). + // Reject anything that isn't a URB command immediately. + if version != 0 { + return Err(std::io::Error::other(format!( + "Unexpected non-URB command: version={version:#06X} command={command:#06X}" + ))); + } + + 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?; + + if transfer_buffer_length > MAX_TRANSFER_BUFFER_LENGTH { + return Err(std::io::Error::other(format!( + "transfer_buffer_length {} exceeds maximum {}", + transfer_buffer_length, MAX_TRANSFER_BUFFER_LENGTH + ))); + } + + if number_of_packets > MAX_NUMBER_OF_PACKETS + && number_of_packets != 0 + && number_of_packets != 0xFFFFFFFF + { + return Err(std::io::Error::other(format!( + "number_of_packets {} exceeds maximum {}", + number_of_packets, 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, + }) + } + _ => Err(std::io::Error::other(format!( + "Unexpected command in URB loop: {command:#06X}" + ))), + } +} +``` + +Note: the `Direction` enum is private in this module — you'll need to reference it. It's already defined at the top of `usbip_protocol.rs`. + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cargo test --package usbip-rs -- usbip_protocol::tests::read_urb_command 2>&1 +``` + +Expected: all 6 new tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add lib/src/usbip_protocol.rs +git commit -m "Add read_urb_command with bounded allocation and command filtering" +``` + +--- + +## Task 3: Extract `handle_urb_loop()` and Add `UsbDevice::from_bytes()` + +Extract the URB handling loop into a standalone public function. Add device deserialization. Make visibility changes. + +**Files:** +- Modify: `lib/src/lib.rs` +- Modify: `lib/src/device.rs` + +- [ ] **Step 1: Write test for `handle_urb_loop`** + +Add to `lib/src/lib.rs` tests module: + +```rust +#[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); +} +``` + +- [ ] **Step 2: Write test for `UsbDevice::from_bytes` round-trip** + +Add to `lib/src/device.rs` test module: + +```rust +#[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); +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +```bash +cargo test --package usbip-rs -- tests::handle_urb_loop_processes_cmd_submit tests::test_from_bytes_round_trip 2>&1 +``` + +Expected: compilation errors — functions don't exist yet. + +- [ ] **Step 4: Add `UsbDevice::from_bytes()` in `lib/src/device.rs`** + +```rust +/// 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]; + // bytes[311] is num_interfaces, but we can't reconstruct interfaces from bytes alone + + 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() + } +} +``` + +- [ ] **Step 5: Change visibility in `lib/src/device.rs`** + +Change these from `pub(crate)` to `pub`: +- `to_bytes` (line 234) +- `to_bytes_with_interfaces` (line 264) +- `find_ep` (line 217) +- `handle_urb` (line 278) +- `new_string` (line 207) +- `ep0_in` field (line 63) +- `ep0_out` field (line 64) +- `string_pool` field (line 66) +- `string_configuration` field (line 67) +- `string_manufacturer` field (line 68) +- `string_product` field (line 69) +- `string_serial` field (line 70) + +- [ ] **Step 6: Implement `handle_urb_loop()` in `lib/src/lib.rs`** + +Add this public function before the existing `handler()` function: + +```rust +/// Handle USB/IP URB commands for a single device on a connected socket. +/// +/// This is the core loop for the simplified handshake mode. It only accepts +/// CMD_SUBMIT and CMD_UNLINK commands. Any other command is rejected and +/// the connection is closed. +/// +/// The caller is responsible for the handshake (sending device info) before +/// calling this function. +pub async fn handle_urb_loop( + socket: &mut T, + device: &UsbDevice, +) -> Result<()> { + loop { + let command = match UsbIpCommand::read_urb_command(socket).await { + Ok(cmd) => cmd, + Err(err) => { + if err.kind() == ErrorKind::UnexpectedEof { + info!("Connection closed"); + return Ok(()); + } + warn!("Error reading URB command: {err}"); + return Err(err); + } + }; + + match command { + UsbIpCommand::UsbIpCmdSubmit { + mut header, + transfer_buffer_length, + setup, + data, + .. + } => { + trace!("Got USBIP_CMD_SUBMIT"); + + 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) { + None => { + warn!("Endpoint {real_ep:02x?} not found"); + UsbIpResponse::usbip_ret_submit_fail(&header) + } + Some((ep, intf)) => { + trace!("->Endpoint {ep:02x?}"); + trace!("->Setup {setup:02x?}"); + trace!("->Request {data:02x?}"); + let resp = device + .handle_urb( + ep, + intf, + transfer_buffer_length, + SetupPacket::parse(&setup), + &data, + ) + .await; + + match resp { + Ok(resp) => { + if out { + trace!("<-Wrote {}", data.len()); + } else { + trace!("<-Resp {resp:02x?}"); + } + UsbIpResponse::usbip_ret_submit_success( + &header, 0, 0, resp, vec![], + ) + } + Err(err) => { + warn!("Error handling URB: {err}"); + UsbIpResponse::usbip_ret_submit_fail(&header) + } + } + } + }; + res.write_to_socket(socket).await?; + trace!("Sent USBIP_RET_SUBMIT"); + } + UsbIpCommand::UsbIpCmdUnlink { + mut header, + 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"); + } + _ => { + // Should never happen — read_urb_command filters these + warn!("Unexpected command in URB loop"); + return Err(std::io::Error::other("Unexpected command in URB loop")); + } + } + } +} +``` + +- [ ] **Step 7: Refactor `handler()` to delegate to `handle_urb_loop()`** + +In `lib/src/lib.rs`, replace the `UsbIpCmdSubmit` and `UsbIpCmdUnlink` arms in `handler()` (lines 456-521) with a delegation to `handle_urb_loop`. After a successful import, drop the locks and call `handle_urb_loop`. On return, do the device-return-to-available cleanup. + +The refactored `handler()` should handle `OpReqDevlist` and `OpReqImport` as before. When it gets a `UsbIpCmdSubmit` or `UsbIpCmdUnlink` and has an imported device, it should break out of the initial command loop, then call `handle_urb_loop`, and finally clean up. + +Note: this is a refactor of existing code flow. The key constraint is preserving the device-return-on-disconnect behavior (lines 393-400 of current code). The simplest approach: after a successful `OpReqImport`, drop the read locks, and re-construct the first URB command from the already-parsed data (or restructure so the handler enters `handle_urb_loop` after import and the first URB is read there). + +Actually, the simplest correct refactor: after a successful `OpReqImport`, immediately break out of the `handler` loop and call `handle_urb_loop` with the device. Wrap it in the cleanup logic. The old `handler` function currently handles both pre-import and post-import in the same loop, but post-import it only processes URBs. So: + +```rust +pub async fn handler( + mut socket: &mut T, + server: Arc, +) -> Result<()> { + let mut current_import_device_id: Option = 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. + // The original code acquires/releases the read lock per iteration; holding it + // for the full loop duration would block cleanup writes from other connections. + 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 +} +``` + +- [ ] **Step 8: Run all tests** + +```bash +cargo test --package usbip-rs 2>&1 +``` + +Expected: all existing tests still pass, plus the new `handle_urb_loop` test passes. + +- [ ] **Step 9: Commit** + +```bash +git add lib/src/lib.rs lib/src/device.rs +git commit -m "Extract handle_urb_loop, add UsbDevice::from_bytes, make visibility public" +``` + +--- + +## Task 4: Create CLI Binary Skeleton with Clap + +Set up the binary crate with clap subcommand structure and env_logger. No real functionality yet — just argument parsing and stubs. + +**Files:** +- Create: `cli/Cargo.toml` +- Create: `cli/src/main.rs` +- Create: `cli/src/transport.rs` +- Create: `cli/src/client.rs` +- Create: `cli/src/host.rs` +- Create: `cli/src/test_hid.rs` +- Create: `cli/src/vhci.rs` + +- [ ] **Step 1: Create `cli/Cargo.toml`** + +```toml +[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.6" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] } +log = "0.4" +env_logger = "0.11" +nusb = "0.2.1" +``` + +Note: check latest `tokio-vsock` version on crates.io. The version above may need adjustment — the implementer should verify. + +- [ ] **Step 2: Create `cli/src/transport.rs`** + +```rust +use std::io::Result; + +/// Parsed vsock address: CID and port +#[derive(Debug, Clone)] +pub struct VsockAddr { + pub cid: u32, + pub port: u32, +} + +/// Default CID for host connections (VMADDR_CID_HOST) +const DEFAULT_CID: u32 = 2; + +/// Parse a vsock address string. +/// +/// Formats: +/// "vsock:" -> CID defaults to 2 +/// "vsock::" -> explicit CID +pub fn parse_vsock_addr(addr: &str) -> Result { + 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::().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::().map_err(|e| { + std::io::Error::other(format!("Invalid CID '{}': {e}", parts[0])) + })?; + let port = parts[1].parse::().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:' or 'vsock::'" + ))), + } +} +``` + +- [ ] **Step 3: Create stub modules** + +Create `cli/src/client.rs`: +```rust +pub async fn run(_port: u32) -> std::io::Result<()> { + todo!("client listen not yet implemented") +} +``` + +Create `cli/src/host.rs`: +```rust +pub async fn run(_cid: u32, _port: u32, _device: &str) -> std::io::Result<()> { + todo!("host connect not yet implemented") +} +``` + +Create `cli/src/test_hid.rs`: +```rust +pub async fn run(_cid: u32, _port: u32) -> std::io::Result<()> { + todo!("test_hid connect not yet implemented") +} +``` + +Create `cli/src/vhci.rs`: +```rust +pub fn find_free_port(_speed: u32) -> std::io::Result<(u32, std::path::PathBuf)> { + todo!("vhci find_free_port not yet implemented") +} + +pub fn attach(_port: u32, _fd: i32, _devid: u32, _speed: u32, _attach_path: &std::path::Path) -> std::io::Result<()> { + todo!("vhci attach not yet implemented") +} +``` + +- [ ] **Step 4: Create `cli/src/main.rs`** + +```rust +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: + address: String, + }, +} + +#[derive(Subcommand)] +enum HostAction { + /// Connect to a listening client + Connect { + /// Vsock address: vsock:[:] + 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:[:] + address: String, + }, +} + +#[tokio::main] +async fn main() { + // Configure env_logger: no timestamps, output to stderr + 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); + } +} +``` + +- [ ] **Step 5: Build the workspace** + +```bash +cargo build --workspace 2>&1 +``` + +Expected: compiles. The binary should respond to `--help`: + +```bash +cargo run --bin usbip-rs -- --help +cargo run --bin usbip-rs -- client listen --help +cargo run --bin usbip-rs -- host connect --help +``` + +- [ ] **Step 6: Commit** + +```bash +git add cli/ Cargo.toml +git commit -m "Add CLI binary skeleton with clap subcommands" +``` + +--- + +## Task 5: Implement Transport and vhci_hcd Modules + +Implement vsock connect/listen functions and the vhci_hcd sysfs interaction. + +**Files:** +- Modify: `cli/src/transport.rs` +- Modify: `cli/src/vhci.rs` + +- [ ] **Step 1: Add connect and listen functions to `cli/src/transport.rs`** + +Add to the end of `cli/src/transport.rs`: + +```rust +use tokio_vsock::{VsockListener, VsockStream, VMADDR_CID_ANY}; + +/// Connect to a vsock endpoint. +pub async fn connect_vsock(cid: u32, port: u32) -> Result { + log::info!("Connecting to vsock CID={cid} port={port}"); + let stream = VsockStream::connect(cid, port).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) +} + +/// Listen on a vsock port. +pub fn listen_vsock(port: u32) -> Result { + log::info!("Listening on vsock port={port}"); + let listener = VsockListener::bind(VMADDR_CID_ANY, port).map_err(|e| { + std::io::Error::other(format!("Failed to bind vsock port={port}: {e}")) + })?; + Ok(listener) +} +``` + +Note: `VsockListener::bind` might not be async in all versions of `tokio-vsock`. Check the actual API and adjust. Some versions use `VsockListener::bind(cid, port)?` (sync), others are async. + +- [ ] **Step 2: Implement vhci.rs** + +Replace `cli/src/vhci.rs` with: + +```rust +use log::{debug, error, info}; +use std::fs; +use std::io::Result; +use std::path::{Path, PathBuf}; + +/// vhci_hcd device status: port is available +const VDEV_ST_NULL: u32 = 4; + +/// USB speed threshold: Super speed and above use SS hub ports +const USB_SPEED_SUPER: u32 = 5; + +/// Find the vhci_hcd platform device directory. +fn find_vhci_hcd_path() -> Result { + 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 a vhci_hcd status file and find a free port matching the requested speed. +/// +/// Status file format (one header line, then one line per port): +/// ```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 +/// ``` +/// +/// Returns (port_number, hub_type) where hub_type is "hs" or "ss". +fn parse_status_for_free_port(status_content: &str, need_ss: bool) -> Option { + 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 (e.g. "0000", "004") + 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; // Try status.0 next + } + break; // No more status files + } + }; + + 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 by writing to the sysfs attach file. +/// +/// Format: " " +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(()) +} +``` + +- [ ] **Step 3: Build** + +```bash +cargo build --workspace 2>&1 +``` + +Expected: compiles without errors. + +- [ ] **Step 4: Commit** + +```bash +git add cli/src/transport.rs cli/src/vhci.rs +git commit -m "Implement vsock transport and vhci_hcd sysfs interaction" +``` + +--- + +## Task 6: Implement Client Listen Command + +Implement the client daemon that listens for vsock connections and hands sockets to vhci_hcd. + +**Files:** +- Modify: `cli/src/client.rs` + +- [ ] **Step 1: Implement client listen** + +Replace `cli/src/client.rs`: + +```rust +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::{USBIP_VERSION, OP_REP_IMPORT}; + +use crate::transport; +use crate::vhci; + +/// Size of the OP_REP_IMPORT handshake message +const HANDSHAKE_SIZE: usize = 320; + +/// Run the client listen daemon. +/// +/// Listens on the given vsock port, accepts connections, reads device info +/// from the simplified handshake, and hands the socket fd to vhci_hcd. +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<()> { + // Step 1: 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[..]); + + // Step 2: 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")); + } + + // Step 3: 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); + + // Step 4: Find free vhci_hcd port + let (vhci_port, attach_path) = vhci::find_free_port(device.speed)?; + + // Step 5: Extract raw fd and hand to kernel + // into_raw_fd() takes ownership of the socket without closing it. + // 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"); + + // Step 6: 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(()) +} +``` + +Note on `into_raw_fd()`: `tokio_vsock::VsockStream` may not directly implement `IntoRawFd`. If it doesn't, you may need to access the inner fd through `stream.into_inner()` or similar. Check the `tokio-vsock` API. If blocked, try: +```rust +use std::os::unix::io::AsRawFd; +let raw_fd = stream.as_raw_fd(); +std::mem::forget(stream); // Prevent drop from closing the fd +``` +This is a known implementation risk from the spec — adjust the approach based on the actual API. + +- [ ] **Step 2: Build** + +```bash +cargo build --workspace 2>&1 +``` + +Expected: compiles. Fix any API issues with `tokio-vsock`. + +- [ ] **Step 3: Commit** + +```bash +git add cli/src/client.rs +git commit -m "Implement client listen command with vhci_hcd handoff" +``` + +--- + +## Task 7: Implement Host Connect Command + +Open a USB device via nusb, connect over vsock, send device info, and handle URBs. + +**Files:** +- Modify: `cli/src/host.rs` + +- [ ] **Step 1: Implement host connect** + +Replace `cli/src/host.rs`: + +```rust +use log::{debug, error, info, warn}; +use nusb::MaybeFuture; +use std::sync::{Arc, Mutex}; +use tokio::io::AsyncWriteExt; + +use usbip_rs::{ + UsbDevice, UsbEndpoint, UsbInterface, + EndpointAttributes, NusbUsbHostDeviceHandler, NusbUsbHostInterfaceHandler, + UsbInterfaceHandler, + usbip_protocol::UsbIpResponse, +}; + +use crate::transport; + +/// Parse a device path in /dev/bus/usb/BBB/DDD format. +/// Returns (bus_number, device_address). +fn parse_dev_bus_usb(path: &str) -> std::io::Result<(u8, u8)> { + let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect(); + // Expected: ["dev", "bus", "usb", "BBB", "DDD"] + if parts.len() == 5 && parts[0] == "dev" && parts[1] == "bus" && parts[2] == "usb" { + let bus: u8 = parts[3].parse().map_err(|e| { + std::io::Error::other(format!("Invalid bus number '{}': {e}", parts[3])) + })?; + let dev: u8 = parts[4].parse().map_err(|e| { + std::io::Error::other(format!("Invalid device number '{}': {e}", parts[4])) + })?; + return Ok((bus, dev)); + } + Err(std::io::Error::other(format!( + "Invalid device path '{}'. Expected /dev/bus/usb/BBB/DDD", path + ))) +} + +/// Parse a bus ID like "1-2" or "1-2.3" and resolve to (bus, dev) via sysfs. +fn parse_bus_id(bus_id: &str) -> std::io::Result<(u8, u8)> { + let sysfs_path = format!("/sys/bus/usb/devices/{bus_id}"); + if !std::path::Path::new(&sysfs_path).exists() { + return Err(std::io::Error::other(format!( + "USB device with bus ID '{bus_id}' not found at {sysfs_path}" + ))); + } + let busnum = std::fs::read_to_string(format!("{sysfs_path}/busnum")) + .map_err(|e| std::io::Error::other(format!("Cannot read busnum: {e}")))? + .trim() + .parse::() + .map_err(|e| std::io::Error::other(format!("Invalid busnum: {e}")))?; + let devnum = std::fs::read_to_string(format!("{sysfs_path}/devnum")) + .map_err(|e| std::io::Error::other(format!("Cannot read devnum: {e}")))? + .trim() + .parse::() + .map_err(|e| std::io::Error::other(format!("Invalid devnum: {e}")))?; + Ok((busnum, devnum)) +} + +/// Parse device argument — either /dev/bus/usb/BBB/DDD or bus ID format. +fn parse_device_arg(device: &str) -> std::io::Result<(u8, u8)> { + if device.starts_with("/dev/bus/usb/") { + parse_dev_bus_usb(device) + } else { + parse_bus_id(device) + } +} + +/// Find and open a USB device by bus number and device address using nusb. +fn open_device(bus: u8, dev: u8) -> std::io::Result<(nusb::Device, nusb::DeviceInfo)> { + info!("Looking for USB device bus={bus} dev={dev}"); + + for dev_info in nusb::list_devices().map_err(|e| { + std::io::Error::other(format!("Failed to enumerate USB devices: {e}")) + })? { + #[cfg(target_os = "linux")] + let matches = dev_info.busnum() == bus && dev_info.device_address() == dev; + #[cfg(not(target_os = "linux"))] + let matches = dev_info.device_address() == dev; + + if matches { + info!( + "Found device: {:04x}:{:04x} {}", + dev_info.vendor_id(), + dev_info.product_id(), + dev_info.product_string().unwrap_or("(unknown)") + ); + let device = dev_info.open().wait().map_err(|e| { + std::io::Error::other(format!("Failed to open device: {e}")) + })?; + return Ok((device, dev_info)); + } + } + + Err(std::io::Error::other(format!( + "USB device not found at bus={bus} dev={dev}" + ))) +} + +/// Build a UsbDevice from an nusb device. +/// +/// This is adapted from UsbIpServer::with_nusb_devices but for a single device, +/// with better error handling. +fn build_usb_device(dev: nusb::Device, dev_info: &nusb::DeviceInfo) -> std::io::Result { + 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!("Cannot claim interface {intf_num}: {e}, skipping"); + continue; + } + }; + + let alt_setting = match claimed.descriptors().next() { + Some(alt) => alt, + None => { + warn!("No alt setting for interface {intf_num}, 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(), + }); + } + + debug!( + "Claimed interface {intf_num}: class={:02x} subclass={:02x} endpoints={}", + alt_setting.class(), + alt_setting.subclass(), + endpoints.len() + ); + + let handler = Arc::new(Mutex::new(Box::new(NusbUsbHostInterfaceHandler::new( + Arc::new(Mutex::new(claimed.clone())), + )) as Box)); + + 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, + }); + } + + if interfaces.is_empty() { + return Err(std::io::Error::other("No interfaces could be claimed")); + } + + #[cfg(target_os = "linux")] + let bus_num = dev_info.busnum() as u32; + #[cfg(not(target_os = "linux"))] + let bus_num = 0u32; + + let device_address = dev_info.device_address(); + + let mut device = UsbDevice { + path: format!("/sys/bus/{bus_num}/{device_address}/0"), + bus_id: format!("{bus_num}-{device_address}-0"), + bus_num, + dev_num: 0, + speed: dev_info.speed().unwrap() 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: 64, + interval: 0, + }, + ep0_out: UsbEndpoint { + address: 0x00, + attributes: EndpointAttributes::Control as u8, + max_packet_size: 64, + interval: 0, + }, + interfaces, + device_handler: Some(Arc::new(Mutex::new(Box::new( + NusbUsbHostDeviceHandler::new(Arc::new(Mutex::new(dev))), + )))), + ..UsbDevice::default() + }; + + 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); + } + + Ok(device) +} + +/// Run the host connect command. +pub async fn run(cid: u32, port: u32, device_arg: &str) -> std::io::Result<()> { + // Step 1: Parse and open USB device + let (bus, dev_addr) = parse_device_arg(device_arg)?; + let (dev, dev_info) = open_device(bus, dev_addr)?; + let device = build_usb_device(dev, &dev_info)?; + + info!( + "Exporting device {:04x}:{:04x} (speed={})", + device.vendor_id, device.product_id, device.speed + ); + + // Step 2: Connect via vsock + let mut stream = transport::connect_vsock(cid, port).await?; + + // Step 3: Send device info (simplified handshake) + let handshake = UsbIpResponse::op_rep_import_success(&device).to_bytes(); + debug!("Sending handshake ({} bytes)", handshake.len()); + stream.write_all(&handshake).await.map_err(|e| { + std::io::Error::other(format!("Failed to send handshake: {e}")) + })?; + info!("Handshake sent, entering URB loop"); + + // Step 4: Handle URBs + let result = usbip_rs::handle_urb_loop(&mut stream, &device).await; + match &result { + Ok(()) => info!("Connection closed normally"), + Err(e) => error!("URB loop error: {e}"), + } + + result +} +``` + +Note: `UsbDevice::new_string` is currently `pub(crate)`. The `build_usb_device` function calls it. If it's not accessible from the cli crate, change it to `pub` in `lib/src/device.rs`, or use the `set_manufacturer_name` etc. public setters instead. + +- [ ] **Step 2: Build** + +```bash +cargo build --workspace 2>&1 +``` + +Expected: compiles. + +- [ ] **Step 3: Commit** + +```bash +git add cli/src/host.rs +git commit -m "Implement host connect command with nusb device passthrough" +``` + +--- + +## Task 8: Implement Test HID Command + +Simulated HID keyboard that connects over vsock — for testing the client without real USB hardware. + +**Files:** +- Modify: `cli/src/test_hid.rs` + +- [ ] **Step 1: Implement test_hid connect** + +Replace `cli/src/test_hid.rs`: + +```rust +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; + +/// Run the test_hid connect command. +pub async fn run(cid: u32, port: u32) -> std::io::Result<()> { + // Step 1: Create simulated HID keyboard + let handler = Arc::new(Mutex::new( + Box::new(UsbHidKeyboardHandler::new_keyboard()) + as Box, + )); + + 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 + ); + + // Step 2: Connect via vsock + let mut stream = transport::connect_vsock(cid, port).await?; + + // Step 3: 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"); + + // Step 4: 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::() + { + hid.pending_key_events + .push_back(usbip_rs::hid::UsbHidKeyboardReport::from_ascii(b'1')); + info!("Simulated key event '1'"); + } + } + }); + + // Step 5: Handle URBs + usbip_rs::handle_urb_loop(&mut stream, &device).await +} +``` + +- [ ] **Step 2: Build** + +```bash +cargo build --workspace 2>&1 +``` + +Expected: compiles. + +- [ ] **Step 3: Commit** + +```bash +git add cli/src/test_hid.rs +git commit -m "Implement test_hid connect command with simulated keyboard" +``` + +--- + +## Task 9: Update flake.nix + +Update the Nix flake to build the workspace and expose the `usbip-rs` binary. + +**Files:** +- Modify: `flake.nix` + +- [ ] **Step 1: Update flake.nix** + +The flake needs to handle the workspace structure. Key changes: +- Build the entire workspace +- Expose the `usbip-rs` binary as the main program +- Keep the library examples too + +```nix +{ + 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 + 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 = pkgs.lib.fakeHash; + + inherit nativeBuildInputs buildInputs; + + # In a workspace, features must be scoped to the package + 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; + }; + }); +} +``` + +Note: `cargoHash` is set to `pkgs.lib.fakeHash` — this will fail on first build and print the real hash. Replace it with the printed hash. + +- [ ] **Step 2: Test the flake builds** + +```bash +nix build 2>&1 +``` + +Expected: first run fails with hash mismatch. Copy the correct hash, update `cargoHash`, run again. + +```bash +nix build 2>&1 +``` + +Expected: builds successfully, `result/bin/usbip-rs` exists. + +- [ ] **Step 3: Verify the binary works** + +```bash +./result/bin/usbip-rs --help +``` + +- [ ] **Step 4: Commit** + +```bash +git add flake.nix +git commit -m "Update flake.nix for workspace build with CLI binary" +``` + +--- + +## Task 10: Run Full Test Suite and Final Verification + +Verify everything compiles, tests pass, and the binary works. + +**Files:** None (verification only) + +- [ ] **Step 1: Run all tests** + +```bash +cargo test --workspace 2>&1 +``` + +Expected: all tests pass (existing 28 + new tests from tasks 2 and 3). + +- [ ] **Step 2: Run clippy** + +```bash +cargo clippy --workspace -- -W warnings 2>&1 +``` + +Expected: no warnings. + +- [ ] **Step 3: Run rustfmt check** + +```bash +cargo fmt --all -- --check 2>&1 +``` + +Expected: no formatting issues. If there are, run `cargo fmt --all` and commit. + +- [ ] **Step 4: Verify CLI help text** + +```bash +cargo run --bin usbip-rs -- --help +cargo run --bin usbip-rs -- client --help +cargo run --bin usbip-rs -- host --help +cargo run --bin usbip-rs -- test_hid --help +``` + +Expected: help text shows all subcommands and arguments correctly. + +- [ ] **Step 5: Fix any issues and commit** + +If clippy or fmt found issues: +```bash +cargo fmt --all +git add -A +git commit -m "Fix formatting and clippy warnings" +``` diff --git a/docs/superpowers/specs/2026-03-19-usbip-rs-cli-design.md b/docs/superpowers/specs/2026-03-19-usbip-rs-cli-design.md new file mode 100644 index 0000000..2eeeaf9 --- /dev/null +++ b/docs/superpowers/specs/2026-03-19-usbip-rs-cli-design.md @@ -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: +usbip-rs host connect vsock:[:] +usbip-rs test_hid connect vsock:[:] +``` + +- ``: `/dev/bus/usb/BBB/DDD` format or bus ID (`1-2`) +- ``: 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:` + +Long-running daemon. Listens on `VMADDR_CID_ANY:`, 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 ` 0 ` 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 ` ` to sysfs attach file + +## Host Command + +### `usbip-rs host connect vsock:[:] ` + +**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 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:[:]` + +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` +- `listen_vsock(port: u32) -> Result` + +CLI parser handles `vsock:[:]` 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//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 | diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..752172b --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..2734be6 --- /dev/null +++ b/flake.nix @@ -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; + }; + }); +} diff --git a/lib/Cargo.toml b/lib/Cargo.toml new file mode 100644 index 0000000..6e46e97 --- /dev/null +++ b/lib/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "usbip-rs" +version = "0.8.0" +authors = ["Jiajie Chen "] +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"] diff --git a/examples/cdc_acm_serial.rs b/lib/examples/cdc_acm_serial.rs similarity index 54% rename from examples/cdc_acm_serial.rs rename to lib/examples/cdc_acm_serial.rs index fce5a0a..17dbc29 100644 --- a/examples/cdc_acm_serial.rs +++ b/lib/examples/cdc_acm_serial.rs @@ -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)); - 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)); + 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::() + .downcast_mut::() { acm.tx_buffer.push(b'a'); info!("Simulate a char input"); diff --git a/examples/hid_keyboard.rs b/lib/examples/hid_keyboard.rs similarity index 61% rename from examples/hid_keyboard.rs rename to lib/examples/hid_keyboard.rs index 47bae3e..30568ae 100644 --- a/examples/hid_keyboard.rs +++ b/lib/examples/hid_keyboard.rs @@ -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, + Box::new(usbip_rs::hid::UsbHidKeyboardHandler::new_keyboard()) + as Box, )); - 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::() + .downcast_mut::() { 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"); } } diff --git a/examples/host.rs b/lib/examples/host.rs similarity index 71% rename from examples/host.rs rename to lib/examples/host.rs index d1d6f40..ef407e1 100644 --- a/examples/host.rs +++ b/lib/examples/host.rs @@ -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 diff --git a/src/cdc.rs b/lib/src/cdc.rs similarity index 100% rename from src/cdc.rs rename to lib/src/cdc.rs diff --git a/src/consts.rs b/lib/src/consts.rs similarity index 100% rename from src/consts.rs rename to lib/src/consts.rs diff --git a/src/device.rs b/lib/src/device.rs similarity index 89% rename from src/device.rs rename to lib/src/device.rs index 19b135c..c25b6f9 100644 --- a/src/device.rs +++ b/lib/src/device.rs @@ -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, - pub(crate) string_configuration: u8, - pub(crate) string_manufacturer: u8, - pub(crate) string_product: u8, - pub(crate) string_serial: u8, + pub string_pool: HashMap, + 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 { + pub fn to_bytes(&self) -> Vec { 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 { + pub fn to_bytes_with_interfaces(&self) -> Vec { 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); + } } diff --git a/src/endpoint.rs b/lib/src/endpoint.rs similarity index 100% rename from src/endpoint.rs rename to lib/src/endpoint.rs diff --git a/src/hid.rs b/lib/src/hid.rs similarity index 100% rename from src/hid.rs rename to lib/src/hid.rs diff --git a/src/host.rs b/lib/src/host.rs similarity index 100% rename from src/host.rs rename to lib/src/host.rs diff --git a/src/interface.rs b/lib/src/interface.rs similarity index 100% rename from src/interface.rs rename to lib/src/interface.rs diff --git a/src/lib.rs b/lib/src/lib.rs similarity index 88% rename from src/lib.rs rename to lib/src/lib.rs index b9fae1a..96c5b9f 100644 --- a/src/lib.rs +++ b/lib/src/lib.rs @@ -382,77 +382,25 @@ impl UsbIpServer { } } -pub async fn handler( - mut socket: &mut T, - server: Arc, +/// Handle USB/IP URB commands for a single device on a connected socket. +pub async fn handle_urb_loop( + socket: &mut T, + device: &UsbDevice, ) -> Result<()> { - let mut current_import_device_id: Option = 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( .. } => { 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( &data, ) .await; - match resp { Ok(resp) => { if out { @@ -511,17 +455,111 @@ pub async fn handler( 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( + mut socket: &mut T, + server: Arc, +) -> Result<()> { + let mut current_import_device_id: Option = 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) { 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); + } } diff --git a/src/setup.rs b/lib/src/setup.rs similarity index 100% rename from src/setup.rs rename to lib/src/setup.rs diff --git a/src/usbip_protocol.rs b/lib/src/usbip_protocol.rs similarity index 75% rename from src/usbip_protocol.rs rename to lib/src/usbip_protocol.rs index 9414cf6..7463d84 100644 --- a/src/usbip_protocol.rs +++ b/lib/src/usbip_protocol.rs @@ -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(socket: &mut T) -> Result { + 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 { 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 { + 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'" + ); + } } diff --git a/src/util.rs b/lib/src/util.rs similarity index 100% rename from src/util.rs rename to lib/src/util.rs