mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
scsi: route raw_command through checked_cdb_len; add the fd hand-off release edge (#16)
raw_command clamped CDBs with cdb.len().min(16) instead of routing through the shared checked_cdb_len guard, so an over-length or empty CDB became a different command rather than a rejected one; it now returns the crate Result so a rejected CDB is distinguishable from an I/O failure. The recovery/Drop fd hand-off lacked a release edge: the teardown swap was not a release op and the recovery CAS was not an acquire load, so a recovery thread publishing its fd after Drop drained the slot could miss dead==true and leak the descriptor. Both sides are now AcqRel, with the execute() drain left at Acquire (sound via the release-sequence rule). A loom model (cfg(loom) dev-dep, its own CI job) provides the regression coverage cargo test cannot. Module docs for fd_handoff and timeline now resolve in their own scope, and item docs stay within comment-guard's caps. Fixes #16.
This commit is contained in:
@@ -136,6 +136,49 @@ jobs:
|
|||||||
- run: cargo test --tests
|
- run: cargo test --tests
|
||||||
working-directory: libfreemkv
|
working-directory: libfreemkv
|
||||||
|
|
||||||
|
# The memory orderings in `scsi::fd_handoff` — the fd hand-off between a
|
||||||
|
# recovery thread and `SgIoTransport::drop` — cannot be checked by `cargo
|
||||||
|
# test`: they compile to the same instructions on x86_64, so the WRONG ones
|
||||||
|
# pass every run. loom enumerates the executions the C++20 model permits and
|
||||||
|
# reports the leaked descriptor directly, which makes it the only regression
|
||||||
|
# coverage that part of the module has. If this job stops running, that
|
||||||
|
# coverage disappears silently behind a green build.
|
||||||
|
#
|
||||||
|
# Its own job rather than a step in `test` because `RUSTFLAGS` is part of the
|
||||||
|
# rust-cache key: folding it into `test` costs that job a full cache miss on
|
||||||
|
# every run. It takes the same sibling checkout as the jobs above even though
|
||||||
|
# `--no-default-features` drops freemkv-unlock from the BUILD — `--features`
|
||||||
|
# does not exempt an optional path dependency from manifest RESOLUTION, so
|
||||||
|
# without the sibling this job would fail the moment the branch tip carries
|
||||||
|
# the path-dep form of that dependency rather than a git tag.
|
||||||
|
loom:
|
||||||
|
needs: resolve-msrv
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
RUSTFLAGS: --cfg loom
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
path: libfreemkv
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
repository: freemkv/freemkv-unlock
|
||||||
|
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
||||||
|
path: freemkv-unlock
|
||||||
|
- uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||||
|
components: clippy
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: libfreemkv
|
||||||
|
- run: cargo test --lib --no-default-features --features scsi fd_handoff
|
||||||
|
working-directory: libfreemkv
|
||||||
|
# `lint` runs clippy without `--cfg loom`, so this is the only place the
|
||||||
|
# model code is linted at all.
|
||||||
|
- run: cargo clippy --lib --no-default-features --features scsi --profile test -- -D warnings
|
||||||
|
working-directory: libfreemkv
|
||||||
|
|
||||||
deny:
|
deny:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
## [1.6.15] — unreleased
|
## [1.6.15] — unreleased
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Linux: `SgIoTransport::raw_command` clamped an over-length CDB with `cdb.len().min(16)` instead of routing it through the shared `checked_cdb_len` guard that `execute()` uses. Under SPC-4 a CDB's length is fixed by its opcode, so truncating one does not shorten the command — it sends a descriptor the drive will read as something else. An empty CDB was likewise unguarded, and went to the sg driver as a zero-length command descriptor. Unreachable — `raw_command` is private and its one caller passes a fixed 6-byte ALLOW MEDIUM REMOVAL — but it was the one transport path that did the thing the guard exists to prevent.
|
||||||
|
- Linux: the fd hand-off between a background recovery thread and `SgIoTransport::drop` had no release edge, so a recovery thread that published its fd after teardown had drained the slot was not guaranteed to observe `dead == true` and could leave the descriptor unclosed. `Drop`'s claiming `swap` was `Acquire` (a relaxed store, heading no release sequence) and the recovery thread's `compare_exchange` was `Release` (a relaxed load, acquiring nothing) — neither side alone was enough, and upgrading only one still leaks. Both are now `AcqRel`. Worst case was a leaked file descriptor on a transport already being torn down.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The Linux recovery-thread fd hand-off moved to `scsi::fd_handoff`, where it is compiled and unit-tested on every platform (`linux.rs` is built on one host only) and model-checked under `loom`. No public API change.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- Fixed a soundness bug (GHSA-j8ww-f5fg-9pmh, low severity): `PrefetchedSectorSource::into_channels` handed out a `Sender<Vec<u8>>` recycle channel whose buffers the producer re-exposed with `unsafe { Vec::set_len }` guarded only by capacity, so a downstream caller recycling a `Vec::with_capacity(n)` (len 0) could drive `set_len` over uninitialized memory — undefined behaviour reachable from safe code. The producer now uses `Vec::resize`, removing the `unsafe`; buffer pooling (the real cross-thread alloc/free win) is unchanged. No in-tree caller triggered it.
|
- Fixed a soundness bug (GHSA-j8ww-f5fg-9pmh, low severity): `PrefetchedSectorSource::into_channels` handed out a `Sender<Vec<u8>>` recycle channel whose buffers the producer re-exposed with `unsafe { Vec::set_len }` guarded only by capacity, so a downstream caller recycling a `Vec::with_capacity(n)` (len 0) could drive `set_len` over uninitialized memory — undefined behaviour reachable from safe code. The producer now uses `Vec::resize`, removing the `unsafe`; buffer pooling (the real cross-thread alloc/free win) is unchanged. No in-tree caller triggered it.
|
||||||
|
|||||||
Generated
+182
@@ -13,6 +13,15 @@ dependencies = [
|
|||||||
"cpufeatures 0.3.1",
|
"cpufeatures 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aho-corasick"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "autocfg"
|
name = "autocfg"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -55,6 +64,16 @@ version = "3.20.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -223,6 +242,12 @@ version = "2.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flate2"
|
name = "flate2"
|
||||||
version = "1.1.10"
|
version = "1.1.10"
|
||||||
@@ -248,6 +273,21 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generator"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"rustversion",
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
@@ -310,6 +350,12 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy_static"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.189"
|
version = "0.2.189"
|
||||||
@@ -325,6 +371,7 @@ dependencies = [
|
|||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"freemkv-unlock",
|
"freemkv-unlock",
|
||||||
"libc",
|
"libc",
|
||||||
|
"loom",
|
||||||
"memchr",
|
"memchr",
|
||||||
"rand",
|
"rand",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -349,12 +396,43 @@ version = "0.4.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "loom"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"generator",
|
||||||
|
"scoped-tls",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "matchers"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||||
|
dependencies = [
|
||||||
|
"regex-automata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.3"
|
version = "2.8.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nu-ansi-term"
|
||||||
|
version = "0.50.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint"
|
name = "num-bigint"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -456,6 +534,23 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.4.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.8.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "roxmltree"
|
name = "roxmltree"
|
||||||
version = "0.20.0"
|
version = "0.20.0"
|
||||||
@@ -475,6 +570,18 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scoped-tls"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.229"
|
version = "1.0.229"
|
||||||
@@ -540,12 +647,33 @@ dependencies = [
|
|||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sharded-slab"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.10"
|
version = "0.3.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smallvec"
|
||||||
|
version = "1.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.119"
|
version = "2.0.119"
|
||||||
@@ -581,6 +709,15 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thread_local"
|
||||||
|
version = "1.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
@@ -610,6 +747,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"valuable",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-log"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-subscriber"
|
||||||
|
version = "0.3.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||||
|
dependencies = [
|
||||||
|
"matchers",
|
||||||
|
"nu-ansi-term",
|
||||||
|
"once_cell",
|
||||||
|
"regex-automata",
|
||||||
|
"sharded-slab",
|
||||||
|
"smallvec",
|
||||||
|
"thread_local",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -630,6 +797,12 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "valuable"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -642,6 +815,15 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
|
|||||||
+25
@@ -101,10 +101,35 @@ libc = "0.2"
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
|
# Permutation model checker for the `scsi::fd_handoff` atomics — the fd
|
||||||
|
# hand-off between a Linux recovery thread and `SgIoTransport::drop`. A normal
|
||||||
|
# test cannot see a missing release edge there: the orderings compile to the
|
||||||
|
# same instructions on x86_64, so the wrong ones pass every time. loom
|
||||||
|
# enumerates the executions the C++20 model permits and reports the leaked
|
||||||
|
# descriptor directly.
|
||||||
|
#
|
||||||
|
# Behind `cfg(loom)`, which nothing sets unless you ask for it, so this is out
|
||||||
|
# of the graph for every ordinary build and for every CI job but the dedicated
|
||||||
|
# `loom` one. Run it with:
|
||||||
|
#
|
||||||
|
# RUSTFLAGS="--cfg loom" cargo test --lib --no-default-features \
|
||||||
|
# --features scsi fd_handoff
|
||||||
|
#
|
||||||
|
# `cargo test --lib` specifically: as a DEV-dependency loom is only in scope for
|
||||||
|
# test targets, which is why `fd_handoff` gates its import on `all(loom, test)`
|
||||||
|
# and falls back to std elsewhere. Without that, `--cfg loom` would break plain
|
||||||
|
# `cargo build`/`clippy` with an unresolved-crate error.
|
||||||
|
[target.'cfg(loom)'.dev-dependencies]
|
||||||
|
loom = "0.7"
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "sgio_read"
|
name = "sgio_read"
|
||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
|
|
||||||
|
[lints.rust]
|
||||||
|
# `loom` is set by RUSTFLAGS for the model checker above, never by a feature.
|
||||||
|
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(loom)'] }
|
||||||
|
|
||||||
[lints.clippy]
|
[lints.clippy]
|
||||||
uninlined_format_args = "allow"
|
uninlined_format_args = "allow"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# libfreemkv — local dev helper.
|
# libfreemkv — local dev helper.
|
||||||
# Mirrors the workspace-wide CI checks but scoped to this single crate.
|
# Mirrors the workspace-wide CI checks but scoped to this single crate.
|
||||||
|
|
||||||
.PHONY: test build check ci clean
|
.PHONY: test build check loom ci clean
|
||||||
|
|
||||||
test:
|
test:
|
||||||
cargo test --tests
|
cargo test --tests
|
||||||
@@ -13,7 +13,13 @@ check:
|
|||||||
cargo fmt --check
|
cargo fmt --check
|
||||||
cargo clippy --all-targets -- -D warnings
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
ci: check build test
|
# Model-check the scsi::fd_handoff atomics. `cargo test` cannot fail on those
|
||||||
|
# orderings — they compile to the same instructions on x86_64 — so this is the
|
||||||
|
# only check on them. Mirrors the `loom` CI job.
|
||||||
|
loom:
|
||||||
|
RUSTFLAGS="--cfg loom" cargo test --lib --no-default-features --features scsi fd_handoff
|
||||||
|
|
||||||
|
ci: check build test loom
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
cargo clean
|
cargo clean
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
//! The fd hand-off between a Linux recovery thread and the transport that
|
||||||
|
//! spawned it.
|
||||||
|
//!
|
||||||
|
//! When `SgIoTransport::execute` sees a transport-level failure it abandons
|
||||||
|
//! its fd and spawns a detached thread to open a fresh one. That thread has no
|
||||||
|
//! reference back to the transport — the transport may have been dropped
|
||||||
|
//! before the `open()` returns — so the two sides rendezvous through a
|
||||||
|
//! single-slot mailbox: an `AtomicI32` holding an owned fd (`-1` = empty) and
|
||||||
|
//! an `AtomicBool` saying whether the transport is already gone.
|
||||||
|
//!
|
||||||
|
//! The invariant, and the only thing that matters here: **every fd that
|
||||||
|
//! reaches the slot is closed exactly once.** Closing zero times leaks a
|
||||||
|
//! descriptor; closing twice is worse than a leak, because a `close()` of an
|
||||||
|
//! fd number the process has since reused closes an unrelated file.
|
||||||
|
//!
|
||||||
|
//! # Why this lives in `scsi/`, not in `linux.rs`
|
||||||
|
//!
|
||||||
|
//! `linux.rs` is compiled on exactly one host, so nothing in it can be tested
|
||||||
|
//! on macOS or Windows CI — the same reason [`super::checked_cdb_len`] lives
|
||||||
|
//! in the parent module. The protocol below is plain atomics with no syscalls
|
||||||
|
//! in it, so it is compiled and tested on every platform even though Linux is
|
||||||
|
//! its only caller.
|
||||||
|
//!
|
||||||
|
//! # Memory ordering
|
||||||
|
//!
|
||||||
|
//! The subtle case is a `Drop` that races the recovery thread. The two sides
|
||||||
|
//! are ordered only through the slot, so the release edge has to be carried by
|
||||||
|
//! the slot's own operations:
|
||||||
|
//!
|
||||||
|
//! 1. [`claim_for_teardown`] publishes `dead = true` and then claims the slot.
|
||||||
|
//! 2. [`publish_recovered_fd`] fills the slot and then reads `dead`.
|
||||||
|
//!
|
||||||
|
//! Publish-first needs nothing special: teardown's swap is an RMW, so it reads
|
||||||
|
//! the last value in the slot's modification order — the fd — and closes it by
|
||||||
|
//! coherence alone. The dangerous order is teardown-first, where
|
||||||
|
//! `claim_for_teardown` finds the slot empty and returns, leaving the recovery
|
||||||
|
//! thread as the only one that can close the fd — and it will only do so if its
|
||||||
|
//! `dead` load observes the `true`.
|
||||||
|
//!
|
||||||
|
//! That observation is not free. Rust's atomics follow the C++20 rules
|
||||||
|
//! (`std::sync::atomic` module docs: "Rust atomics currently follow the same
|
||||||
|
//! rules as C++20 atomics, specifically the rules from the `intro.races`
|
||||||
|
//! section, without the 'consume' memory ordering"), and under those rules:
|
||||||
|
//!
|
||||||
|
//! - Every operation on the slot is a read-modify-write — `swap` or
|
||||||
|
//! `compare_exchange`, never a plain store after construction. That matters,
|
||||||
|
//! because "a release sequence headed by a release operation A on an atomic
|
||||||
|
//! object M is a maximal contiguous sub-sequence of side effects in the
|
||||||
|
//! modification order of M, where the first operation is A, and every
|
||||||
|
//! subsequent operation is an atomic read-modify-write operation"
|
||||||
|
//! ([intro.races], N4868). So once `claim_for_teardown`'s `swap` executes,
|
||||||
|
//! *every* later slot operation is in its release sequence — including one
|
||||||
|
//! that lands behind an intervening drain or a second recovery thread,
|
||||||
|
//! which is why the argument does not depend on the CAS reading the `swap`'s
|
||||||
|
//! value directly.
|
||||||
|
//! - Landing in that sequence is still not enough to synchronize with its
|
||||||
|
//! head. That needs both halves: "an atomic operation A that performs a
|
||||||
|
//! release operation on an atomic object M synchronizes with an atomic
|
||||||
|
//! operation B that performs an acquire operation on M and takes its value
|
||||||
|
//! from any side effect in the release sequence headed by A"
|
||||||
|
//! ([atomics.order]/2, N4868).
|
||||||
|
//!
|
||||||
|
//! Both halves of that were missing:
|
||||||
|
//!
|
||||||
|
//! - `swap(-1, Acquire)` is not a release store. Per the [`Ordering::Acquire`]
|
||||||
|
//! docs, "using this ordering for an operation that combines loads and
|
||||||
|
//! stores leads to a `Relaxed` store operation" — so it heads no release
|
||||||
|
//! sequence, and there was nothing for the CAS to synchronize with.
|
||||||
|
//! - `compare_exchange(.., Release, Relaxed)` is not an acquire load. Per the
|
||||||
|
//! [`Ordering::Release`] docs, it "leads to a `Relaxed` load operation" — so
|
||||||
|
//! even against a proper release store it would establish no edge.
|
||||||
|
//!
|
||||||
|
//! Upgrading only one side therefore fixes nothing; the loom models in this
|
||||||
|
//! file still report the leak with either half applied alone. Both are
|
||||||
|
//! `AcqRel` below.
|
||||||
|
//!
|
||||||
|
//! Concretely, the CAS in [`publish_recovered_fd`] carries both roles at once:
|
||||||
|
//! its release half publishes the `open()` to [`take_recovered_fd`], and its
|
||||||
|
//! acquire half is what lets the `dead` load that follows observe a teardown
|
||||||
|
//! that has already claimed the slot.
|
||||||
|
//!
|
||||||
|
//! [`take_recovered_fd`] is deliberately left at `Acquire`, which makes its
|
||||||
|
//! store half relaxed. That is harmless precisely because of the release-
|
||||||
|
//! sequence rule above: an RMW extends the sequence whatever its own ordering,
|
||||||
|
//! so a drain landing between `claim_for_teardown` and a later CAS does not
|
||||||
|
//! break the edge. It needs no release half of its own — it is called from
|
||||||
|
//! `execute()`, which cannot run concurrently with `Drop` (both need the
|
||||||
|
//! transport), so it never has anything of its own to publish to a teardown.
|
||||||
|
//! `loom_drain_does_not_break_the_release_sequence` is the check on that.
|
||||||
|
//!
|
||||||
|
//! `dead`'s `Release` store is belt-and-braces: it is sequenced before the
|
||||||
|
//! `AcqRel` swap, and the swap is what carries the edge. Downgrading it to
|
||||||
|
//! `Relaxed` leaves all three models passing.
|
||||||
|
//!
|
||||||
|
//! # Building the model
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! RUSTFLAGS="--cfg loom" cargo test --lib --no-default-features \
|
||||||
|
//! --features scsi fd_handoff
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Under those flags the protocol runs on loom's instrumented atomics, so the
|
||||||
|
//! models check the code the transport actually calls rather than a
|
||||||
|
//! transcription that could drift from it. The alias below is gated on `test`
|
||||||
|
//! as well as `loom`, because `loom` is a DEV-dependency and so is not in the
|
||||||
|
//! graph of a non-test target: `cfg(loom)` alone would break `cargo build`,
|
||||||
|
//! `cargo check` and `cargo clippy --all-targets` under those flags with an
|
||||||
|
//! unresolved-crate error. Non-test targets keep std's atomics.
|
||||||
|
//!
|
||||||
|
//! One consequence to know about: under `all(loom, test)`, `SgIoTransport`'s
|
||||||
|
//! atomics are loom's, and those panic outside a `loom::model`. No unit test
|
||||||
|
//! constructs a transport today — `raw_command` is a free-standing `fn` that
|
||||||
|
//! touches none — but the first one that does must live behind
|
||||||
|
//! `cfg(not(loom))`.
|
||||||
|
|
||||||
|
// See "Building the model" above for why this is gated on `test` too.
|
||||||
|
#[cfg(all(loom, test))]
|
||||||
|
pub(crate) use loom::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||||
|
#[cfg(not(all(loom, test)))]
|
||||||
|
pub(crate) use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
|
||||||
|
|
||||||
|
/// The empty slot. Not a valid fd; `open()` never returns a negative value.
|
||||||
|
pub(crate) const EMPTY: i32 = -1;
|
||||||
|
|
||||||
|
/// Interpret a value taken out of the slot. Only a non-negative value is an fd
|
||||||
|
/// the caller now owns; `EMPTY` — or any other negative, which nothing can put
|
||||||
|
/// there — is nothing to close. Deliberately `>= 0` rather than `!= EMPTY`, to
|
||||||
|
/// keep the guard the call sites used before this protocol was factored out.
|
||||||
|
fn taken(value: i32) -> Option<i32> {
|
||||||
|
(value >= 0).then_some(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take whatever fd a recovery thread has published, transferring ownership to
|
||||||
|
/// the caller. `None` if no recovery has completed.
|
||||||
|
///
|
||||||
|
/// Called from `execute()` at the top of a command, which is why it does not
|
||||||
|
/// consult `dead`: a live `execute()` means the transport is not being torn
|
||||||
|
/// down. `Acquire` pairs with the `AcqRel` CAS in [`publish_recovered_fd`] so
|
||||||
|
/// the `open()` that produced the fd happens-before the caller uses it.
|
||||||
|
pub(crate) fn take_recovered_fd(slot: &AtomicI32) -> Option<i32> {
|
||||||
|
taken(slot.swap(EMPTY, Ordering::Acquire))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish a freshly opened fd from a recovery thread. Returns the fd the
|
||||||
|
/// **caller** must now close, or `None` if it was handed off:
|
||||||
|
///
|
||||||
|
/// - The slot was already full — another recovery thread won, so we close
|
||||||
|
/// ours rather than overwrite (and leak) the winner's.
|
||||||
|
/// - The transport was torn down mid-`open()`; nothing will drain the slot,
|
||||||
|
/// so we re-claim through it rather than close `new_fd` directly, which
|
||||||
|
/// would double-close against a teardown swapping at the same moment.
|
||||||
|
///
|
||||||
|
/// Both orderings below are `AcqRel`; the module docs say why.
|
||||||
|
pub(crate) fn publish_recovered_fd(
|
||||||
|
slot: &AtomicI32,
|
||||||
|
dead: &AtomicBool,
|
||||||
|
new_fd: i32,
|
||||||
|
) -> Option<i32> {
|
||||||
|
// A negative `new_fd` would go in as the `EMPTY` sentinel, or as a value no
|
||||||
|
// drain will hand back — silently lost either way. The caller checks
|
||||||
|
// `open()` before getting here; this pins that as the contract.
|
||||||
|
debug_assert!(new_fd >= 0, "only a real fd may enter the slot");
|
||||||
|
if slot
|
||||||
|
.compare_exchange(EMPTY, new_fd, Ordering::AcqRel, Ordering::Relaxed)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Some(new_fd);
|
||||||
|
}
|
||||||
|
if dead.load(Ordering::Acquire) {
|
||||||
|
return taken(slot.swap(EMPTY, Ordering::AcqRel));
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark the transport dead and claim any fd still sitting in the slot,
|
||||||
|
/// transferring ownership to the caller. Called from `Drop`.
|
||||||
|
///
|
||||||
|
/// The `dead` store must be ordered before the claim: a recovery thread that
|
||||||
|
/// fills the slot after we have drained it has to see `dead == true`, or its
|
||||||
|
/// fd is never closed by anyone. `AcqRel` on the swap is what carries that
|
||||||
|
/// edge — see the module docs for why `Acquire` alone did not.
|
||||||
|
pub(crate) fn claim_for_teardown(slot: &AtomicI32, dead: &AtomicBool) -> Option<i32> {
|
||||||
|
dead.store(true, Ordering::Release);
|
||||||
|
taken(slot.swap(EMPTY, Ordering::AcqRel))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ownership tests. These run on every platform's CI (the module has no Linux
|
||||||
|
/// in it) and drive each case directly rather than hoping a thread schedule
|
||||||
|
/// produces it. The assertion is always the same one: an fd that enters the
|
||||||
|
/// slot leaves it through exactly one caller.
|
||||||
|
///
|
||||||
|
/// They cover the ownership protocol, NOT the memory orderings — those compile
|
||||||
|
/// to the same instructions on x86_64, so these pass against the wrong ones.
|
||||||
|
/// [`loom_tests`] is what covers the orderings.
|
||||||
|
#[cfg(all(test, not(loom)))]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Fresh slot + liveness flag, as `SgIoTransport::open` builds them.
|
||||||
|
fn slot() -> (AtomicI32, AtomicBool) {
|
||||||
|
(AtomicI32::new(EMPTY), AtomicBool::new(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Positive: the ordinary hand-off ────────────────────────────────────
|
||||||
|
|
||||||
|
/// The path taken on every successful recovery: the thread publishes, the
|
||||||
|
/// next `execute()` drains. Neither side is asked to close anything.
|
||||||
|
#[test]
|
||||||
|
fn published_fd_reaches_the_next_execute() {
|
||||||
|
let (s, d) = slot();
|
||||||
|
assert_eq!(
|
||||||
|
publish_recovered_fd(&s, &d, 7),
|
||||||
|
None,
|
||||||
|
"publisher handed off"
|
||||||
|
);
|
||||||
|
assert_eq!(take_recovered_fd(&s), Some(7), "execute() picks it up");
|
||||||
|
assert_eq!(take_recovered_fd(&s), None, "slot is empty afterwards");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recovery completes, but the transport is dropped before another
|
||||||
|
/// `execute()` runs (the abort-on-wedge path). Teardown owns the close.
|
||||||
|
#[test]
|
||||||
|
fn teardown_claims_an_undrained_fd() {
|
||||||
|
let (s, d) = slot();
|
||||||
|
assert_eq!(publish_recovered_fd(&s, &d, 7), None);
|
||||||
|
assert_eq!(claim_for_teardown(&s, &d), Some(7), "Drop closes it");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A small thing that the `EMPTY` sentinel exists to get right: `0` is a
|
||||||
|
/// perfectly legal descriptor and must not read as an empty slot.
|
||||||
|
#[test]
|
||||||
|
fn fd_zero_is_a_real_fd_not_an_empty_slot() {
|
||||||
|
let (s, d) = slot();
|
||||||
|
assert_eq!(publish_recovered_fd(&s, &d, 0), None);
|
||||||
|
assert_eq!(take_recovered_fd(&s), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Negative: every way the fd could go unclosed or be closed twice ────
|
||||||
|
|
||||||
|
/// Teardown wins the race. It drains an empty slot and will never look
|
||||||
|
/// again, so the late publisher must get its own fd back — this is the
|
||||||
|
/// leak the `Acquire`/`Release` orderings could produce in practice.
|
||||||
|
#[test]
|
||||||
|
fn publish_after_teardown_returns_the_fd_to_the_publisher() {
|
||||||
|
let (s, d) = slot();
|
||||||
|
assert_eq!(claim_for_teardown(&s, &d), None, "nothing published yet");
|
||||||
|
assert_eq!(
|
||||||
|
publish_recovered_fd(&s, &d, 7),
|
||||||
|
Some(7),
|
||||||
|
"transport is gone — publisher must close its own fd"
|
||||||
|
);
|
||||||
|
assert_eq!(take_recovered_fd(&s), None, "and must not leave it behind");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two recovery threads for one transport. The loser closes the fd it
|
||||||
|
/// opened; it must not overwrite the winner's, which nothing would then
|
||||||
|
/// close.
|
||||||
|
#[test]
|
||||||
|
fn losing_publisher_closes_its_own_fd_and_leaves_the_winners() {
|
||||||
|
let (s, d) = slot();
|
||||||
|
assert_eq!(publish_recovered_fd(&s, &d, 7), None, "winner");
|
||||||
|
assert_eq!(publish_recovered_fd(&s, &d, 9), Some(9), "loser closes 9");
|
||||||
|
assert_eq!(take_recovered_fd(&s), Some(7), "winner's fd survived");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Teardown after the fd has already been drained by `execute()`. The slot
|
||||||
|
/// is empty and teardown must claim nothing — returning the stale value
|
||||||
|
/// here would close an fd the transport is still using.
|
||||||
|
#[test]
|
||||||
|
fn teardown_after_drain_claims_nothing() {
|
||||||
|
let (s, d) = slot();
|
||||||
|
assert_eq!(publish_recovered_fd(&s, &d, 7), None);
|
||||||
|
assert_eq!(take_recovered_fd(&s), Some(7));
|
||||||
|
assert_eq!(claim_for_teardown(&s, &d), None, "no double close");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Teardown twice (belt and braces — `Drop` runs once, but the second
|
||||||
|
/// claim must be inert rather than re-yielding the fd).
|
||||||
|
#[test]
|
||||||
|
fn teardown_is_idempotent() {
|
||||||
|
let (s, d) = slot();
|
||||||
|
assert_eq!(publish_recovered_fd(&s, &d, 7), None);
|
||||||
|
assert_eq!(claim_for_teardown(&s, &d), Some(7));
|
||||||
|
assert_eq!(claim_for_teardown(&s, &d), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty slot, nothing published: both drains are no-ops.
|
||||||
|
#[test]
|
||||||
|
fn draining_an_empty_slot_yields_nothing() {
|
||||||
|
let (s, d) = slot();
|
||||||
|
assert_eq!(take_recovered_fd(&s), None);
|
||||||
|
assert_eq!(claim_for_teardown(&s, &d), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accounting over the orders the three operations can run in, one whole
|
||||||
|
/// operation at a time: for each, the fd is handed to exactly one caller.
|
||||||
|
///
|
||||||
|
/// Sequences, not interleavings — each function is the atomic unit here, so
|
||||||
|
/// this cannot express the case the ordering bug lives in (a teardown swap
|
||||||
|
/// landing between `publish_recovered_fd`'s CAS and its `dead` load). That
|
||||||
|
/// one is only reachable in the loom models below.
|
||||||
|
#[test]
|
||||||
|
fn every_sequence_closes_the_fd_exactly_once() {
|
||||||
|
// Each sequence returns how many callers were handed the fd across the
|
||||||
|
// whole run; the answer is always exactly one.
|
||||||
|
fn exactly_one(order: &str, run: impl Fn() -> usize) {
|
||||||
|
assert_eq!(run(), 1, "`{order}` must close fd 7 exactly once");
|
||||||
|
}
|
||||||
|
|
||||||
|
exactly_one("publish, drain", || {
|
||||||
|
let (s, d) = slot();
|
||||||
|
publish_recovered_fd(&s, &d, 7).is_some() as usize
|
||||||
|
+ take_recovered_fd(&s).is_some() as usize
|
||||||
|
});
|
||||||
|
exactly_one("publish, teardown", || {
|
||||||
|
let (s, d) = slot();
|
||||||
|
publish_recovered_fd(&s, &d, 7).is_some() as usize
|
||||||
|
+ claim_for_teardown(&s, &d).is_some() as usize
|
||||||
|
});
|
||||||
|
exactly_one("teardown, publish", || {
|
||||||
|
let (s, d) = slot();
|
||||||
|
claim_for_teardown(&s, &d).is_some() as usize
|
||||||
|
+ publish_recovered_fd(&s, &d, 7).is_some() as usize
|
||||||
|
});
|
||||||
|
exactly_one("drain, publish, teardown", || {
|
||||||
|
let (s, d) = slot();
|
||||||
|
take_recovered_fd(&s).is_some() as usize
|
||||||
|
+ publish_recovered_fd(&s, &d, 7).is_some() as usize
|
||||||
|
+ claim_for_teardown(&s, &d).is_some() as usize
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The real race, run for real: a teardown thread against a recovery
|
||||||
|
/// thread, many times over. This exercises the protocol under a genuine
|
||||||
|
/// scheduler — it does NOT prove the memory orderings (x86 and, in
|
||||||
|
/// practice, AArch64 will happily pass the weaker ones). The loom model
|
||||||
|
/// below is what covers those; this covers the ownership logic.
|
||||||
|
#[test]
|
||||||
|
fn concurrent_teardown_and_publish_close_each_fd_exactly_once() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
for round in 0..2_000 {
|
||||||
|
let s = Arc::new(AtomicI32::new(EMPTY));
|
||||||
|
let d = Arc::new(AtomicBool::new(false));
|
||||||
|
let fd = 7;
|
||||||
|
|
||||||
|
let (s1, d1) = (Arc::clone(&s), Arc::clone(&d));
|
||||||
|
let teardown = std::thread::spawn(move || claim_for_teardown(&s1, &d1));
|
||||||
|
let (s2, d2) = (Arc::clone(&s), Arc::clone(&d));
|
||||||
|
let recovery = std::thread::spawn(move || publish_recovered_fd(&s2, &d2, fd));
|
||||||
|
|
||||||
|
let claimed = teardown.join().unwrap();
|
||||||
|
let returned = recovery.join().unwrap();
|
||||||
|
|
||||||
|
let closers = claimed.is_some() as usize + returned.is_some() as usize;
|
||||||
|
assert_eq!(
|
||||||
|
closers, 1,
|
||||||
|
"round {round}: fd must be closed exactly once, not {closers} times \
|
||||||
|
(0 = leaked descriptor, 2 = close() of a reused fd number)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
s.load(Ordering::Acquire),
|
||||||
|
EMPTY,
|
||||||
|
"round {round}: slot must not be left holding an fd nobody owns"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Memory-ordering models. `cargo test` cannot observe a missing release edge:
|
||||||
|
/// the orderings compile to the same instructions on x86_64, and on AArch64 the
|
||||||
|
/// window is far too narrow to hit by chance. loom enumerates the executions
|
||||||
|
/// the C++20 model permits instead, and reports the stale `dead` load directly.
|
||||||
|
#[cfg(all(test, loom))]
|
||||||
|
mod loom_tests {
|
||||||
|
use super::*;
|
||||||
|
use loom::sync::Arc;
|
||||||
|
use loom::sync::atomic::AtomicUsize;
|
||||||
|
|
||||||
|
/// `Drop` against a recovery thread: the execution this file exists for.
|
||||||
|
///
|
||||||
|
/// With `swap(.., Acquire)` in [`claim_for_teardown`], or with
|
||||||
|
/// `compare_exchange(.., Release, ..)` in [`publish_recovered_fd`] — either
|
||||||
|
/// one alone, not just both — loom finds an execution where teardown
|
||||||
|
/// drains the empty slot, the recovery thread's CAS then fills it, and the
|
||||||
|
/// `dead` load still reads `false`. Nobody closes the fd.
|
||||||
|
#[test]
|
||||||
|
fn loom_teardown_racing_publish_closes_the_fd_exactly_once() {
|
||||||
|
loom::model(|| {
|
||||||
|
let slot = Arc::new(AtomicI32::new(EMPTY));
|
||||||
|
let dead = Arc::new(AtomicBool::new(false));
|
||||||
|
let closes = Arc::new(AtomicUsize::new(0));
|
||||||
|
|
||||||
|
let (s, d, c) = (slot.clone(), dead.clone(), closes.clone());
|
||||||
|
let teardown = loom::thread::spawn(move || {
|
||||||
|
if claim_for_teardown(&s, &d).is_some() {
|
||||||
|
c.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let (s, d, c) = (slot.clone(), dead.clone(), closes.clone());
|
||||||
|
let recovery = loom::thread::spawn(move || {
|
||||||
|
if publish_recovered_fd(&s, &d, 7).is_some() {
|
||||||
|
c.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
teardown.join().unwrap();
|
||||||
|
recovery.join().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
closes.load(Ordering::Relaxed),
|
||||||
|
1,
|
||||||
|
"fd must be closed exactly once (0 = leaked, 2 = double close)"
|
||||||
|
);
|
||||||
|
assert_eq!(slot.load(Ordering::Relaxed), EMPTY, "slot left non-empty");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two recovery threads against a teardown. `execute()` spawns a thread on
|
||||||
|
/// every transport-level failure and an earlier one may still be blocked in
|
||||||
|
/// `open()`, so this is reachable, and it is the case where a CAS lands
|
||||||
|
/// behind an intervening slot operation rather than reading the teardown
|
||||||
|
/// swap's value directly — the release-sequence half of the module docs.
|
||||||
|
///
|
||||||
|
/// Bounded rather than exhaustive: unbounded, this model runs for many
|
||||||
|
/// minutes, which is too slow for the CI step. Three preemptions is enough
|
||||||
|
/// to reach the leaking schedule — the old orderings fail this test.
|
||||||
|
#[test]
|
||||||
|
fn loom_two_publishers_racing_teardown_close_each_fd_exactly_once() {
|
||||||
|
let mut model = loom::model::Builder::new();
|
||||||
|
model.preemption_bound = Some(3);
|
||||||
|
model.check(|| {
|
||||||
|
let slot = Arc::new(AtomicI32::new(EMPTY));
|
||||||
|
let dead = Arc::new(AtomicBool::new(false));
|
||||||
|
let closes = Arc::new(AtomicUsize::new(0));
|
||||||
|
|
||||||
|
let publishers: Vec<_> = [7, 9]
|
||||||
|
.into_iter()
|
||||||
|
.map(|fd| {
|
||||||
|
let (s, d, c) = (slot.clone(), dead.clone(), closes.clone());
|
||||||
|
loom::thread::spawn(move || {
|
||||||
|
if publish_recovered_fd(&s, &d, fd).is_some() {
|
||||||
|
c.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let (s, d, c) = (slot.clone(), dead.clone(), closes.clone());
|
||||||
|
let teardown = loom::thread::spawn(move || {
|
||||||
|
if claim_for_teardown(&s, &d).is_some() {
|
||||||
|
c.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for p in publishers {
|
||||||
|
p.join().unwrap();
|
||||||
|
}
|
||||||
|
teardown.join().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
closes.load(Ordering::Relaxed),
|
||||||
|
2,
|
||||||
|
"both fds must be closed, exactly once each"
|
||||||
|
);
|
||||||
|
assert_eq!(slot.load(Ordering::Relaxed), EMPTY, "slot left non-empty");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The check on leaving [`take_recovered_fd`] at `Acquire` — i.e. with a
|
||||||
|
/// relaxed store half. A drain landing between the teardown swap and a
|
||||||
|
/// recovery CAS must not break the release sequence the edge rides on.
|
||||||
|
#[test]
|
||||||
|
fn loom_drain_does_not_break_the_release_sequence() {
|
||||||
|
loom::model(|| {
|
||||||
|
let slot = Arc::new(AtomicI32::new(EMPTY));
|
||||||
|
let dead = Arc::new(AtomicBool::new(false));
|
||||||
|
let closes = Arc::new(AtomicUsize::new(0));
|
||||||
|
|
||||||
|
// execute() drains, then (later, same transport) Drop tears down.
|
||||||
|
let (s, d, c) = (slot.clone(), dead.clone(), closes.clone());
|
||||||
|
let transport = loom::thread::spawn(move || {
|
||||||
|
if take_recovered_fd(&s).is_some() {
|
||||||
|
c.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
if claim_for_teardown(&s, &d).is_some() {
|
||||||
|
c.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let (s, d, c) = (slot.clone(), dead.clone(), closes.clone());
|
||||||
|
let recovery = loom::thread::spawn(move || {
|
||||||
|
if publish_recovered_fd(&s, &d, 7).is_some() {
|
||||||
|
c.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
transport.join().unwrap();
|
||||||
|
recovery.join().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
closes.load(Ordering::Relaxed),
|
||||||
|
1,
|
||||||
|
"fd must be closed exactly once (0 = leaked, 2 = double close)"
|
||||||
|
);
|
||||||
|
assert_eq!(slot.load(Ordering::Relaxed), EMPTY, "slot left non-empty");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+163
-56
@@ -9,9 +9,13 @@
|
|||||||
//! See docs/scsi-linux.md for the escalation ladder, the design
|
//! See docs/scsi-linux.md for the escalation ladder, the design
|
||||||
//! rationale, and the pre-0.13.20 async-poll design it replaced.
|
//! rationale, and the pre-0.13.20 async-poll design it replaced.
|
||||||
|
|
||||||
|
use super::fd_handoff::{
|
||||||
|
AtomicBool, AtomicI32, claim_for_teardown, publish_recovered_fd, take_recovered_fd,
|
||||||
|
};
|
||||||
use super::{DataDirection, ScsiResult, ScsiTransport};
|
use super::{DataDirection, ScsiResult, ScsiTransport};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
const SG_IO: u32 = 0x2285;
|
const SG_IO: u32 = 0x2285;
|
||||||
const SG_DXFER_NONE: i32 = -1;
|
const SG_DXFER_NONE: i32 = -1;
|
||||||
@@ -24,6 +28,10 @@ const SG_FLAG_Q_AT_HEAD: u32 = 0x10;
|
|||||||
/// 16 bytes. Matches `K_MAX_CDB_SIZE` in the macOS and Windows backends.
|
/// 16 bytes. Matches `K_MAX_CDB_SIZE` in the macOS and Windows backends.
|
||||||
const K_MAX_CDB_SIZE: usize = 16;
|
const K_MAX_CDB_SIZE: usize = 16;
|
||||||
|
|
||||||
|
/// SPC-4 PREVENT ALLOW MEDIUM REMOVAL (0x1E) with PREVENT=0. Sent by `Drop` so
|
||||||
|
/// the tray is not left locked; a six-byte group-0 CDB.
|
||||||
|
const ALLOW_MEDIUM_REMOVAL: [u8; 6] = [0x1E, 0, 0, 0, 0, 0];
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
#[allow(non_camel_case_types)]
|
#[allow(non_camel_case_types)]
|
||||||
struct sg_io_hdr {
|
struct sg_io_hdr {
|
||||||
@@ -61,12 +69,15 @@ const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 64);
|
|||||||
pub struct SgIoTransport {
|
pub struct SgIoTransport {
|
||||||
pub fd: i32,
|
pub fd: i32,
|
||||||
device_path: std::path::PathBuf,
|
device_path: std::path::PathBuf,
|
||||||
pub fd_recovery: std::sync::Arc<std::sync::atomic::AtomicI32>,
|
/// Single-slot mailbox a background recovery thread publishes a freshly
|
||||||
/// Set to `true` by `Drop` before the transport is torn down. The
|
/// opened fd into. Drained by `execute()`, or claimed by `Drop` if the
|
||||||
/// recovery thread checks this after a successful `compare_exchange`
|
/// transport dies first. The protocol — and the memory ordering that makes
|
||||||
/// and closes `new_fd` itself when the transport is already gone,
|
/// it leak-free — lives in `super::fd_handoff`.
|
||||||
/// preventing an fd leak when Drop races the recovery thread.
|
pub fd_recovery: Arc<AtomicI32>,
|
||||||
dead: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
/// Set to `true` by `Drop` before it claims the slot. A recovery thread
|
||||||
|
/// that publishes after that point sees it and closes its own fd, since
|
||||||
|
/// nothing will ever drain the slot again.
|
||||||
|
dead: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SgIoTransport {
|
impl SgIoTransport {
|
||||||
@@ -86,8 +97,8 @@ impl SgIoTransport {
|
|||||||
Ok(SgIoTransport {
|
Ok(SgIoTransport {
|
||||||
fd,
|
fd,
|
||||||
device_path: device,
|
device_path: device,
|
||||||
fd_recovery: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(-1)),
|
fd_recovery: Arc::new(AtomicI32::new(super::fd_handoff::EMPTY)),
|
||||||
dead: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
dead: Arc::new(AtomicBool::new(false)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,14 +118,22 @@ impl SgIoTransport {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a raw SCSI command on an fd. Used by reset() before the
|
/// Send a raw SCSI command on a bare fd, for the one caller that has no
|
||||||
/// transport is constructed.
|
/// live `&mut self` to route through `execute()`: `Drop`, unlocking the
|
||||||
fn raw_command(fd: i32, cdb: &[u8], timeout_ms: u32) -> std::result::Result<(), ()> {
|
/// tray on its way out.
|
||||||
|
///
|
||||||
|
/// The CDB goes through the shared [`super::checked_cdb_len`] guard rather
|
||||||
|
/// than the old `cdb.len().min(16)` clamp — see docs/scsi-mod.md,
|
||||||
|
/// "checked_cdb_len rationale". Returning a typed [`Error`] rather than
|
||||||
|
/// `()` is what lets `raw_command_cdb_guard_tests` tell a rejected CDB
|
||||||
|
/// from an I/O failure without a real `/dev/sg*` device.
|
||||||
|
fn raw_command(fd: i32, cdb: &[u8], timeout_ms: u32) -> Result<()> {
|
||||||
|
let cmd_len = super::checked_cdb_len(cdb, K_MAX_CDB_SIZE)?;
|
||||||
let mut sense = [0u8; 32];
|
let mut sense = [0u8; 32];
|
||||||
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
|
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
|
||||||
hdr.interface_id = b'S' as i32;
|
hdr.interface_id = b'S' as i32;
|
||||||
hdr.dxfer_direction = SG_DXFER_NONE;
|
hdr.dxfer_direction = SG_DXFER_NONE;
|
||||||
hdr.cmd_len = cdb.len().min(16) as u8;
|
hdr.cmd_len = cmd_len;
|
||||||
hdr.mx_sb_len = sense.len() as u8;
|
hdr.mx_sb_len = sense.len() as u8;
|
||||||
hdr.dxfer_len = 0;
|
hdr.dxfer_len = 0;
|
||||||
hdr.dxferp = std::ptr::null_mut();
|
hdr.dxferp = std::ptr::null_mut();
|
||||||
@@ -124,15 +143,32 @@ impl SgIoTransport {
|
|||||||
hdr.flags = SG_FLAG_Q_AT_HEAD;
|
hdr.flags = SG_FLAG_Q_AT_HEAD;
|
||||||
|
|
||||||
let ret = unsafe { libc::ioctl(fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
|
let ret = unsafe { libc::ioctl(fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
|
||||||
|
if ret < 0 {
|
||||||
|
return Err(Error::IoError {
|
||||||
|
source: std::io::Error::last_os_error(),
|
||||||
|
});
|
||||||
|
}
|
||||||
// Mask DRIVER_SENSE (0x08): it only flags "sense data present", not
|
// Mask DRIVER_SENSE (0x08): it only flags "sense data present", not
|
||||||
// a failure — matches execute()'s driver_status_real handling so a
|
// a failure — matches execute()'s driver_status_real handling so a
|
||||||
// benign CHECK CONDITION isn't misread as a transport error.
|
// benign CHECK CONDITION isn't misread as a transport error.
|
||||||
let driver_status_real = hdr.driver_status & !super::DRIVER_SENSE;
|
let driver_status_real = hdr.driver_status & !super::DRIVER_SENSE;
|
||||||
if ret < 0 || hdr.status != 0 || hdr.host_status != 0 || driver_status_real != 0 {
|
if hdr.host_status != 0 || driver_status_real != 0 {
|
||||||
Err(())
|
// No SCSI status was delivered: same synthesised sentinel and same
|
||||||
} else {
|
// `sense: None` that `execute()` reports for a transport wedge.
|
||||||
Ok(())
|
return Err(Error::ScsiError {
|
||||||
|
opcode: cdb[0],
|
||||||
|
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||||
|
sense: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
if hdr.status != 0 {
|
||||||
|
return Err(Error::ScsiError {
|
||||||
|
opcode: cdb[0],
|
||||||
|
status: hdr.status,
|
||||||
|
sense: Some(super::parse_sense(&sense, hdr.sb_len_wr)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_c_path(device: &Path) -> Vec<u8> {
|
fn to_c_path(device: &Path) -> Vec<u8> {
|
||||||
@@ -174,20 +210,13 @@ impl Drop for SgIoTransport {
|
|||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if self.fd >= 0 {
|
if self.fd >= 0 {
|
||||||
// Unlock tray before closing — don't leave it locked.
|
// Unlock tray before closing — don't leave it locked.
|
||||||
let _ = Self::raw_command(self.fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
|
let _ = Self::raw_command(self.fd, &ALLOW_MEDIUM_REMOVAL, 3_000);
|
||||||
unsafe { libc::close(self.fd) };
|
unsafe { libc::close(self.fd) };
|
||||||
}
|
}
|
||||||
// Signal the recovery thread this transport is gone. Must be set
|
|
||||||
// before the fd_recovery swap so it can't observe dead=false and
|
|
||||||
// store into a slot Drop is no longer going to drain.
|
|
||||||
self.dead.store(true, std::sync::atomic::Ordering::Release);
|
|
||||||
// A failed execute() spawns a thread that opens a fresh fd into
|
// A failed execute() spawns a thread that opens a fresh fd into
|
||||||
// fd_recovery, normally drained at the top of the next execute().
|
// fd_recovery, normally drained at the top of the next execute(). If
|
||||||
// If dropped first (abort-on-wedge), claim and close it here.
|
// dropped first (abort-on-wedge), claim and close it here.
|
||||||
let recovered = self
|
if let Some(recovered) = claim_for_teardown(&self.fd_recovery, &self.dead) {
|
||||||
.fd_recovery
|
|
||||||
.swap(-1, std::sync::atomic::Ordering::Acquire);
|
|
||||||
if recovered >= 0 {
|
|
||||||
unsafe { libc::close(recovered) };
|
unsafe { libc::close(recovered) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,10 +250,7 @@ impl ScsiTransport for SgIoTransport {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Check if a background recovery has produced a new fd.
|
// Check if a background recovery has produced a new fd.
|
||||||
let recovered = self
|
if let Some(recovered) = take_recovered_fd(&self.fd_recovery) {
|
||||||
.fd_recovery
|
|
||||||
.swap(-1, std::sync::atomic::Ordering::Acquire);
|
|
||||||
if recovered >= 0 {
|
|
||||||
// Close the old fd if it's still valid.
|
// Close the old fd if it's still valid.
|
||||||
if self.fd >= 0 {
|
if self.fd >= 0 {
|
||||||
unsafe { libc::close(self.fd) };
|
unsafe { libc::close(self.fd) };
|
||||||
@@ -329,31 +355,11 @@ impl ScsiTransport for SgIoTransport {
|
|||||||
if new_fd < 0 {
|
if new_fd < 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Publish only into an empty (-1) slot. If two recovery
|
// Hand the fd to the transport. Comes back to us only if
|
||||||
// threads race, the loser closes its own fd rather than
|
// nobody there will ever close it: another recovery thread
|
||||||
// overwriting (and leaking) the winner's.
|
// won the slot, or Drop already tore the transport down.
|
||||||
if recovery
|
if let Some(orphan) = publish_recovered_fd(&recovery, &dead, new_fd) {
|
||||||
.compare_exchange(
|
unsafe { libc::close(orphan) };
|
||||||
-1,
|
|
||||||
new_fd,
|
|
||||||
std::sync::atomic::Ordering::Release,
|
|
||||||
std::sync::atomic::Ordering::Relaxed,
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
// Another recovery thread already stored its fd; ours
|
|
||||||
// was not stored so it's our responsibility to close it.
|
|
||||||
unsafe { libc::close(new_fd) };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Check whether Drop raced us: if the transport is already
|
|
||||||
// dead it won't drain fd_recovery, so swap to atomically
|
|
||||||
// claim new_fd and close it; a -1 result means Drop won.
|
|
||||||
if dead.load(std::sync::atomic::Ordering::Acquire) {
|
|
||||||
let claimed = recovery.swap(-1, std::sync::atomic::Ordering::AcqRel);
|
|
||||||
if claimed >= 0 {
|
|
||||||
unsafe { libc::close(claimed) };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -586,3 +592,104 @@ pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CDB guard wiring ───────────────────────────────────────────────────────
|
||||||
|
#[cfg(test)]
|
||||||
|
mod raw_command_cdb_guard_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// `ioctl()` on this fails with `EBADF` before touching any device, so the
|
||||||
|
/// tests below need no `/dev/sg*` and have no side effects — they run in
|
||||||
|
/// ordinary CI. Any error that is NOT `InvalidCdbLength` therefore means
|
||||||
|
/// the CDB cleared the guard and the syscall was reached.
|
||||||
|
const NO_FD: i32 = -1;
|
||||||
|
|
||||||
|
fn reached_the_ioctl(err: &Error) -> bool {
|
||||||
|
!matches!(err, Error::InvalidCdbLength { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Negative: CDBs the guard must reject ──────────────────────────────
|
||||||
|
|
||||||
|
/// The regression this module exists for. `raw_command` used to set
|
||||||
|
/// `cmd_len = cdb.len().min(16)`, silently shortening an over-length CDB
|
||||||
|
/// into a *different* SPC-4 command and issuing it. It must fail the
|
||||||
|
/// caller instead, with the same error `execute()` gives.
|
||||||
|
///
|
||||||
|
/// Before the fix this test fails by reaching the `ioctl` and returning
|
||||||
|
/// `IoError(EBADF)` — the guard never ran.
|
||||||
|
#[test]
|
||||||
|
fn over_length_cdb_is_rejected_not_truncated() {
|
||||||
|
let cdb = [0u8; K_MAX_CDB_SIZE + 1];
|
||||||
|
match SgIoTransport::raw_command(NO_FD, &cdb, 3_000) {
|
||||||
|
Err(Error::InvalidCdbLength { len, max }) => {
|
||||||
|
assert_eq!(len, K_MAX_CDB_SIZE + 1);
|
||||||
|
assert_eq!(max, K_MAX_CDB_SIZE);
|
||||||
|
}
|
||||||
|
other => panic!(
|
||||||
|
"over-length CDB must be rejected with InvalidCdbLength, got {other:?} — \
|
||||||
|
a non-guard error means it was truncated to {K_MAX_CDB_SIZE} bytes and sent"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Well past the field width, to show the guard is a bound and not a
|
||||||
|
/// one-off check at `max + 1`.
|
||||||
|
#[test]
|
||||||
|
fn far_over_length_cdb_is_rejected() {
|
||||||
|
let cdb = [0u8; 260];
|
||||||
|
assert!(matches!(
|
||||||
|
SgIoTransport::raw_command(NO_FD, &cdb, 3_000),
|
||||||
|
Err(Error::InvalidCdbLength {
|
||||||
|
len: 260,
|
||||||
|
max: K_MAX_CDB_SIZE
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty CDB must be rejected rather than handed to the sg driver as a
|
||||||
|
/// zero-length command descriptor, which under SPC-4 is not a command at
|
||||||
|
/// all. (The pre-fix code did not panic on this — it never read the opcode
|
||||||
|
/// — it just issued `cmd_len = 0`. The two error paths added here DO read
|
||||||
|
/// `cdb[0]`, so the guard is now load-bearing for that too.)
|
||||||
|
#[test]
|
||||||
|
fn empty_cdb_is_rejected_before_the_opcode_is_read() {
|
||||||
|
assert!(matches!(
|
||||||
|
SgIoTransport::raw_command(NO_FD, &[], 3_000),
|
||||||
|
Err(Error::InvalidCdbLength {
|
||||||
|
len: 0,
|
||||||
|
max: K_MAX_CDB_SIZE
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Positive: CDBs the guard must let through ─────────────────────────
|
||||||
|
|
||||||
|
/// The only CDB `raw_command` is called with in the crate. It must still
|
||||||
|
/// reach the syscall — a guard that rejected this would silently stop
|
||||||
|
/// unlocking the tray on `Drop`.
|
||||||
|
#[test]
|
||||||
|
fn drops_allow_medium_removal_still_reaches_the_ioctl() {
|
||||||
|
let err = SgIoTransport::raw_command(NO_FD, &ALLOW_MEDIUM_REMOVAL, 3_000)
|
||||||
|
.expect_err("EBADF on fd -1");
|
||||||
|
assert!(
|
||||||
|
reached_the_ioctl(&err),
|
||||||
|
"the 6-byte CDB Drop sends must pass the guard, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every real SPC-4 CDB length (groups 0-5: 6, 10, 12, 16), plus the
|
||||||
|
/// shortest a caller could legally construct, plus the boundary case — a
|
||||||
|
/// CDB of exactly `K_MAX_CDB_SIZE` must not be caught by an off-by-one.
|
||||||
|
#[test]
|
||||||
|
fn in_range_cdb_lengths_reach_the_ioctl() {
|
||||||
|
for len in [1usize, 6, 10, 12, K_MAX_CDB_SIZE] {
|
||||||
|
let cdb = vec![0x1Eu8; len];
|
||||||
|
let err = SgIoTransport::raw_command(NO_FD, &cdb, 3_000).expect_err("EBADF on fd -1");
|
||||||
|
assert!(
|
||||||
|
reached_the_ioctl(&err),
|
||||||
|
"a {len}-byte CDB is within the {K_MAX_CDB_SIZE}-byte field and must \
|
||||||
|
pass the guard, got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ mod macos;
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
mod windows;
|
mod windows;
|
||||||
|
|
||||||
|
// The Linux recovery-thread fd hand-off. Compiled on every platform though
|
||||||
|
// only `linux.rs` uses it, so its tests run everywhere — the same reasoning as
|
||||||
|
// `checked_cdb_len`. Rationale and memory ordering: the module's own docs.
|
||||||
|
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||||
|
pub(crate) mod fd_handoff;
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|||||||
Reference in New Issue
Block a user