server/src/usbip_input_policy.cpp
Rama main · réplica de lectura del repositorio original.
/**
* @file src/usbip_input_policy.cpp
* @brief Definitions for the portable USB/IP importer policies.
*/
#include "usbip_input_policy.h"
#include <cctype>
#include <cstddef>
#include <stdexcept>
namespace input::usbip {
namespace {
// A real busid is a bus number and a port number, both small. The cap exists so that a
// pathological value from the network is refused before it reaches an argument vector.
constexpr std::size_t kMaxBusidLength = 16;
// Length of the "(1234:abcd)" form, which is the only parenthesised thing we trust.
constexpr std::size_t kVidPidLength = 11;
constexpr bool is_digit(const char c) {
return c >= '0' && c <= '9';
}
constexpr bool is_hex(const char c) {
return is_digit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}
constexpr char to_lower(const char c) {
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c;
}
std::string_view trim(std::string_view text) {
while (!text.empty() && (text.front() == ' ' || text.front() == '\t' || text.front() == '\r')) {
text.remove_prefix(1);
}
while (!text.empty() && (text.back() == ' ' || text.back() == '\t' || text.back() == '\r')) {
text.remove_suffix(1);
}
return text;
}
bool starts_with(const std::string_view text, const std::string_view prefix) {
return text.size() >= prefix.size() && text.substr(0, prefix.size()) == prefix;
}
/**
* @brief Pull "(1234:abcd)" out of a line, lowercased. Empty when there is none.
*
* The four hex digits on each side are checked exactly rather than merely looking for a
* colon inside brackets, because product names contain brackets of their own:
* "[Viper Ultimate (Wireless)]" must not be read as a device id.
*/
std::string extract_vid_pid(const std::string_view text) {
for (std::size_t i = 0; i + kVidPidLength <= text.size(); ++i) {
if (text[i] != '(' || text[i + 5] != ':' || text[i + 10] != ')') {
continue;
}
bool ok = true;
for (std::size_t k = 1; k <= 4 && ok; ++k) {
ok = is_hex(text[i + k]);
}
for (std::size_t k = 6; k <= 9 && ok; ++k) {
ok = is_hex(text[i + k]);
}
if (!ok) {
continue;
}
std::string out;
out.reserve(9);
for (std::size_t k = 1; k <= 4; ++k) {
out.push_back(to_lower(text[i + k]));
}
out.push_back(':');
for (std::size_t k = 6; k <= 9; ++k) {
out.push_back(to_lower(text[i + k]));
}
return out;
}
return {};
}
/// Drop a trailing "(1234:abcd)" from a description — the id is kept separately.
std::string strip_trailing_vid_pid(const std::string_view text) {
std::string_view s = trim(text);
if (s.size() >= kVidPidLength && s.back() == ')') {
const std::string_view tail = s.substr(s.size() - kVidPidLength);
if (tail.front() == '(' && extract_vid_pid(tail).size() == 9) {
s = trim(s.substr(0, s.size() - kVidPidLength));
}
}
return std::string(s);
}
/// "Razer USA, Ltd : RC30-0305 ..." -> vendor and product.
void split_vendor_product(const std::string_view text, Device &device) {
const std::string_view s = trim(text);
const auto separator = s.find(" : ");
if (separator == std::string_view::npos) {
device.product = strip_trailing_vid_pid(s);
return;
}
device.vendor = std::string(trim(s.substr(0, separator)));
device.product = strip_trailing_vid_pid(s.substr(separator + 3));
}
bool is_header_underline(const std::string_view line) {
if (line.size() < 3) {
return false;
}
for (const char c : line) {
if (c != '=') {
return false;
}
}
return true;
}
/// True for the Linux tool's per-exporter header, " - 192.168.50.35".
///
/// A remote listing from the Linux tool is grouped BY EXPORTER, and each group opens with a
/// line naming the machine whose rows follow. It is not a device and it is not the banner,
/// and without a rule for it the whole reply is unreadable: the same document reads fine
/// from `usbip-win2` and fails from `usbip`, so a Linux importer would attach nothing while
/// the Windows host beside it worked — and "the exporter offers nothing" would report the
/// identical wrong answer, because the header is still printed when there is nothing under
/// it.
///
/// Distinguished from the local list's device rows, which also begin with a dash: those
/// continue with the word "busid". Anything else after the dash is an address rather than a
/// device we failed to read, so the test is deliberately narrow — no whitespace, and only
/// the characters a host name or an address can carry. A line that fails that is still
/// handed back as unreadable, because a rule loose enough to swallow anything would turn a
/// format change into "nothing is offered".
bool is_exporter_header(const std::string_view line) {
if (!starts_with(line, "- ") || starts_with(line, "- busid")) {
return false;
}
const std::string_view rest = trim(line.substr(2));
if (rest.empty() || rest.find_first_of(" \t") != std::string_view::npos) {
return false;
}
return rest.find_first_not_of("0123456789abcdefABCDEF.:-_%[]") == std::string_view::npos;
}
/// Gather up to three lines that the tool itself called an error.
void collect_error_lines(const std::string_view text, std::string &out, std::size_t &count) {
std::size_t cursor = 0;
while (cursor < text.size() && count < 3) {
const auto newline = text.find('\n', cursor);
const std::size_t stop = (newline == std::string_view::npos) ? text.size() : newline;
const std::string_view candidate = trim(text.substr(cursor, stop - cursor));
if (candidate.find("error:") != std::string_view::npos) {
if (!out.empty()) {
out += " | ";
}
out += std::string(candidate);
++count;
}
cursor = (newline == std::string_view::npos) ? text.size() : newline + 1;
}
}
/**
* @brief Refuse to read past a line we cannot place.
*
* When the tool named its own errors, those are the detail — all of them, joined. Picking
* one would mean guessing which sentence a human needs, and in the captured failure the
* line that matters is the second one: "open vhci_driver (is vhci_hcd loaded?)" says what
* to do, while the line before it only says something went wrong. With no such line, the
* detail is simply the row that defeated the parse.
*/
DeviceList unreadable(const std::string_view line, const std::string_view rest) {
DeviceList result;
result.outcome = ListOutcome::Unreadable;
result.detail = std::string(line);
std::string errors;
std::size_t count = 0;
collect_error_lines(line, errors, count);
collect_error_lines(rest, errors, count);
if (!errors.empty()) {
result.detail = errors;
}
return result;
}
} // namespace
bool is_valid_busid(const std::string_view busid) {
if (busid.empty() || busid.size() > kMaxBusidLength) {
return false;
}
// The first character must be a digit. This is what makes a leading '-' impossible, and
// a leading '-' is what would turn a busid into an option usbip acts on.
if (!is_digit(busid.front())) {
return false;
}
std::size_t dashes = 0;
for (const char c : busid) {
if (c == '-') {
++dashes;
continue;
}
if (!is_digit(c)) {
return false;
}
}
if (dashes != 1) {
return false;
}
// The single separator has to sit between the two runs, not at the end. A trailing '-'
// passes the digit test above and would otherwise be accepted.
const auto separator = busid.find('-');
return separator + 1 < busid.size();
}
DeviceList parse_device_list(const std::string_view output) {
if (trim(output).empty()) {
// Deliberately not NothingOffered. We cannot tell those apart without a clock and an
// exit code, and guessing here is how a sleeping exporter becomes "nothing to share".
DeviceList result;
result.outcome = ListOutcome::NoOutput;
return result;
}
DeviceList result;
std::vector<Device> devices;
bool saw_any_line = false;
// True while the device just opened still owes us its description line. The Linux shape
// puts the vendor on the following row; the Windows shape puts it on the busid row.
bool wants_description = false;
bool have_current = false;
std::size_t cursor = 0;
while (cursor <= output.size()) {
const auto newline = output.find('\n', cursor);
const std::size_t stop = (newline == std::string_view::npos) ? output.size() : newline;
const std::string_view line = trim(output.substr(cursor, stop - cursor));
cursor = (newline == std::string_view::npos) ? output.size() + 1 : newline + 1;
if (line.empty()) {
continue;
}
saw_any_line = true;
// The Windows tool's banner, both of its rows. Recognising it is what lets an empty
// result mean "nothing is offered" rather than "this build did not understand".
if (line == "Exportable USB devices" || is_header_underline(line)) {
continue;
}
// The Linux tool's per-exporter header, which the Windows tool does not print at all.
if (is_exporter_header(line)) {
continue;
}
// Linux shape: " - busid 3-10 (8087:0033)"
if (starts_with(line, "- busid")) {
const std::string_view rest = trim(line.substr(7));
const auto space = rest.find_first_of(" \t");
const std::string_view busid = (space == std::string_view::npos) ? rest : rest.substr(0, space);
if (!is_valid_busid(busid)) {
return unreadable(line, output.substr(cursor));
}
devices.push_back(Device{});
devices.back().busid = std::string(busid);
devices.back().vid_pid = extract_vid_pid(rest);
wants_description = true;
have_current = true;
continue;
}
const auto separator = line.find(':');
if (separator != std::string_view::npos) {
const std::string_view head = trim(line.substr(0, separator));
const std::string_view tail = line.substr(separator + 1);
// Windows shape: the busid sits before the separator, on the device's own row.
if (is_valid_busid(head)) {
devices.push_back(Device{});
devices.back().busid = std::string(head);
devices.back().vid_pid = extract_vid_pid(tail);
split_vendor_product(tail, devices.back());
wants_description = false;
have_current = true;
continue;
}
if (wants_description && have_current) {
// Linux shape: the vendor and product arrive on the row AFTER the busid, indented,
// carrying a colon of their own. That is why the whole line is used here rather than
// the part after its separator — splitting on that would throw the vendor away.
Device ¤t = devices.back();
split_vendor_product(head.empty() ? tail : line, current);
if (current.vid_pid.empty()) {
current.vid_pid = extract_vid_pid(line);
}
wants_description = false;
continue;
}
if (head.empty() && have_current) {
// A detail row hanging off the device above it.
devices.back().interfaces.emplace_back(trim(tail));
continue;
}
}
// Bytes came back that we cannot place. That is not an empty list, and it is not a
// device list either — say so rather than reporting a fact we do not have.
return unreadable(line, output.substr(cursor));
}
if (!saw_any_line) {
result.outcome = ListOutcome::NoOutput;
return result;
}
if (!devices.empty()) {
result.outcome = ListOutcome::Devices;
result.devices = std::move(devices);
return result;
}
// Every non-empty line was understood — a line we could not place returned early above, and
// an entirely empty document never reached this point — so the tool answered the question and
// its answer is that it has nothing to offer. That is a fact, not a guess. The banner checks
// above are what make it safe, which is why an unrecognised line must return early rather
// than being skipped.
result.outcome = ListOutcome::NothingOffered;
return result;
}
namespace {
/**
* @brief Read "Port NN:" from a row, in either tool's shape.
*
* Three answers, and the third is the one that matters: -1 means this is not a port row and
* the caller should look elsewhere; -2 means it starts like our format and then is not one,
* which has to STOP the parse. Skipping a malformed row would turn "we cannot read this"
* into "this machine holds nothing", and the cost of that is a device never given back.
*
* The 255 ceiling is usbip's own: detach refuses a port outside [1,255]. Reading a larger
* number is not a device we could release, so it is refused here rather than carried.
*/
int parse_port_number(const std::string_view line) {
constexpr std::string_view kPrefix = "Port ";
if (!starts_with(line, kPrefix)) {
return -1;
}
const auto colon = line.find(':');
if (colon == std::string_view::npos || colon <= kPrefix.size()) {
return -2;
}
const std::string_view digits = trim(line.substr(kPrefix.size(), colon - kPrefix.size()));
if (digits.empty() || digits.size() > 3) {
return -2;
}
int value = 0;
for (const char c : digits) {
if (!is_digit(c)) {
return -2;
}
value = (value * 10) + (c - '0');
}
return value > 255 ? -2 : value;
}
/**
* @brief Fill in the remote identity from a "-> usbip://HOST:SERVICE/BUSID" row.
*
* The busid is taken from the URL rather than from the row's leading column, because the two
* tools disagree about what leads the row: usbip-win2 leaves it blank, the Linux tool prints
* the busid there. The last path segment is the one place both agree.
*
* @return false when the row is not a URL we can read, or the busid fails validation — the
* busid arrives from another machine, so it is checked here exactly as any other
* value off the wire is.
*/
bool parse_usbip_url(const std::string_view line, Attached &device) {
constexpr std::string_view kScheme = "usbip://";
const auto scheme = line.find(kScheme);
if (scheme == std::string_view::npos) {
return false;
}
const std::string_view rest = trim(line.substr(scheme + kScheme.size()));
const auto slash = rest.find('/');
if (slash == std::string_view::npos) {
return false;
}
const std::string_view authority = rest.substr(0, slash);
const std::string_view busid = trim(rest.substr(slash + 1));
const auto colon = authority.rfind(':');
if (colon == std::string_view::npos) {
return false;
}
// Validate before storing: nothing leaves here for an argument vector unchecked.
if (!is_valid_busid(busid)) {
return false;
}
device.exporter = std::string(trim(authority.substr(0, colon)));
device.service = std::string(trim(authority.substr(colon + 1)));
device.busid = std::string(busid);
return true;
}
/// The port-shaped twin of unreadable(), with the same rule about which line is the detail.
AttachedList unreadable_attached(const std::string_view line, const std::string_view rest) {
AttachedList result;
result.outcome = PortOutcome::Unreadable;
result.detail = std::string(line);
std::string errors;
std::size_t count = 0;
collect_error_lines(line, errors, count);
collect_error_lines(rest, errors, count);
if (!errors.empty()) {
result.detail = errors;
}
return result;
}
} // namespace
AttachedList parse_attached(const std::string_view output) {
if (trim(output).empty()) {
// Deliberately not NothingAttached. usbip-win2 prints NOTHING when it holds nothing and
// exits 0, so zero bytes here is genuinely ambiguous and the caller's exit code is the
// only thing that separates "holds nothing" from "could not reach the driver". Guessing
// it here would be the same mistake parse_device_list refuses to make.
AttachedList result;
result.outcome = PortOutcome::NoOutput;
return result;
}
AttachedList result;
std::vector<Attached> devices;
bool saw_any_line = false;
bool have_device = false;
// The tool's own diagnostics: seen, recorded, and skipped. Kept so that "it complained and
// named no ports" can be told apart from "it holds nothing" — see the end of this function.
std::string diagnostics;
std::size_t diagnostic_count = 0;
// The Linux tool's diagnostic prefix. It is the tool talking about itself, not about a port.
constexpr std::string_view kToolDiagnostic = "libusbip: error:";
// True once a "Port NN:" row has opened a device that still owes us its description row.
// While this is set the next line cannot be a header, which is what stops a device whose
// name merely starts with "Port " from being read as the start of a new one.
bool wants_description = false;
std::size_t cursor = 0;
while (cursor <= output.size()) {
const auto newline = output.find('\n', cursor);
const std::size_t stop = (newline == std::string_view::npos) ? output.size() : newline;
const std::string_view line = trim(output.substr(cursor, stop - cursor));
cursor = (newline == std::string_view::npos) ? output.size() + 1 : newline + 1;
if (line.empty()) {
continue;
}
saw_any_line = true;
// Both tools' banner, both rows. The Linux tool prints it even when it holds nothing.
if (line == "Imported USB devices" || is_header_underline(line)) {
continue;
}
// The Linux tool's own diagnostics, printed alongside a perfectly valid port table.
//
// It emits these when a port is occupied and it cannot read that port's record file — and
// on a modern kernel it cannot, every time, because the record directory it wants
// (/var/run/vhci_hcd) no longer exists. That is the same reason the occupied port's row
// reads "unknown host, remote port and remote busid".
//
// They must not stop the parse. A merged capture is a normal way to run this — a shell
// pipeline, or a popen that did not separate the streams — and refusing a table that is
// right there, over a line of the tool talking about itself, costs a device that is never
// given back. Skipping is only safe because of the check at the end of this function:
// complaining and naming no ports is not the same as holding nothing.
if (starts_with(line, kToolDiagnostic)) {
if (diagnostic_count < 3) {
if (!diagnostics.empty()) {
diagnostics += " | ";
}
diagnostics += std::string(line);
++diagnostic_count;
}
continue;
}
if (!wants_description) {
const int port = parse_port_number(line);
if (port >= 0) {
Attached device;
device.port = port;
// "at <speed>" appears in both shapes. Kept verbatim; never branched on.
constexpr std::string_view kAt = " at ";
const auto at = line.rfind(kAt);
if (at != std::string_view::npos) {
device.speed = std::string(trim(line.substr(at + kAt.size())));
}
devices.push_back(std::move(device));
have_device = true;
wants_description = true;
continue;
}
if (port == -2) {
return unreadable_attached(line, output.substr(cursor));
}
}
if (!have_device) {
// Detail, or something we do not know, with no device to hang it off.
return unreadable_attached(line, output.substr(cursor));
}
if (wants_description) {
// The row after the header names the device, in both shapes. A "->" row here means the
// header had no description: a shape we do not know, so refuse rather than absorb it
// into the product name.
if (line.find("->") != std::string_view::npos) {
return unreadable_attached(line, output.substr(cursor));
}
devices.back().product = std::string(line);
wants_description = false;
continue;
}
// From here on every row belongs to the device above and must be one of its detail rows.
if (line.find("->") == std::string_view::npos) {
return unreadable_attached(line, output.substr(cursor));
}
// The Linux tool prints the remote busid in the row's LEADING column when it has no URL to
// put it in — and on a modern kernel it never has one, because the record the URL would
// come from is gone. That leaves the leading column as the only place the identity appears:
//
// 9-1 -> unknown host, remote port and remote busid
//
// Without it the held device has no identity, and a hold that cannot be shown to be the
// device we wanted is one the planner hands back — so the importer would attach a device
// and then detach it again on the next reconcile, forever. The column is only read when
// what is in it passes the same validation any busid off the wire passes, and it never
// overwrites a busid the URL already gave us.
if (devices.back().busid.empty()) {
const std::string_view lead = trim(line.substr(0, line.find("->")));
if (is_valid_busid(lead)) {
devices.back().busid = std::string(lead);
}
}
if (line.find("usbip://") != std::string_view::npos) {
if (!parse_usbip_url(line, devices.back())) {
return unreadable_attached(line, output.substr(cursor));
}
continue;
}
constexpr std::string_view kSerial = "-> serial:";
constexpr std::string_view kMode = "-> mode:";
constexpr std::string_view kBusDev = "-> remote bus/dev";
if (line.find("unknown host") != std::string_view::npos || starts_with(line, kBusDev)) {
// The Linux tool could not read this port's record, or is telling us the remote bus and
// device numbers. Neither carries identity we need: the port above is what releases it.
continue;
}
if (starts_with(line, kSerial)) {
// The tool prints "-> serial: " with a trailing space and nothing after it when the
// attach carried no --serial, which is the normal case. The row is already trimmed on
// the way in, so this trim is belt-and-braces rather than the thing that makes the
// value empty — mutation testing showed that removing it changes nothing observable.
// Kept because it costs nothing and the day the outer trim moves is the day it matters.
devices.back().serial = std::string(trim(line.substr(kSerial.size())));
continue;
}
if (starts_with(line, kMode)) {
devices.back().mode = std::string(trim(line.substr(kMode.size())));
continue;
}
return unreadable_attached(line, output.substr(cursor));
}
if (!saw_any_line) {
result.outcome = PortOutcome::NoOutput;
return result;
}
if (!devices.empty()) {
result.outcome = PortOutcome::Devices;
result.devices = std::move(devices);
return result;
}
// The tool complained and named no ports at all. That is NOT "this machine holds nothing":
// these lines are the tool saying it could not read a record, and answering "your hands are
// empty" from them would leave a device attached here forever, with the machine it came from
// missing the hardware and no row anywhere saying so. Refuse, and hand back what it said.
if (diagnostic_count > 0) {
result.outcome = PortOutcome::Unreadable;
result.detail = diagnostics;
return result;
}
// The banner was recognised and no port row followed. On Linux that is exactly what
// "holding nothing" looks like, and it is reachable only because every line we could not
// place returned early above.
result.outcome = PortOutcome::NothingAttached;
return result;
}
std::string describe(const ListOutcome outcome) {
switch (outcome) {
case ListOutcome::Devices:
return "the exporter is offering devices";
case ListOutcome::NothingOffered:
return "the exporter is offering nothing";
case ListOutcome::NoOutput:
return "no output came back - the exporter may be asleep or unreachable";
case ListOutcome::Unreadable:
return "output came back that this build could not read";
}
return "unknown";
}
std::string describe(const PortOutcome outcome) {
switch (outcome) {
case PortOutcome::Devices:
return "this machine is holding devices";
case PortOutcome::NothingAttached:
return "this machine is holding nothing";
case PortOutcome::NoOutput:
return "no output came back - the tool may not have reached the driver";
case PortOutcome::Unreadable:
return "output came back that this build could not read";
}
return "unknown";
}
std::string describe(const Action action) {
switch (action) {
case Action::Attach:
return "attach";
case Action::Detach:
return "detach";
}
return "unknown";
}
std::size_t Plan::attach_count() const {
std::size_t count = 0;
for (const PlannedAction &action : actions) {
if (action.action == Action::Attach) {
++count;
}
}
return count;
}
std::size_t Plan::detach_count() const {
std::size_t count = 0;
for (const PlannedAction &action : actions) {
if (action.action == Action::Detach) {
++count;
}
}
return count;
}
namespace {
bool contains(const std::vector<std::string> &values, const std::string_view needle) {
for (const std::string &value : values) {
if (value == needle) {
return true;
}
}
return false;
}
bool offers(const std::vector<Device> &offered, const std::string_view busid) {
for (const Device &device : offered) {
if (device.busid == busid) {
return true;
}
}
return false;
}
/// Whether this machine already holds that device. An entry with no readable remote identity
/// never matches: it cannot be shown to be the device in question, and treating it as a match
/// would leave a device held forever under a name nobody can check.
bool holds(const std::vector<Attached> &attached, const std::string_view busid) {
for (const Attached &held : attached) {
if (!held.busid.empty() && held.busid == busid) {
return true;
}
}
return false;
}
/// The exporter's address becomes an argument to another program. It arrives from config
/// rather than from the wire, so this is not the same threat as a busid - but a leading '-'
/// would be read as an option and whitespace cannot survive an argument vector at all, so
/// both are refused here rather than passed on and hoped about.
} // namespace
bool is_usable_exporter(const std::string_view exporter) {
if (exporter.empty() || exporter.front() == '-') {
return false;
}
for (const char c : exporter) {
const bool allowed = is_digit(c) || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|| c == '.' || c == ':' || c == '-' || c == '_';
if (!allowed) {
return false;
}
}
return true;
}
Plan plan_reconcile(const std::vector<Device> &offered,
const std::vector<Attached> &attached,
const std::vector<std::string> &wanted) {
Plan plan;
// An empty want list means the user's answer is "whatever the exporter is offering", because
// ArtMoon binds exactly what was toggled on and nothing else.
const bool everything_wanted = wanted.empty();
// Detaches first. A swap - the user now wants B instead of A - has to give up A's port
// before B can be given one.
for (const Attached &held : attached) {
const bool still_wanted = everything_wanted || contains(wanted, held.busid);
const bool still_offered = !held.busid.empty() && offers(offered, held.busid);
if (still_wanted && still_offered) {
continue; // already right: held, offered and wanted
}
PlannedAction action;
action.action = Action::Detach;
action.busid = held.busid;
action.port = held.port;
if (held.busid.empty()) {
action.reason = "port " + std::to_string(held.port)
+ " is held with no readable remote identity, so it is given back";
} else if (!still_wanted) {
action.reason = held.busid + " is held but no longer wanted";
} else {
action.reason = held.busid + " is held but no longer offered by the exporter";
}
plan.actions.push_back(std::move(action));
}
// Then attaches, in the order the exporter listed them, so the plan is stable across runs.
for (const Device &device : offered) {
if (!everything_wanted && !contains(wanted, device.busid)) {
continue; // offered but not wanted: leave it exactly where it is
}
if (holds(attached, device.busid)) {
continue; // already held
}
PlannedAction action;
action.action = Action::Attach;
action.busid = device.busid;
action.reason = device.busid + " is offered and wanted";
plan.actions.push_back(std::move(action));
}
return plan;
}
std::vector<std::string> build_list_remote_argv(const std::string_view exporter) {
if (!is_usable_exporter(exporter)) {
throw std::invalid_argument("refusing to list an unusable exporter address: '" +
std::string(exporter) + "'");
}
return {"usbip", "list", "-r", std::string(exporter)};
}
std::vector<std::string> build_list_attached_argv() {
return {"usbip", "port"};
}
std::vector<std::string> build_attach_argv(const std::string_view exporter,
const std::string_view busid) {
if (!is_valid_busid(busid)) {
throw std::invalid_argument("refusing to attach a busid that is not a busid: '" +
std::string(busid) + "'");
}
if (!is_usable_exporter(exporter)) {
throw std::invalid_argument("refusing to attach from an unusable exporter address: '" +
std::string(exporter) + "'");
}
// Element 0 is a placeholder. The platform layer replaces it with the client it resolved,
// because on Windows the client is deliberately not on PATH.
return {"usbip", "attach", "-r", std::string(exporter), "-b", std::string(busid)};
}
std::vector<std::string> build_detach_argv(const int port) {
if (port < 0) {
throw std::invalid_argument("refusing to detach a negative port");
}
// Port 0 is valid: the Linux tool numbers from 0, and reading 0 as "unset" would make the
// first port on every Linux machine impossible to release.
return {"usbip", "detach", "-p", std::to_string(port)};
}
std::string describe(const ClientState state) {
switch (state) {
case ClientState::Ready:
return "the USB/IP client is ready";
case ClientState::NotInstalled:
return "the USB/IP client is not installed";
case ClientState::DriverMissing:
return "the USB/IP driver is missing";
case ClientState::DriverNotLoaded:
return "the USB/IP driver is present but not loaded";
case ClientState::HelperMissing:
return "the privileged helper is not set up";
}
return "the USB/IP client state could not be read";
}
bool names_the_client(const std::string_view display_name) {
constexpr std::string_view needle = "usbip";
if (display_name.size() < needle.size()) {
return false;
}
for (std::size_t start = 0; start + needle.size() <= display_name.size(); ++start) {
bool matched = true;
for (std::size_t i = 0; i < needle.size(); ++i) {
const auto ch = static_cast<unsigned char>(display_name[start + i]);
if (std::tolower(ch) != static_cast<unsigned char>(needle[i])) {
matched = false;
break;
}
}
if (!matched) {
continue;
}
const auto after = start + needle.size();
if (after == display_name.size()) {
return true;
}
// The boundary is the whole rule. "USBip 0.9.8.1" ends the word here; "usbipd-win" carries
// straight on into another product's name.
if (std::isalnum(static_cast<unsigned char>(display_name[after])) == 0) {
return true;
}
}
return false;
}
ClientAvailability assess_client(const ClientProbe &probe) {
ClientAvailability result;
// Order matters. Each check assumes the ones above it passed, so the reason names the FIRST
// thing missing rather than the last one we happened to look at — which is what makes it
// something to act on instead of a list to work through.
if (!probe.client_found) {
// Not an error. The client ships with ArtLight's installer, so a machine without it is a
// machine that has not run setup — the same shape as ArtMoon's install-the-service step.
result.state = ClientState::NotInstalled;
result.reason = "the USB/IP client is not set up on this PC";
return result;
}
if (!probe.driver_present && !probe.driver_loadable) {
// Measured on the z13: usbip-utils 2.0 installed and not one device importable, because
// vhci_hcd was not loaded. The binary on PATH proves nothing, which is why the driver is
// checked separately rather than inferred from the client being present.
result.state = ClientState::DriverMissing;
result.reason = "the USB/IP client is installed but its driver is not available on this PC";
return result;
}
if (!probe.driver_present) {
// Present-but-unloaded is deliberately its own state. It is a load, not a reinstall, and
// reporting it as "missing" would send someone to reinstall a driver they already have.
result.state = ClientState::DriverNotLoaded;
result.reason = "the USB/IP driver is present but not loaded, so nothing can arrive yet";
return result;
}
// The Linux half. On Windows privilege_required is false and this is skipped, because attach
// there was proven to work from a limited token — the asymmetry is between the platforms and
// must not be smoothed over by giving Windows a daemon it does not need.
if (probe.privilege_required && !probe.helper_present) {
result.state = ClientState::HelperMissing;
result.reason = "the USB/IP client is ready, but the helper that can attach a device is not set up";
return result;
}
result.ok = true;
result.state = ClientState::Ready;
result.reason.clear();
return result;
}
namespace {
/// Case-insensitive substring. Used ONLY to recognise failure lines that have actually been
/// seen on real machines - never to interpret something a machine did not say.
bool contains_ci(const std::string_view haystack, const std::string_view needle) {
if (needle.empty() || haystack.size() < needle.size()) {
return false;
}
for (std::size_t start = 0; start + needle.size() <= haystack.size(); ++start) {
bool matched = true;
for (std::size_t i = 0; i < needle.size(); ++i) {
if (to_lower(haystack[start + i]) != to_lower(needle[i])) {
matched = false;
break;
}
}
if (matched) {
return true;
}
}
return false;
}
/// The first line carrying `needle`, trimmed, kept as the tool wrote it. This is what goes in
/// the log: a paraphrase of a tool's complaint is a new claim, and the raw line is the only
/// thing that can be checked against the machine later.
std::string line_containing(const std::string_view text, const std::string_view needle) {
std::size_t start = 0;
while (start <= text.size()) {
const auto end = text.find('\n', start);
const auto line = text.substr(start, end == std::string_view::npos ? std::string_view::npos : end - start);
if (contains_ci(line, needle)) {
return std::string(trim(line));
}
if (end == std::string_view::npos) {
break;
}
start = end + 1;
}
return {};
}
std::string first_meaningful_line(const std::string_view text) {
std::size_t start = 0;
while (start <= text.size()) {
const auto end = text.find('\n', start);
const auto line = text.substr(start, end == std::string_view::npos ? std::string_view::npos : end - start);
const auto trimmed = trim(line);
if (!trimmed.empty()) {
return std::string(trimmed);
}
if (end == std::string_view::npos) {
break;
}
start = end + 1;
}
return {};
}
/// The port an attach named, read out of the tool's own line.
///
/// Measured on the z13 on 2026-10-06: the installed `/usr/bin/usbip` carries the format
/// string `using port %d (` and a successful attach prints
///
/// usbip: info: using port 8 (vhci_hcd.0)
///
/// The port table lags that by MINUTES. In the session that found this, the attach printed
/// its port at 17:40:38 while `usbip port` reported an EMPTY table at 17:40:40 (six looks)
/// and again at teardown at 17:41:05 - and the device only appeared in the table when it was
/// read at 17:56, fifteen minutes after that session had ended. So a session that waits for
/// the table before it can name a port ends up holding a device it cannot detach, which is
/// the exact state this feature exists to prevent.
///
/// Read from stderr first - every `usbip: info:` line goes there - then stdout, so the two
/// platforms are covered whichever stream their tool chooses. ONLY called for a successful
/// attach: a port named by a failed one would belong to some other attach, and detaching it
/// would give back a device this session never took.
///
/// Ports are numbered FROM 0 on Linux, unlike usbip-win2 which starts at 1, so 0 is a real
/// port and must never read as "not found". Hence -1 as the absent value.
int port_from_attach_output(const std::string_view err, const std::string_view out) {
for (const auto &stream : {err, out}) {
const auto line = line_containing(stream, "using port");
if (line.empty()) {
continue;
}
static constexpr std::string_view kMarker {"using port"};
const auto marker = line.find(kMarker);
if (marker == std::string::npos) {
continue;
}
std::size_t at = marker + kMarker.size();
while (at < line.size() && (line[at] == ' ' || line[at] == '\t')) {
++at;
}
int port = 0;
std::size_t digits = 0;
while (at < line.size() && line[at] >= '0' && line[at] <= '9') {
port = (port * 10) + (line[at] - '0');
++at;
++digits;
}
// No digits at all means this was not the line we are looking for, whatever it said.
if (digits == 0 || port < 0 || port > 255) {
continue;
}
return port;
}
return -1;
}
} // namespace
AttachResult classify_attach(const int exit_code, const std::string_view out, const std::string_view err) {
AttachResult result;
if (exit_code == 0) {
result.outcome = AttachOutcome::Attached;
// Ask the attach what port it used, before anything else. This is the one thing that makes
// the device releasable without depending on the port table's timing at all.
result.port = port_from_attach_output(err, out);
return result;
}
// The exporter still counts the device as out. Documented on the exporter, and note the trap
// recorded there: an EMPTY `Persisted:` table does not clear this - it is the vendor's veto,
// not stale state, and retrying forever will not shake it loose.
for (const std::string_view needle : {"already exported", "device busy"}) {
const auto line = line_containing(err, needle);
if (!line.empty()) {
result.outcome = AttachOutcome::DeviceBusy;
result.detail = line;
return result;
}
}
// The exporter does not have it to give. Measured on the real exporter, asking for a device it
// was not sharing. Nothing moved, and the fix is on the exporter side - which is why this is
// not filed with the generic failures.
for (const std::string_view needle : {"not found by bus id", "device not found"}) {
const auto line = line_containing(err, needle);
if (!line.empty()) {
result.outcome = AttachOutcome::NotOffered;
result.detail = line;
return result;
}
}
// Privilege or policy said no. The Linux form was measured on the z13: an unprivileged attach
// dies with this and exit 1, because /sys/devices/platform/vhci_hcd.0/attach is a root-only
// write. On Linux this is also the shape a missing helper, or a helper that never answered,
// takes.
//
// The polkit needles are kept on purpose even though this build no longer produces them. The
// helper is reached over a socket now, not through pkexec, but a machine still carrying the old
// polkit action will produce them - and the sentence that cost a night must never be anonymous
// again: "Error executing command as another user: Not authorized", exit 127, matching none of
// the original four needles and therefore filed as a bare "the attach failed" with the reason
// captured and then discarded. "authoriz" spans authorized/authorization/authorize in one stem.
//
// The last two needles are the socket path's own refusals, and each one is a different call to
// make: the helper is not installed, the service behind the socket is not up, or the reply
// could not be read. A bucket name is not an answer, so each has to survive as a sentence.
for (const std::string_view needle :
{"import device", "permission denied", "operation not permitted", "access denied",
"not authorized", "authoriz", "authentication is required", "no authentication agent",
"usb/ip helper", "unix socket"}) {
const auto line = line_containing(err, needle);
if (!line.empty()) {
result.outcome = AttachOutcome::Refused;
result.detail = line;
return result;
}
}
// It ran, it did not work, and this build will not pretend to know why. Keep what it said.
result.outcome = AttachOutcome::Failed;
result.detail = first_meaningful_line(err);
if (result.detail.empty()) {
result.detail = first_meaningful_line(out);
}
return result;
}
std::string describe(const AttachOutcome outcome) {
switch (outcome) {
case AttachOutcome::Attached:
return "the device attached";
case AttachOutcome::DeviceBusy:
return "the exporter still has this device out - nothing moved";
case AttachOutcome::NotOffered:
return "the exporter is not offering that device - nothing moved";
case AttachOutcome::Refused:
return "the attach was refused - nothing moved";
case AttachOutcome::Failed:
return "the attach failed";
}
throw std::runtime_error("unhandled AttachOutcome");
}
} // namespace input::usbip