Ctrl K
ComunidadGuías…

server/src/usbip_input_policy.h

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * @file src/usbip_input_policy.h
 * @brief Portable policy for the USB/IP importer: busid validation, exporter output parsing,
 *        and the attach/detach plan.
 *
 * Nothing in this header touches the operating system, spawns a process, or knows which
 * platform it is running on. That is deliberate. The parse is the part that fails quietly —
 * a device that never arrives, behind a log line that says it did — so it is the part that
 * has to be testable without a driver, a network, or a second machine.
 *
 * Terminology is not cosmetic here; see ArtMoon/docs/usb-ip-input-passthrough.md. The machine
 * a device is plugged into is the EXPORTER, the machine it arrives on is the IMPORTER. Never
 * write "host" or "client" bare: usbip names its roles after the device side and streaming
 * names them after the video, and on this pair of products the two are inverted.
 */
#pragma once

#include <string>
#include <string_view>
#include <vector>

namespace input::usbip {
  /**
   * @brief Whether a value is a busid of the shape usbip emits, e.g. "3-10" or "9-3".
   *
   * Two properties matter beyond tidiness. The first character must be a digit, so a value
   * can never be read as an option by usbip ("-p", "--help"). And nothing outside [0-9-] may
   * appear, so a busid arriving from the exporter can never carry whitespace, a path, a
   * newline, or a shell metacharacter into an argument vector.
   *
   * The digits are deliberately not range-checked and leading zeros are not rejected:
   * refusing a device the exporter really is offering is a worse failure than handing a
   * malformed busid to usbip, which will simply report no such device.
   *
   * @param busid Candidate, as received from the exporter.
   * @return true for exactly one run of digits, a single '-', and another run of digits.
   */
  bool is_valid_busid(std::string_view busid);

  /**
   * @brief Whether an exporter address is one this feature will act on.
   *
   * Exported, and not duplicated, because the privileged helper is the side that runs as root. A
   * second copy of this rule living in the helper would be a rule that drifts, and the drift would
   * be on the privileged side - the one place it must not happen. The helper links this instead.
   *
   * Deliberately narrow: letters, digits, dot, colon, hyphen, underscore, and it may not begin with
   * a hyphen (which would be read as an option). That is enough for a hostname, an IPv4 literal and
   * an IPv6-literal-with-port, and it cannot carry a shell metacharacter, a space, or a newline.
   */
  bool is_usable_exporter(std::string_view exporter);

  /// One device an exporter is offering.
  struct Device {
    std::string busid;    ///< "9-1". Guaranteed to have passed is_valid_busid.
    std::string vendor;   ///< "Razer USA, Ltd". Empty when the row carried no vendor.
    std::string product;  ///< "unknown product" is a normal value here, not a parse failure.
    std::string vid_pid;  ///< "1532:007b", lowercased. Empty when the row carried none.
    std::vector<std::string> interfaces;  ///< Per-interface detail rows, verbatim, colon stripped.
  };

  /**
   * @brief What a list call actually established.
   *
   * Four states rather than "devices or not", because two of them get collapsed by accident
   * and the collapse is silent. "The exporter is offering nothing" is a fact about the
   * exporter. "We could not read the reply" is a fact about us. A user told the first when the
   * second is true will go and check the wrong machine.
   */
  enum class ListOutcome {
    Devices,        ///< Read successfully; one or more devices are offered.
    NothingOffered, ///< Read successfully; the exporter is genuinely offering nothing.
    NoOutput,       ///< Nothing came back at all. The caller's own timeout decides what that means.
    Unreadable,     ///< Bytes came back and this build could not place them.
  };

  struct DeviceList {
    ListOutcome outcome = ListOutcome::NoOutput;
    std::vector<Device> devices;
    /// For Unreadable: the first line that could not be placed, or the tool's own error line.
    /// Empty for every other outcome.
    std::string detail;
  };

  /**
   * @brief Read one device list, in either tool's shape.
   *
   * Two shapes have to parse, and they are genuinely different:
   *
   *   Linux   " - busid 3-10 (8087:0033)" then an indented "Intel Corp. : AX211 Bluetooth"
   *   Windows "    9-1    : Razer USA, Ltd : unknown product (1532:028d)" then indented ":" rows
   *
   * The busid decides which: on the Windows row it sits before the separator, on the Linux one
   * after the word "busid". Continuation rows carrying only interface detail are attached to
   * the device above them and are never mistaken for new devices.
   *
   * An empty document is NoOutput rather than NothingOffered, on purpose. This function cannot
   * see the exit code or the clock, and from output alone "the exporter is asleep" and "the
   * exporter is offering nothing" are indistinguishable — the asleep case produces the same
   * zero bytes as the clean-empty case, only after hanging indefinitely. The caller combines
   * this with its own timeout and exit status to choose. See
   * tests/fixtures/captured-2026-10-03-network-probe.md.
   *
   * @param output Everything the tool wrote to stdout, with stderr appended by the caller.
   * @return The outcome, the devices if any, and for Unreadable the line that defeated us.
   */
  DeviceList parse_device_list(std::string_view output);

  /// @brief Render an outcome as words, for a log line or a status row. Never empty.
  std::string describe(ListOutcome outcome);

  /// One device this machine currently holds.
  struct Attached {
    /// Remote busid, e.g. "9-1". Validated, so it is safe in an argument vector.
    ///
    /// Legitimately EMPTY on Linux: when the tool cannot read a port's record it prints the
    /// port and no remote identity at all. That is not a parse failure, and it does not stop
    /// us, because detach takes a port.
    std::string busid;
    /// Hub port number. usbip-win2 numbers from 1 and asserts non-zero; the Linux tool numbers
    /// from 0. So 0 is a valid value here and must not be treated as "unset".
    int port = 0;
    std::string exporter;  ///< Host from the "usbip://HOST:SERVICE/BUSID" row.
    std::string service;   ///< That row's TCP port, e.g. "3240".
    std::string speed;     ///< "High Speed(480Mbps)", verbatim. Empty when the row had none.
    std::string product;   ///< The description row, verbatim.
    std::string serial;    ///< Only present when the attach carried one. Usually empty.
    std::string mode;      ///< "zero-copy" or "low-latency". Empty when the row had none.
  };

  /**
   * @brief What a `port` call actually established.
   *
   * The same four states as ListOutcome, for the same reason, plus one asymmetry that is easy
   * to get wrong in the dangerous direction:
   *
   *   usbip-win2 prints its banner ONLY when it finds a device. So with nothing attached it
   *   prints ZERO BYTES and exits 0 — byte-identical to a call that never reached the driver.
   *   The Linux tool prints the banner unconditionally, so its empty case is distinguishable.
   *
   * That means on Windows this parse alone cannot tell "holds nothing" from "could not ask";
   * the exit code separates them. It is read as NoOutput here and reclassified by the caller,
   * exactly as parse_device_list is, because a machine wrongly reported as holding nothing is
   * a device that never gets given back.
   */
  enum class PortOutcome {
    Devices,          ///< Read successfully; this machine holds devices.
    NothingAttached,  ///< Read successfully; this machine holds none.
    NoOutput,         ///< Zero bytes. Exit 0 means NothingAttached; non-zero means we could not ask.
    Unreadable,       ///< Bytes came back and this build could not place them.
  };

  struct AttachedList {
    PortOutcome outcome = PortOutcome::NoOutput;
    std::vector<Attached> devices;
    /// For Unreadable: the tool's own error lines if it named any, else the row that defeated us.
    std::string detail;
  };

  /**
   * @brief Read what this machine currently holds, in either tool's shape.
   *
   * Two shapes, and they disagree in two places that matter:
   *
   *   Windows  "Port 01: device in use at High Speed(480Mbps)"     9-space description indent
   *            "           -> usbip://192.168.50.35:3240/9-1"       11-space detail indent
   *
   *   Linux    "Port 00: <Port in Use> at High Speed(480Mbps)"     7-space description indent
   *            "       9-1 -> usbip://192.168.50.35:3240/9-1"       the busid leads the row
   *
   * Both put the port number on the "Port NN:" row and the remote busid as the last path
   * segment of the "usbip://" URL, so this reads those two anchors rather than counting
   * columns — the layouts differ and the columns are not the contract.
   *
   * A device whose remote record could not be read prints no URL row at all (a real Linux
   * state, "unknown host, remote port and remote busid"). It is kept, with its port and an
   * empty busid, because the port is what releases it.
   *
   * @param output Everything the tool wrote to stdout, with stderr appended by the caller.
   * @return The outcome, the devices if any, and for Unreadable what defeated us.
   */
  AttachedList parse_attached(std::string_view output);

  /// @brief Render a port outcome as words. Never empty.
  std::string describe(PortOutcome outcome);

  /// What the importer should do about one device.
  enum class Action {
    Attach,  ///< Fetch it: offered, wanted, and this machine does not hold it.
    Detach,  ///< Give it back: this machine holds it and it is no longer wanted or offered.
  };

  /// @brief Render an action as words, for a log line. Never empty.
  std::string describe(Action action);

  /**
   * @brief One thing to do, and why.
   *
   * The reason is carried because this plan is what a log line is written from, and "detached
   * 9-1" without the reason is the kind of entry that makes a later reader guess.
   */
  struct PlannedAction {
    Action action = Action::Attach;
    /// For Attach: the device to fetch, validated. For Detach: the device being released, and
    /// legitimately EMPTY when the tool could not read the port's remote identity.
    std::string busid;
    /// For Detach: the hub port to release, which is the ONLY thing usbip detach accepts.
    /// -1 for Attach, which is addressed by busid.
    int port = -1;
    /// Why this action is in the plan, in words. Never empty.
    std::string reason;
  };

  struct Plan {
    /// Detaches first, then attaches, each in the order its input arrived.
    std::vector<PlannedAction> actions;

    bool empty() const { return actions.empty(); }
    std::size_t attach_count() const;
    std::size_t detach_count() const;
  };

  /**
   * @brief Decide what to do, given what is offered, what is held, and what is wanted.
   *
   * Four states, and each is a real one on a real pair of machines:
   *
   *   offered, wanted, not held        -> attach
   *   held, offered, wanted            -> leave alone (already right)
   *   held, not wanted                 -> detach (the user unticked it)
   *   held, no longer offered          -> detach (the exporter unbound it, or this is a stale
   *                                       hold from a session that did not end cleanly)
   *   offered, not wanted              -> leave alone. ArtMoon binds only what is ticked, so in
   *                                       practice everything offered is wanted; this branch
   *                                       exists for the case where they disagree.
   *
   * **`wanted` empty means every offered device is wanted.** That is not a convenience: ArtMoon
   * binds exactly what the user toggled on, so "what the exporter is offering" IS the user's
   * answer. The parameter exists so a caller that knows better can say so, not so every caller
   * has to repeat the list back.
   *
   * **A held device whose busid is empty is detached.** Linux prints no remote identity for a
   * port whose record it cannot read, so such an entry cannot be matched against `wanted` at
   * all. Giving it back is the safe direction: the device returns to the machine it is plugged
   * into, and it can always be attached again. The alternative - leaving it held because we
   * cannot name it - is a device that never goes home.
   *
   * **Detach is by PORT, never by busid.** `usbip attach` assigns a vhci port and
   * `usbip detach -p <port>` is the only way back, so every Detach action carries the port it
   * was read with. Attach is by busid, because the port does not exist until it succeeds.
   *
   * Deterministic and idempotent: the same three inputs always produce the same plan, in the
   * same order, so running it twice cannot double-attach anything. Detaches are ordered before
   * attaches so that a swap (the user wants B instead of A) frees A's port before B needs one.
   *
   * @param offered What the exporter says it is offering, in the order it was read.
   * @param attached What this machine currently holds, in the order it was read.
   * @param wanted The busids the user wants. Empty means "everything offered".
   * @return The actions to take, and the reason for each.
   */
  Plan plan_reconcile(const std::vector<Device> &offered,
                      const std::vector<Attached> &attached,
                      const std::vector<std::string> &wanted);

  /**
   * @brief Build the argument vector to fetch one device, or throw refusing to.
   *
   * The shape is identical on both tools - `attach -r <exporter> -b <busid>` - so this is
   * portable, and only element 0 differs: it is written here as the bare name `usbip`, and the
   * platform layer REPLACES it with the path it resolved, because on Windows the client is
   * deliberately not on PATH.
   *
   * Always a vector, never a shell string, so nothing can be re-split on the way to exec.
   *
   * @throws std::invalid_argument if the busid is not a valid busid, or the exporter is empty,
   *         carries whitespace, or begins with '-' and would be read as an option.
   */
  /**
   * @brief Build the argv that asks an exporter what it is offering.
   *
   * Read-only, so unlike attach it needs no privilege on either platform. The address is validated
   * here for the same reason it is validated in build_attach_argv: this function's output is an
   * argument VECTOR, and an unvalidated address in one is a way to run something else.
   *
   * 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.
   */
  std::vector<std::string> build_list_remote_argv(std::string_view exporter);

  /**
   * @brief Build the argv that asks this machine what it holds. Element 0 is a placeholder, as above.
   */
  std::vector<std::string> build_list_attached_argv();

  std::vector<std::string> build_attach_argv(std::string_view exporter, std::string_view busid);

  /**
   * @brief Build the argument vector to give one device back, or throw refusing to.
   *
   * @throws std::invalid_argument if the port is negative.
   *
   * The accepted range is the PORTABLE one (any non-negative integer) and the two tools disagree
   * inside it. Measured on real machines: the Linux tool numbers ports FROM 0, while usbip-win2
   * 0.9.8.1 numbers from 1 and rejects 0 outright -
   *
   *     --port: Value 0 not in range [1 - 255]
   *
   * So 0 must NOT be refused here, because refusing it would make the first port on every Linux
   * machine impossible to release; and it must not be assumed meaningful on Windows either. The
   * port always comes from parsing that platform's own `port` output, so each tool only ever sees
   * numbers it issued. Narrowing this to >= 1 would break Linux; widening the Windows side to
   * accept 0 would be a lie about a tool that says otherwise.
   */
  std::vector<std::string> build_detach_argv(int port);

  /**
   * @brief What this machine looks like, as facts. Gathered by the platform layer, judged here.
   *
   * Splitting the gathering from the judging is what makes this testable: the decision that
   * actually matters — "is this machine ready, and if not, exactly what is missing" — runs in CI
   * on a machine that has no client and no driver. A field the platform cannot answer is left
   * false rather than faked, which is why `privilege_required` exists instead of every platform
   * pretending its privileged path is ready.
   */
  struct ClientProbe {
    bool client_found = false;      ///< The CLI binary exists where it is supposed to be.
    std::string client_path;        ///< Where it was found, for the log. Empty when not found.
    bool driver_present = false;    ///< usbip2_ude present (Windows) / vhci_hcd loaded (Linux).
    bool driver_loadable = false;   ///< Linux only: the module exists but is not loaded yet.
    bool privilege_required = false;///< Linux only: attach writes a root-only sysfs node.
    bool helper_present = false;    ///< Linux only: the helper binary AND the socket systemd serves it on.
  };

  /**
   * @brief Why the client is or is not usable.
   *
   * Five states, because four of them are a different thing to do about it, and collapsing any
   * pair of them sends someone to fix the wrong machine. In particular a module that is present
   * and merely unloaded is NOT the same as a driver that is missing: one is a load, the other is
   * a reinstall.
   */
  enum class ClientState {
    Ready,           ///< Everything this platform needs is in place.
    NotInstalled,    ///< No client at all. A setup step, not an error.
    DriverMissing,   ///< The client is here, its driver is not and cannot be loaded.
    DriverNotLoaded, ///< The driver exists but is not loaded. Fixable without reinstalling anything.
    HelperMissing,   ///< Client and driver are fine; the privileged path attach needs is not set up.
  };

  /// The verdict, and a sentence a human can act on rather than a code to look up.
  struct ClientAvailability {
    bool ok = false;
    ClientState state = ClientState::NotInstalled;
    std::string reason;
  };

  /**
   * @brief Whether an uninstall record's DisplayName names the USB/IP CLIENT.
   *
   * The Windows exporter and the Windows importer install two DIFFERENT products whose names both
   * begin "usbip": the importer's client is `USBip`, the exporter's tool is `usbipd-win`. Both are
   * installed on the reference machines.
   *
   * A plain substring match puts `usbipd-win` forward as a candidate. Measured on the reference
   * exporter, that candidate is then rejected downstream - the install directory is required to
   * contain `usbip.exe` - so the loose match does NOT currently produce a wrong answer. The reason
   * to name the product instead is that the loose match makes the right answer depend on what
   * happens to be in a folder rather than on what the product is called, and that holds only until
   * usbipd-win ships a file with that name.
   *
   * The rule is on the NAME: a case-insensitive `usbip` whose next character is not a letter or a
   * digit. "USBip 0.9.8.1" is the client. "usbipd-win" is not.
   */
  bool names_the_client(std::string_view display_name);

  ClientAvailability assess_client(const ClientProbe &probe);
  std::string describe(ClientState state);

  /**
   * @brief What an attach attempt actually did.
   *
   * These states are not decoration and they must not be collapsed. `DeviceBusy` means the device
   * did NOT move and the exporter still counts it as exported - someone else holds it, or a
   * previous session never gave it back. `Failed` means the attach did not happen at all. One sends
   * someone to find out who has their keyboard; the other sends them to read a log. Reporting both
   * as "attach failed" costs them the device.
   */
  enum class AttachOutcome {
    Attached,     ///< The device is here.
    DeviceBusy,   ///< The exporter still has it out. Nothing moved.
    NotOffered,   ///< The exporter does not have that busid to give. Nothing moved. Measured on
                  ///< the real exporter: "error: Device not found by bus id." Its own fix - tick
                  ///< the device on the exporter - which is not the fix for any other state here.
    Refused,      ///< Privilege or policy said no. Nothing moved. On Linux this is the shape a
                  ///< missing helper, or a declined polkit prompt, takes.
    Failed,       ///< It ran, it did not work, and this build cannot name the reason.
  };

  struct AttachResult {
    AttachOutcome outcome = AttachOutcome::Failed;
    std::string detail;  ///< The line that decided it, kept VERBATIM. Never paraphrased.
    /// The port the attach itself named, when it named one. -1 means it did not, and the port
    /// table is then the only thing left that can supply it. Read from the attach's own output
    /// because that output is immediate and the table is not: see port_from_attach_output.
    int port = -1;
  };

  /**
   * @brief Read an attach attempt's exit code and output as one of the four states above.
   *
   * The patterns below are the ones that have actually been seen on real machines - a vendor-engine
   * refusal, and an unprivileged Linux attach. Everything else that fails is `Failed` with its raw
   * output kept, deliberately: naming a failure is a claim about what went wrong, and a claim this
   * build cannot support is worse than an honest "it failed and here is exactly what it said". A
   * new state is added when a real machine produces a real instance of it, not before.
   *
   * stderr is passed in for the same reason the recorder captures it: on both platforms the reason
   * a tool refused appears there, beside an exit code that only says "no".
   */
  AttachResult classify_attach(int exit_code, std::string_view out, std::string_view err);

  std::string describe(AttachOutcome outcome);
}  // namespace input::usbip