/** * @file src/usbip_input_policy.cpp * @brief Definitions for the portable USB/IP importer policies. */ #include "usbip_input_policy.h" #include #include #include 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(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 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 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 " 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 &values, const std::string_view needle) { for (const std::string &value : values) { if (value == needle) { return true; } } return false; } bool offers(const std::vector &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, 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 &offered, const std::vector &attached, const std::vector &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 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 build_list_attached_argv() { return {"usbip", "port"}; } std::vector 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 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(display_name[start + i]); if (std::tolower(ch) != static_cast(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(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