Merge remote-tracking branch 'origin/keymindCascade' into keymindCascade
This commit is contained in:
commit
78322df5fe
6 files changed
+914
-21
No files matched your search
@@ -139,8 +139,38 @@ command plus the `--motatool` argument to use afterward.
|
||||
The motatool repair builds the [mikecarper/motatool](https://github.com/mikecarper/motatool)
|
||||
fork at pinned revision `8c38369e7d35ad50cf74261869676d52dd24adf7`, with Cargo's
|
||||
`--locked` dependencies, in a separate per-user cache directory. It needs
|
||||
Rust/Cargo and a native linker already installed; otherwise it points to
|
||||
matching stable Cargo and rustc **1.86.0 or newer** and a native linker already
|
||||
installed; otherwise it points to
|
||||
[Rust installation](https://rustup.rs/) and stops without installing anything.
|
||||
The pinned dependency set needs more than the older `rust-version` in motatool's
|
||||
own manifest: `zeroize 1.9.0` needs edition 2024, and ICU/idna dependencies need
|
||||
Rust 1.86. Cargo 1.75 is a version problem, not a root-permission problem; no
|
||||
nightly toolchain is necessary.
|
||||
|
||||
Before asking to build or downloading crates, the runner checks both executable
|
||||
versions. It keeps a compatible default pair; otherwise it looks for versioned
|
||||
executables on PATH (such as Ubuntu's `cargo-1.91` and `rustc-1.91`) and already
|
||||
installed stable rustup toolchains. It displays the selected paths and pins both
|
||||
for the build process, so a newer Cargo cannot silently invoke an old rustc.
|
||||
Rustup proxies are resolved to their actual toolchain binaries before changing
|
||||
to the build directory. No default toolchain, symlink, system PATH, or package
|
||||
installation is changed, and probes disable rustup's automatic toolchain install.
|
||||
Tool version probes have ten-second timeouts. A working cached motatool does not
|
||||
need Cargo or Rust checks at all.
|
||||
|
||||
To choose a pair explicitly, append:
|
||||
|
||||
```text
|
||||
--cargo cargo-1.91 --rustc rustc-1.91
|
||||
```
|
||||
|
||||
These options also accept executable paths, not shell aliases or a command with
|
||||
embedded arguments. Explicit selections and `RUSTC`/`CARGO_BUILD_RUSTC` compiler
|
||||
overrides are honored; if incompatible, they fail instead of silently selecting
|
||||
another compiler. `--rustc` takes precedence over those environment variables.
|
||||
The manual build command includes the chosen compiler as well as Cargo. Native
|
||||
linker, SDK, and system-library availability are still checked by the real build;
|
||||
a passed version check is not a guarantee that every host prerequisite is present.
|
||||
Source/dependency downloads, build time and disk use are disclosed before
|
||||
confirmation. Cargo progress is displayed, with a one-hour build timeout.
|
||||
The radio admin-password environment variable is not passed to the build.
|
||||
@@ -186,6 +216,32 @@ redirect the run. Duplicate names, ambiguous prefixes, or the same radio under
|
||||
different participant aliases stop with an actionable error. Missing contacts
|
||||
must first be imported or discovered; a key alone does not create a contact.
|
||||
|
||||
For a separate source with a managed USB/TCP console, automatic source selection
|
||||
reads the physical source's full public key and matches it in the controller's
|
||||
contacts. Repeaters use `get public.key`. Full Companion does not implement
|
||||
that repeater command: on both USB and TCP, its key is read from a **fresh
|
||||
terminal welcome banner**, with a supported `ver` reply on the same connection
|
||||
proving the terminal is live. USB briefly uses the existing STOP/START/STOP
|
||||
terminal wrapper to obtain that banner, even for an ASCII-first Full Companion;
|
||||
TCP reads the greeting from a new connection. Identity rechecks repeat this
|
||||
exchange rather than trusting a key cached from an earlier connection.
|
||||
|
||||
`ota status` identifies the Full Companion seeder role before this probe. For
|
||||
unrecognized firmware, only an explicit unsupported `get public.key` response
|
||||
can trigger the Companion fallback. Timeouts, permission errors, malformed keys,
|
||||
missing/ambiguous banners, and failed `ver` replies still stop the run. Firmware
|
||||
that exposes neither identity mechanism needs a supported terminal firmware;
|
||||
the runner does not guess a key from the node name or skip the identity check.
|
||||
|
||||
Source selection does not require the source's current name to match an
|
||||
old saved advert, and duplicate/emoji names cannot redirect this lookup. An
|
||||
explicit `--source-contact` must identify that same physical source. If its key
|
||||
really is missing, import the source contact or advertise/discover it on the
|
||||
normal channel first; the normal/TempRadio on-air ACK checks still require the
|
||||
contact. The script does not silently create contacts or skip those checks.
|
||||
When the controller and source are the same TCP Full Companion, use the verified
|
||||
`--source-shares-controller` topology; it does not need a contact for itself.
|
||||
|
||||
## Destination requirements
|
||||
|
||||
| Destination | Package installed | One-time prerequisite | Raw ZIP handling |
|
||||
|
||||
+287
-19
@@ -147,6 +147,10 @@ MIN_MESHCLI_VERSION = (1, 6, 0)
|
||||
MOTATOOL_REPAIR_REPOSITORY = "https://github.com/mikecarper/motatool.git"
|
||||
MOTATOOL_REPAIR_REVISION = "8c38369e7d35ad50cf74261869676d52dd24adf7"
|
||||
MOTATOOL_REPAIR_TIMEOUT_SECONDS = 60 * 60
|
||||
# The pinned lockfile, not motatool's older package-level rust-version: zeroize
|
||||
# needs edition 2024 (1.85), and idna_adapter/ICU 2.2 require Rust 1.86.
|
||||
MOTATOOL_MIN_RUST_VERSION = (1, 86, 0)
|
||||
RUST_TOOL_PROBE_TIMEOUT_SECONDS = 10
|
||||
CRYPTOGRAPHY_REPAIR_REQUIREMENT = "cryptography==50.0.1"
|
||||
CRYPTOGRAPHY_REPAIR_TIMEOUT_SECONDS = 10 * 60
|
||||
# v1.17.1.5 is the first release-version contract in which every packet,
|
||||
@@ -2558,11 +2562,34 @@ def bind_contact_selectors(controller: Controller, args: argparse.Namespace) ->
|
||||
relays = [(resolve(name), password) for name, password in args.relay_values]
|
||||
source_selector = args.source_contact_value
|
||||
if (
|
||||
not source_selector and not args.source_shares_controller
|
||||
not args.source_shares_controller
|
||||
and has_managed_source_cli(args)
|
||||
):
|
||||
source_selector = read_source_name_bounded(args)
|
||||
source = resolve(source_selector) if source_selector else None
|
||||
# USB/TCP identifies the physical source. Its current display name may
|
||||
# differ from the controller's saved advert (renames, truncation, emoji).
|
||||
# Never bind a same-named but different radio, even with --source-contact.
|
||||
source_key = read_source_public_key_bounded(args).lower()
|
||||
if source_selector:
|
||||
source = resolve(source_selector)
|
||||
if source != source_key:
|
||||
raise OtaError(
|
||||
f"--source-contact identifies {source}, but the connected OTA source "
|
||||
f"reports {source_key}; select the contact for the physical source"
|
||||
)
|
||||
else:
|
||||
if source_key not in contacts:
|
||||
raise OtaError(
|
||||
f"the connected OTA source public key {source_key} is not in the "
|
||||
"controller's contacts; import its contact or advertise/discover it "
|
||||
"on the normal channel first. Renaming a different contact or passing "
|
||||
"--source-contact cannot replace this identity check. If source and "
|
||||
"controller are the same TCP Full Companion, use the verified "
|
||||
"--source-shares-controller topology instead"
|
||||
)
|
||||
source = source_key
|
||||
print(f"[contact] OTA source -> {contacts[source].get('adv_name', '?')} [{source}]")
|
||||
else:
|
||||
source = resolve(source_selector) if source_selector else None
|
||||
keys = [target, *(key for key, _password in relays)]
|
||||
if source:
|
||||
keys.append(source)
|
||||
@@ -2758,6 +2785,29 @@ def parse_temp_radio(value: str) -> tuple[float, float, int, int, int]:
|
||||
return freq, bandwidth, sf, cr, minutes
|
||||
|
||||
|
||||
def parse_source_terminal_banner(value: str) -> tuple[str, str]:
|
||||
"""Read one Full/Companion welcome identity, not an advert or cached name."""
|
||||
text = value.replace("\r", "")
|
||||
headers = list(re.finditer(
|
||||
r"(?m)^===== MeshCore (?:Full )?Companion Terminal =====[ \t]*$", text,
|
||||
))
|
||||
if len(headers) != 1:
|
||||
raise OtaError("OTA source did not expose one fresh Companion terminal banner")
|
||||
tail = text[headers[0].end():]
|
||||
# meshcli raw output strips trailing spaces from a standalone USB prompt.
|
||||
prompt = re.search(r"\n>(?:[ \t]|(?=\n|$))", tail)
|
||||
if prompt is None:
|
||||
raise OtaError("OTA source Companion banner did not reach its terminal prompt")
|
||||
banner = tail[:prompt.start()]
|
||||
keys = re.findall(r"(?m)^[0-9A-Fa-f]{64}$", banner)
|
||||
welcome = re.findall(
|
||||
r"(?:^|\n)WELCOME[^\n]*\n([0-9A-Fa-f]{64})\nCompanion [^\n]+", banner,
|
||||
)
|
||||
if len(keys) != 1 or welcome != keys:
|
||||
raise OtaError("OTA source Companion banner did not expose one exact public key after WELCOME")
|
||||
return keys[0].lower(), tail[prompt.end():]
|
||||
|
||||
|
||||
def source_cli_command(
|
||||
args: argparse.Namespace,
|
||||
command_text: str,
|
||||
@@ -2766,6 +2816,7 @@ def source_cli_command(
|
||||
bounded: bool = False,
|
||||
deadline: float | None = None,
|
||||
retry: bool = True,
|
||||
full_companion_identity: bool = False,
|
||||
) -> str:
|
||||
if not command_text.strip() or any(c in command_text for c in "\r\n\0"):
|
||||
raise OtaError("source command must be one nonempty line")
|
||||
@@ -2773,6 +2824,8 @@ def source_cli_command(
|
||||
raise OtaError(
|
||||
f"{command_text!r} requires state-aware retry handling"
|
||||
)
|
||||
if full_companion_identity and command_text != "ver":
|
||||
raise OtaError("a Companion identity probe must use the read-only ver command")
|
||||
serial_port = args.source_cli_serial or args.source_serial
|
||||
tcp_console = args.source_cli_tcp
|
||||
if not serial_port and not tcp_console:
|
||||
@@ -2783,6 +2836,7 @@ def source_cli_command(
|
||||
return ""
|
||||
|
||||
def run_once() -> str:
|
||||
terminal_key = None
|
||||
operation_timeout = getattr(args, "source_cli_timeout", 30.0)
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.monotonic()
|
||||
@@ -2826,6 +2880,8 @@ def source_cli_command(
|
||||
expected_terminal_key = getattr(
|
||||
args, "shared_source_public_key", None
|
||||
)
|
||||
if full_companion_identity:
|
||||
terminal_key, _ = parse_source_terminal_banner(greeting_text)
|
||||
if expected_terminal_key is not None:
|
||||
banner_keys = re.findall(
|
||||
r"(?:^|\r?\n)([0-9A-Fa-f]{64})(?=\r?\n)",
|
||||
@@ -2872,7 +2928,7 @@ def source_cli_command(
|
||||
raise TransmissionError("source TCP console returned an empty reply")
|
||||
else:
|
||||
wire_command = command_text
|
||||
if getattr(args, "source_companion_terminal", False):
|
||||
if full_companion_identity or getattr(args, "source_companion_terminal", False):
|
||||
# meshcli raw mode keeps one serial open while writing this
|
||||
# compound command. STOP first makes this independent of the
|
||||
# port's current state: ASCII consumes it and returns to
|
||||
@@ -2905,6 +2961,16 @@ def source_cli_command(
|
||||
if ("error" in lowered or "unknown command" in lowered or "err " in lowered
|
||||
or re.search(r"(?:^|\n)\s*(?:->\s*)?(?:err|fail)(?:\b|:)", lowered)):
|
||||
raise OtaError(f"source rejected {command_text!r}: {output}")
|
||||
if full_companion_identity:
|
||||
version_output = output.replace("\r", "")
|
||||
if not tcp_console:
|
||||
terminal_key, version_output = parse_source_terminal_banner(output)
|
||||
replies = re.findall(r"(?m)^[ \t]*(?:>[ \t]*)?Companion [^\n]+", version_output)
|
||||
if len(replies) != 1 or extract_reply_version(replies[0]) is None:
|
||||
raise OtaError("OTA source Companion did not confirm a live ver reply after its identity banner")
|
||||
# Normalize only after this same connection supplied a complete
|
||||
# welcome key AND replied to ver. No key from a previous connection.
|
||||
output = f"> {terminal_key}"
|
||||
if (
|
||||
command_text.startswith("tempradio ")
|
||||
and "ok - temp params " not in lowered
|
||||
@@ -3006,6 +3072,7 @@ def preflight_source_cli(args: argparse.Namespace) -> None:
|
||||
"repeater raw text CLI, nRF52 full Companion USB port, or "
|
||||
"companion_radio_full TCP terminal."
|
||||
)
|
||||
args.source_full_companion = "OTA seeder" in output and "install:disabled" in output
|
||||
|
||||
|
||||
def has_managed_source_cli(args: argparse.Namespace) -> bool:
|
||||
@@ -4974,13 +5041,20 @@ def read_source_public_key_bounded(
|
||||
*,
|
||||
deadline: float | None = None,
|
||||
) -> str:
|
||||
if not getattr(args, "source_full_companion", False):
|
||||
reply = optional_source_cli_command(args, "get public.key", deadline=deadline)
|
||||
if reply is not None:
|
||||
return parse_cli_public_key(reply, "OTA source")
|
||||
# Full Companion has no repeater `get public.key` command. Its supported
|
||||
# USB/TCP terminal supplies the local key in a fresh welcome banner instead.
|
||||
# Unknown firmware can reach this path only after an explicit unsupported
|
||||
# reply, never after a timeout, access denial, or malformed key.
|
||||
reply = source_cli_command(
|
||||
args,
|
||||
"get public.key",
|
||||
bounded=True,
|
||||
deadline=deadline,
|
||||
args, "ver", bounded=True, deadline=deadline, full_companion_identity=True,
|
||||
)
|
||||
return parse_cli_public_key(reply, "OTA source")
|
||||
key = parse_cli_public_key(reply, "OTA source")
|
||||
args.source_full_companion = True
|
||||
return key
|
||||
|
||||
|
||||
def prove_shared_source_terminal_bounded(
|
||||
@@ -6938,6 +7012,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument("--meshcli", default="meshcli")
|
||||
parser.add_argument(
|
||||
"--cargo", help="Cargo executable for motatool repair (default: find a compatible installed toolchain)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rustc", help="Rust compiler for motatool repair (default: pair with selected Cargo)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--motatool", default="motatool",
|
||||
help=(
|
||||
@@ -7480,6 +7560,195 @@ def offer_cryptography_repair(failure: BootloaderCryptoError) -> None:
|
||||
print("[repair] Ed25519 self-test passed; retrying full package/signature verification.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RustBuildTools:
|
||||
cargo: str
|
||||
rustc: str
|
||||
cargo_version: tuple[int, int, int]
|
||||
rustc_version: tuple[int, int, int]
|
||||
|
||||
def environment(self) -> dict[str, str]:
|
||||
env = rust_probe_environment()
|
||||
# Pin both executables for this child process. In particular, a versioned
|
||||
# distro Cargo must not fall back to the old unversioned rustc on PATH.
|
||||
env["CARGO"] = self.cargo
|
||||
env["RUSTC"] = self.rustc
|
||||
return env
|
||||
|
||||
|
||||
def rust_probe_environment() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env.pop("MESHCORE_ADMIN_PASSWORD", None)
|
||||
env["RUSTUP_AUTO_INSTALL"] = "0"
|
||||
return env
|
||||
|
||||
|
||||
def rust_host_output(command: list[str]) -> str:
|
||||
"""Bounded, read-only probes; never invoke rustup install/update/default."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True, check=False,
|
||||
timeout=RUST_TOOL_PROBE_TIMEOUT_SECONDS, env=rust_probe_environment(),
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise OtaError(f"cannot probe {command[0]}: {exc}") from exc
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout).strip()[:1000]
|
||||
raise OtaError(f"cannot probe {command[0]} (exit {result.returncode}): {detail}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def rust_tool_version(command: str, tool: str) -> tuple[int, int, int]:
|
||||
output = rust_host_output([command, "--version"])
|
||||
match = re.match(rf"^{tool} (\d+)\.(\d+)\.(\d+)(?=\s|$)", output)
|
||||
if match is None:
|
||||
raise OtaError(f"cannot determine a stable {tool} version from {command}: {output[:200]}")
|
||||
version = tuple(int(part) for part in match.groups())
|
||||
if version < MOTATOOL_MIN_RUST_VERSION:
|
||||
needed = ".".join(map(str, MOTATOOL_MIN_RUST_VERSION))
|
||||
raise OtaError(f"{command} reports {tool} {'.'.join(map(str, version))}; need {needed} or newer")
|
||||
return version
|
||||
|
||||
|
||||
def rust_executable(command: str) -> str:
|
||||
path = shutil.which(command)
|
||||
if path is None:
|
||||
raise OtaError(f"Rust tool executable not found: {command}")
|
||||
# Keep argv[0] intact for symlink/hardlink rustup proxies.
|
||||
return str(Path(path).absolute())
|
||||
|
||||
|
||||
def resolve_rustup_proxy(command: str, tool: str) -> str:
|
||||
rustup = shutil.which("rustup")
|
||||
if rustup is not None:
|
||||
try:
|
||||
is_proxy = os.path.samefile(command, rustup)
|
||||
except OSError:
|
||||
is_proxy = False
|
||||
if is_proxy:
|
||||
# Resolve in the caller's context, then retain the real binary path:
|
||||
# changing to the build cache must not change the active toolchain.
|
||||
return rust_executable(rust_host_output([rustup, "which", tool]))
|
||||
return command
|
||||
|
||||
|
||||
def versioned_cargo_candidates() -> list[str]:
|
||||
found: dict[str, tuple[int, ...]] = {}
|
||||
for directory in dict.fromkeys(os.get_exec_path()):
|
||||
if not directory:
|
||||
continue
|
||||
try:
|
||||
for entry in Path(directory).iterdir():
|
||||
match = re.fullmatch(
|
||||
r"cargo-(\d+\.\d+(?:\.\d+)?)(?:\.exe)?", entry.name,
|
||||
flags=re.IGNORECASE if os.name == "nt" else 0,
|
||||
)
|
||||
if match and entry.is_file() and os.access(entry, os.X_OK):
|
||||
found[str(entry.absolute())] = tuple(map(int, match[1].split(".")))
|
||||
except OSError:
|
||||
continue
|
||||
return sorted(found, key=lambda path: found[path], reverse=True)[:16]
|
||||
|
||||
|
||||
def installed_rustup_cargos() -> list[str]:
|
||||
rustup = shutil.which("rustup")
|
||||
if rustup is None:
|
||||
return []
|
||||
result = []
|
||||
for line in rust_host_output([rustup, "toolchain", "list"]).splitlines():
|
||||
name = line.split()[0] if line.strip() else ""
|
||||
if not re.fullmatch(r"(?:stable|\d+\.\d+\.\d+)(?:-[A-Za-z0-9_-]+)?", name):
|
||||
continue # Do not select nightly/beta or arbitrary custom toolchains.
|
||||
try:
|
||||
result.append(rust_host_output([rustup, "which", "--toolchain", name, "cargo"]))
|
||||
except OtaError:
|
||||
continue # An incompletely installed toolchain is not usable.
|
||||
if len(result) == 16:
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
def select_motatool_build_tools(args: argparse.Namespace) -> RustBuildTools:
|
||||
explicit_cargo = getattr(args, "cargo", None)
|
||||
explicit_rustc = (
|
||||
getattr(args, "rustc", None) or os.environ.get("RUSTC")
|
||||
or os.environ.get("CARGO_BUILD_RUSTC")
|
||||
)
|
||||
checked: set[str] = set()
|
||||
failures: list[str] = []
|
||||
|
||||
def check(candidate: str) -> RustBuildTools | None:
|
||||
try:
|
||||
cargo = resolve_rustup_proxy(rust_executable(candidate), "cargo")
|
||||
if cargo in checked:
|
||||
return None
|
||||
checked.add(cargo)
|
||||
cargo_version = rust_tool_version(cargo, "cargo")
|
||||
compiler_candidates = [explicit_rustc] if explicit_rustc else []
|
||||
if not explicit_rustc:
|
||||
name = re.sub(r"^cargo", "rustc", Path(cargo).name, count=1, flags=re.IGNORECASE)
|
||||
compiler_candidates = [str(Path(cargo).with_name(name)), name,
|
||||
f"rustc-{cargo_version[0]}.{cargo_version[1]}", "rustc"]
|
||||
compiler_errors = []
|
||||
seen_compilers: set[str] = set()
|
||||
for compiler in dict.fromkeys(compiler_candidates):
|
||||
try:
|
||||
rustc = resolve_rustup_proxy(rust_executable(compiler), "rustc")
|
||||
if rustc in seen_compilers:
|
||||
continue
|
||||
seen_compilers.add(rustc)
|
||||
rustc_version = rust_tool_version(rustc, "rustc")
|
||||
if cargo_version[:2] != rustc_version[:2]:
|
||||
raise OtaError(
|
||||
f"toolchain mismatch: Cargo {'.'.join(map(str, cargo_version))} at {cargo}, "
|
||||
f"rustc {'.'.join(map(str, rustc_version))} at {rustc}; select matching releases"
|
||||
)
|
||||
break
|
||||
except OtaError as exc:
|
||||
compiler_errors.append(str(exc))
|
||||
else:
|
||||
raise OtaError(" | ".join(compiler_errors))
|
||||
tools = RustBuildTools(cargo, rustc, cargo_version, rustc_version)
|
||||
print(f"[host] build Cargo {'.'.join(map(str, cargo_version))}: {cargo}")
|
||||
print(f"[host] build rustc {'.'.join(map(str, rustc_version))}: {rustc}")
|
||||
return tools
|
||||
except OtaError as exc:
|
||||
failures.append(str(exc))
|
||||
print(f"[host] {exc}")
|
||||
return None
|
||||
|
||||
selected = check(explicit_cargo or "cargo")
|
||||
if selected is not None:
|
||||
return selected
|
||||
if not explicit_cargo:
|
||||
for candidate in versioned_cargo_candidates():
|
||||
selected = check(candidate)
|
||||
if selected is not None:
|
||||
return selected
|
||||
try:
|
||||
installed = installed_rustup_cargos()
|
||||
except OtaError as exc:
|
||||
failures.append(str(exc))
|
||||
installed = []
|
||||
for candidate in installed:
|
||||
selected = check(candidate)
|
||||
if selected is not None:
|
||||
return selected
|
||||
needed = ".".join(map(str, MOTATOOL_MIN_RUST_VERSION))
|
||||
raise OtaError(
|
||||
f"motatool repair needs matching stable Cargo and rustc {needed} or newer "
|
||||
"for its pinned dependencies, plus a native linker. "
|
||||
"Select an installed pair with --cargo cargo-1.91 --rustc rustc-1.91, "
|
||||
"or install a current stable user toolchain from https://rustup.rs "
|
||||
"(with rustup already installed: rustup toolchain install stable --profile minimal). "
|
||||
"Root access and nightly are not required for this version issue. "
|
||||
"Shell aliases do not affect this script; explicit --cargo/--rustc and compiler "
|
||||
"environment overrides are not silently replaced. No build or radio changes "
|
||||
"were made. " + " | ".join(failures[-4:])
|
||||
)
|
||||
|
||||
|
||||
def motatool_repair_command(root: Path, cargo: str = "cargo") -> list[str]:
|
||||
return [
|
||||
cargo, "install", "--git", MOTATOOL_REPAIR_REPOSITORY,
|
||||
@@ -7520,9 +7789,14 @@ def offer_motatool_repair(
|
||||
print(f"[host] using previously installed, rechecked motatool: {binary}")
|
||||
return
|
||||
|
||||
cargo = shutil.which("cargo")
|
||||
command = motatool_repair_command(root, cargo or "cargo")
|
||||
build_tools = None if cached else select_motatool_build_tools(args)
|
||||
command = motatool_repair_command(root, build_tools.cargo if build_tools else "cargo")
|
||||
manual = display_host_command(command)
|
||||
if build_tools is not None:
|
||||
if os.name == "nt":
|
||||
manual = "$env:RUSTC = '" + build_tools.rustc.replace("'", "''") + "'; " + manual
|
||||
else:
|
||||
manual = "RUSTC=" + shlex.quote(build_tools.rustc) + " " + manual
|
||||
selection = display_host_command(["--motatool", str(binary)])
|
||||
# Display an argument, not a PowerShell call operator for an option.
|
||||
if os.name == "nt":
|
||||
@@ -7533,12 +7807,6 @@ def offer_motatool_repair(
|
||||
print("[repair] Existing motatool installations and PATH will not be changed.")
|
||||
print(f"[repair] Manual install command: {manual}")
|
||||
print(f"[repair] To select this build explicitly, append: {selection}")
|
||||
if not cached and cargo is None:
|
||||
raise OtaError(
|
||||
"automatic motatool repair requires Rust/Cargo and a native linker; "
|
||||
"install the toolchain from https://rustup.rs and rerun, or select a "
|
||||
"bootloader-capable build with --motatool. No installation was attempted"
|
||||
)
|
||||
if not sys.stdin.isatty():
|
||||
raise OtaError(
|
||||
"motatool repair needs separate interactive approval (--yes does not approve "
|
||||
@@ -7565,8 +7833,8 @@ def offer_motatool_repair(
|
||||
# No cache directories, downloads or builds before affirmative consent.
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
print("[repair] Building motatool (Cargo output follows)...", flush=True)
|
||||
build_env = os.environ.copy()
|
||||
build_env.pop("MESHCORE_ADMIN_PASSWORD", None)
|
||||
assert build_tools is not None
|
||||
build_env = build_tools.environment()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command, cwd=str(root), stdin=subprocess.DEVNULL,
|
||||
|
||||
@@ -304,6 +304,9 @@ class BootloaderStagingTests(unittest.TestCase):
|
||||
mock.patch.object(ota, "run_checked", side_effect=ota.OtaError("unsupported format_ver 3")),
|
||||
mock.patch.object(ota, "motatool_repair_root", return_value=Path(directory) / "cache"),
|
||||
mock.patch.object(ota.shutil, "which", return_value="cargo"),
|
||||
mock.patch.object(ota, "select_motatool_build_tools", return_value=ota.RustBuildTools(
|
||||
"cargo", "rustc", (1, 91, 0), (1, 91, 0),
|
||||
)),
|
||||
mock.patch.object(ota.sys.stdin, "isatty", return_value=False),
|
||||
self.assertRaisesRegex(ota.OtaError, "separate interactive approval"),
|
||||
):
|
||||
@@ -350,7 +353,7 @@ class BootloaderStagingTests(unittest.TestCase):
|
||||
"switch_controller_to_temp_radio", "source_cli_command",
|
||||
):
|
||||
stack.enter_context(mock.patch.object(ota, name))
|
||||
stack.enter_context(mock.patch.object(ota, "read_source_name_bounded", return_value="source"))
|
||||
stack.enter_context(mock.patch.object(ota, "read_source_public_key_bounded", return_value=source_key))
|
||||
stack.enter_context(mock.patch.object(ota, "read_source_rxps", return_value=ota.RxpsSettings(False, 0, 0)))
|
||||
stack.enter_context(mock.patch.object(ota, "read_remote_rxps", return_value=ota.RxpsSettings(False, 0, 0)))
|
||||
stack.enter_context(mock.patch.object(
|
||||
@@ -428,6 +431,84 @@ class StationSelectionTests(unittest.TestCase):
|
||||
with contextlib.redirect_stdout(io.StringIO()), self.assertRaisesRegex(ota.OtaError, "different radios"):
|
||||
ota.bind_contact_selectors(self.controller(), args)
|
||||
|
||||
def test_source_binds_by_local_key_despite_stale_or_emoji_name(self):
|
||||
source_key = "b2" * 32
|
||||
controller = self.controller()
|
||||
controller._run.return_value[0][source_key] = {
|
||||
"public_key": source_key, "adv_name": "Old source name \U0001f4e1",
|
||||
}
|
||||
args = self.args(self.name)
|
||||
args.source_shares_controller = False
|
||||
args.source_cli_tcp = "source:5002"
|
||||
with (
|
||||
mock.patch.object(ota, "read_source_public_key_bounded", return_value=source_key.upper()) as read_key,
|
||||
mock.patch.object(ota, "read_source_name_bounded") as read_name,
|
||||
contextlib.redirect_stdout(io.StringIO()),
|
||||
):
|
||||
ota.bind_contact_selectors(controller, args)
|
||||
self.assertEqual(args.source_contact_value, source_key)
|
||||
read_key.assert_called_once_with(args)
|
||||
read_name.assert_not_called()
|
||||
controller.remote_command.assert_not_called()
|
||||
|
||||
def test_missing_source_key_does_not_bind_same_named_other_radio(self):
|
||||
args = self.args(self.name)
|
||||
args.source_shares_controller = False
|
||||
args.source_serial = "source"
|
||||
source_key = "b2" * 32
|
||||
controller = self.controller()
|
||||
controller._run.return_value[0]["c3" * 32] = {
|
||||
"public_key": "c3" * 32, "adv_name": "W4JEC MC OTA2",
|
||||
}
|
||||
with (
|
||||
mock.patch.object(ota, "read_source_public_key_bounded", return_value=source_key),
|
||||
contextlib.redirect_stdout(io.StringIO()),
|
||||
self.assertRaisesRegex(ota.OtaError, "connected OTA source public key.*not in") as error,
|
||||
):
|
||||
ota.bind_contact_selectors(controller, args)
|
||||
self.assertIn(source_key, str(error.exception))
|
||||
self.assertIn("normal channel", str(error.exception))
|
||||
controller.remote_command.assert_not_called()
|
||||
|
||||
def test_explicit_source_contact_must_match_connected_source_key(self):
|
||||
args = self.args(self.name)
|
||||
args.source_shares_controller = False
|
||||
args.source_serial = "source"
|
||||
args.source_contact_value = "Saved source"
|
||||
controller = self.controller()
|
||||
controller._run.return_value[0]["c3" * 32] = {
|
||||
"public_key": "c3" * 32, "adv_name": "Saved source",
|
||||
}
|
||||
with (
|
||||
mock.patch.object(ota, "read_source_public_key_bounded", return_value="b2" * 32),
|
||||
contextlib.redirect_stdout(io.StringIO()),
|
||||
self.assertRaisesRegex(ota.OtaError, "--source-contact identifies.*connected OTA source"),
|
||||
):
|
||||
ota.bind_contact_selectors(controller, args)
|
||||
controller.remote_command.assert_not_called()
|
||||
|
||||
def test_auto_source_cannot_also_be_destination(self):
|
||||
args = self.args(self.name)
|
||||
args.source_shares_controller = False
|
||||
args.source_serial = "source"
|
||||
with (
|
||||
mock.patch.object(ota, "read_source_public_key_bounded", return_value=self.key),
|
||||
contextlib.redirect_stdout(io.StringIO()),
|
||||
self.assertRaisesRegex(ota.OtaError, "different radios"),
|
||||
):
|
||||
ota.bind_contact_selectors(self.controller(), args)
|
||||
|
||||
def test_shared_source_does_not_require_its_own_contact_or_repeater_cli(self):
|
||||
args = self.args(self.name)
|
||||
args.source_cli_tcp = "shared:5002"
|
||||
with (
|
||||
mock.patch.object(ota, "read_source_public_key_bounded") as read_key,
|
||||
contextlib.redirect_stdout(io.StringIO()),
|
||||
):
|
||||
ota.bind_contact_selectors(self.controller(), args)
|
||||
read_key.assert_not_called()
|
||||
self.assertIsNone(args.source_contact_value)
|
||||
|
||||
def test_remote_reply_accepts_key_selector_with_emoji_name(self):
|
||||
for selector in (self.name, self.key, self.key[:12]):
|
||||
controller = object.__new__(ota.Controller)
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Toolchain preflight regressions; fake executables are never actually run."""
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import lora_ota as ota
|
||||
|
||||
|
||||
class RustToolSelectionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
self.root = Path(directory.name)
|
||||
self.outputs = {}
|
||||
self.args = argparse.Namespace(cargo=None, rustc=None)
|
||||
stack = contextlib.ExitStack()
|
||||
self.addCleanup(stack.close)
|
||||
stack.enter_context(mock.patch.dict(os.environ, {
|
||||
"PATH": str(self.root), "PATHEXT": ".com;.exe;.bat;.cmd",
|
||||
}, clear=True))
|
||||
self.probe = stack.enter_context(mock.patch.object(ota.subprocess, "run", side_effect=self.run_probe))
|
||||
self.rustup = stack.enter_context(mock.patch.object(ota, "installed_rustup_cargos", return_value=[]))
|
||||
stack.enter_context(mock.patch.object(ota, "resolve_rustup_proxy", side_effect=lambda path, _: path))
|
||||
self.output = stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
|
||||
|
||||
def executable(self, name, version):
|
||||
suffix = ".exe" if os.name == "nt" else ""
|
||||
path = self.root / (name + suffix)
|
||||
path.write_bytes(b"test placeholder: must never execute")
|
||||
path.chmod(0o755)
|
||||
tool = "cargo" if name.startswith("cargo") else "rustc"
|
||||
self.outputs[str(path)] = f"{tool} {version} (test-only build)"
|
||||
return str(path)
|
||||
|
||||
def pair(self, version, suffix=""):
|
||||
return self.executable("cargo" + suffix, version), self.executable("rustc" + suffix, version)
|
||||
|
||||
def run_probe(self, command, **kwargs):
|
||||
self.assertEqual(command[1:], ["--version"])
|
||||
self.assertIn(command[0], self.outputs)
|
||||
return subprocess.CompletedProcess(command, 0, self.outputs[command[0]], "")
|
||||
|
||||
def select(self):
|
||||
return ota.select_motatool_build_tools(self.args)
|
||||
|
||||
def test_compatible_default_pair_is_preserved(self):
|
||||
cargo, rustc = self.pair("1.91.0")
|
||||
self.pair("1.98.0", "-1.98")
|
||||
selected = self.select()
|
||||
self.assertEqual((selected.cargo, selected.rustc), (cargo, rustc))
|
||||
self.rustup.assert_not_called()
|
||||
self.assertEqual(self.probe.call_count, 2)
|
||||
|
||||
def test_reported_ubuntu_old_default_selects_versioned_pair(self):
|
||||
old_cargo, _ = self.pair("1.75.0")
|
||||
cargo, rustc = self.pair("1.91.0", "-1.91")
|
||||
selected = self.select()
|
||||
self.assertEqual((selected.cargo, selected.rustc), (cargo, rustc))
|
||||
self.assertEqual(self.probe.call_args_list[0].args[0][0], old_cargo)
|
||||
self.assertIn("1.75.0", self.output.getvalue())
|
||||
self.assertEqual(selected.environment()["RUSTC"], rustc)
|
||||
self.assertEqual(selected.environment()["CARGO"], cargo)
|
||||
|
||||
def test_new_cargo_with_old_rustc_is_not_accepted(self):
|
||||
self.executable("cargo", "1.91.0")
|
||||
self.executable("rustc", "1.75.0")
|
||||
with self.assertRaisesRegex(ota.OtaError, "rustc 1.75.0"):
|
||||
self.select()
|
||||
|
||||
def test_edition_2024_alone_is_not_enough_for_icu_dependencies(self):
|
||||
self.pair("1.85.0")
|
||||
with self.assertRaisesRegex(ota.OtaError, "1.86.0 or newer"):
|
||||
self.select()
|
||||
|
||||
def test_minimum_supported_pair_is_accepted(self):
|
||||
self.pair("1.86.0")
|
||||
self.assertEqual(self.select().rustc_version, (1, 86, 0))
|
||||
|
||||
def test_incompatible_major_minor_pair_is_rejected(self):
|
||||
self.executable("cargo", "1.91.0")
|
||||
self.executable("rustc", "1.90.0")
|
||||
with self.assertRaisesRegex(ota.OtaError, "toolchain mismatch"):
|
||||
self.select()
|
||||
|
||||
def test_patch_release_difference_is_allowed(self):
|
||||
self.executable("cargo", "1.91.1")
|
||||
self.executable("rustc", "1.91.0")
|
||||
self.assertEqual(self.select().cargo_version, (1, 91, 1))
|
||||
|
||||
def test_missing_unversioned_cargo_can_use_versioned_pair(self):
|
||||
cargo, _ = self.pair("1.91.0", "-1.91")
|
||||
self.assertEqual(self.select().cargo, cargo)
|
||||
|
||||
def test_versioned_cargo_does_not_fall_back_to_old_unversioned_rustc(self):
|
||||
self.executable("cargo-1.91", "1.91.0")
|
||||
self.executable("rustc", "1.75.0")
|
||||
with self.assertRaisesRegex(ota.OtaError, "rustc-1.91"):
|
||||
self.select()
|
||||
|
||||
def test_versioned_candidates_are_ranked_numerically(self):
|
||||
self.pair("1.9.0", "-1.9")
|
||||
self.pair("1.91.0", "-1.91")
|
||||
cargo, _ = self.pair("1.100.0", "-1.100")
|
||||
self.assertEqual(self.select().cargo, cargo)
|
||||
|
||||
def test_versioned_cargo_can_use_compatible_unversioned_compiler(self):
|
||||
self.executable("cargo-1.91", "1.91.0")
|
||||
rustc = self.executable("rustc", "1.91.0")
|
||||
self.assertEqual(self.select().rustc, rustc)
|
||||
|
||||
def test_new_default_cargo_can_find_matching_versioned_compiler(self):
|
||||
self.executable("cargo", "1.91.0")
|
||||
self.executable("rustc", "1.75.0")
|
||||
rustc = self.executable("rustc-1.91", "1.91.0")
|
||||
self.assertEqual(self.select().rustc, rustc)
|
||||
|
||||
def test_explicit_cargo_is_not_replaced(self):
|
||||
self.args.cargo, _ = self.pair("1.75.0")
|
||||
self.pair("1.91.0", "-1.91")
|
||||
with self.assertRaisesRegex(ota.OtaError, "not silently replaced"):
|
||||
self.select()
|
||||
self.assertEqual(self.probe.call_count, 1)
|
||||
self.rustup.assert_not_called()
|
||||
|
||||
def test_explicit_versioned_pair_is_used(self):
|
||||
cargo, rustc = self.pair("1.91.0", "-1.91")
|
||||
self.args.cargo = "cargo-1.91"
|
||||
self.args.rustc = "rustc-1.91"
|
||||
self.assertEqual(self.select().rustc, rustc)
|
||||
self.assertEqual(self.select().cargo, cargo)
|
||||
|
||||
def test_explicit_missing_cargo_never_falls_back(self):
|
||||
self.pair("1.91.0")
|
||||
self.args.cargo = "missing-cargo"
|
||||
with self.assertRaisesRegex(ota.OtaError, "missing-cargo"):
|
||||
self.select()
|
||||
self.probe.assert_not_called()
|
||||
|
||||
def test_explicit_old_compiler_environment_is_not_silently_overridden(self):
|
||||
self.pair("1.91.0")
|
||||
compiler = self.executable("rustc-1.75", "1.75.0")
|
||||
for variable in ("RUSTC", "CARGO_BUILD_RUSTC"):
|
||||
with self.subTest(variable=variable), mock.patch.dict(os.environ, {variable: compiler}):
|
||||
with self.assertRaisesRegex(ota.OtaError, "rustc 1.75.0"):
|
||||
self.select()
|
||||
|
||||
def test_cli_compiler_takes_precedence_over_environment(self):
|
||||
self.pair("1.91.0")
|
||||
old = self.executable("rustc-1.75", "1.75.0")
|
||||
self.args.rustc = "rustc"
|
||||
with mock.patch.dict(os.environ, {"RUSTC": old}):
|
||||
self.assertEqual(self.select().rustc_version, (1, 91, 0))
|
||||
|
||||
def test_installed_rustup_toolchain_is_fallback(self):
|
||||
self.pair("1.75.0")
|
||||
tools = self.root / "installed-stable" / "bin"
|
||||
tools.mkdir(parents=True)
|
||||
cargo, rustc = self.pair("1.91.0", "-1.91")
|
||||
actual_cargo = tools / Path(cargo).name.replace("-1.91", "")
|
||||
actual_rustc = tools / Path(rustc).name.replace("-1.91", "")
|
||||
Path(cargo).rename(actual_cargo)
|
||||
Path(rustc).rename(actual_rustc)
|
||||
self.outputs[str(actual_cargo)] = self.outputs[cargo]
|
||||
self.outputs[str(actual_rustc)] = self.outputs[rustc]
|
||||
self.rustup.return_value = [str(actual_cargo)]
|
||||
self.assertEqual(self.select().rustc, str(actual_rustc))
|
||||
|
||||
def test_malformed_version_fails_closed(self):
|
||||
cargo, _ = self.pair("unknown")
|
||||
with self.assertRaisesRegex(ota.OtaError, "cannot determine a stable cargo version"):
|
||||
self.select()
|
||||
self.assertIn(cargo, self.output.getvalue())
|
||||
|
||||
def test_nightly_is_not_automatically_selected(self):
|
||||
self.pair("1.91.0-nightly")
|
||||
with self.assertRaisesRegex(ota.OtaError, "stable cargo version"):
|
||||
self.select()
|
||||
|
||||
def test_no_toolchain_explains_non_root_stable_fix(self):
|
||||
with self.assertRaises(ota.OtaError) as error:
|
||||
self.select()
|
||||
message = str(error.exception)
|
||||
self.assertIn("--cargo cargo-1.91 --rustc rustc-1.91", message)
|
||||
self.assertIn("Root access and nightly are not required", message)
|
||||
self.assertIn("No build or radio changes", message)
|
||||
|
||||
|
||||
class RustProbeTests(unittest.TestCase):
|
||||
def test_probe_is_bounded_noninteractive_and_strips_admin_secret(self):
|
||||
result = subprocess.CompletedProcess([], 0, "cargo 1.91.0\n", "")
|
||||
with (
|
||||
mock.patch.object(ota.subprocess, "run", return_value=result) as run,
|
||||
mock.patch.dict(os.environ, {"MESHCORE_ADMIN_PASSWORD": "secret"}),
|
||||
):
|
||||
self.assertEqual(ota.rust_host_output(["cargo", "--version"]), "cargo 1.91.0")
|
||||
options = run.call_args.kwargs
|
||||
self.assertEqual(options["timeout"], ota.RUST_TOOL_PROBE_TIMEOUT_SECONDS)
|
||||
self.assertEqual(options["stdin"], subprocess.DEVNULL)
|
||||
self.assertEqual(options["env"]["RUSTUP_AUTO_INSTALL"], "0")
|
||||
self.assertNotIn("MESHCORE_ADMIN_PASSWORD", options["env"])
|
||||
|
||||
def test_probe_timeout_and_missing_executable_are_actionable(self):
|
||||
for error in (FileNotFoundError("not installed"), subprocess.TimeoutExpired("cargo", 10)):
|
||||
with mock.patch.object(ota.subprocess, "run", side_effect=error):
|
||||
with self.assertRaisesRegex(ota.OtaError, "cannot probe cargo"):
|
||||
ota.rust_host_output(["cargo", "--version"])
|
||||
|
||||
def test_failed_probe_preserves_diagnostic(self):
|
||||
result = subprocess.CompletedProcess([], 1, "", "toolchain not installed")
|
||||
with mock.patch.object(ota.subprocess, "run", return_value=result):
|
||||
with self.assertRaisesRegex(ota.OtaError, "toolchain not installed"):
|
||||
ota.rust_host_output(["cargo", "--version"])
|
||||
|
||||
def test_rustup_discovery_only_uses_installed_stable_toolchains(self):
|
||||
def output(command):
|
||||
if command[1:] == ["toolchain", "list"]:
|
||||
return "stable-host (default)\n1.91.0-host\nnightly-host\nbeta-host\ncustom\n"
|
||||
return "/installed/" + command[3] + "/cargo"
|
||||
with (
|
||||
mock.patch.object(ota.shutil, "which", return_value="rustup"),
|
||||
mock.patch.object(ota, "rust_host_output", side_effect=output) as probe,
|
||||
):
|
||||
self.assertEqual(ota.installed_rustup_cargos(), [
|
||||
"/installed/stable-host/cargo", "/installed/1.91.0-host/cargo",
|
||||
])
|
||||
self.assertEqual(probe.call_count, 3)
|
||||
self.assertTrue(all(call.args[0][1] in ("toolchain", "which") for call in probe.call_args_list))
|
||||
|
||||
def test_proxy_is_resolved_to_real_binary_before_build_cwd_changes(self):
|
||||
with (
|
||||
mock.patch.object(ota.shutil, "which", return_value="rustup"),
|
||||
mock.patch.object(ota.os.path, "samefile", return_value=True),
|
||||
mock.patch.object(ota, "rust_host_output", return_value="/installed/stable/bin/cargo") as probe,
|
||||
mock.patch.object(ota, "rust_executable", side_effect=lambda path: path),
|
||||
):
|
||||
self.assertEqual(ota.resolve_rustup_proxy("proxy/cargo", "cargo"), "/installed/stable/bin/cargo")
|
||||
probe.assert_called_once_with(["rustup", "which", "cargo"])
|
||||
|
||||
def test_build_environment_is_private_and_pins_both_binaries(self):
|
||||
pair = ota.RustBuildTools("/new/cargo", "/new/rustc", (1, 91, 0), (1, 91, 0))
|
||||
with mock.patch.dict(os.environ, {"CARGO": "old-cargo", "RUSTC": "old-rustc"}):
|
||||
env = pair.environment()
|
||||
self.assertEqual(env["CARGO"], "/new/cargo")
|
||||
self.assertEqual(env["RUSTC"], "/new/rustc")
|
||||
self.assertEqual(os.environ["CARGO"], "old-cargo")
|
||||
self.assertEqual(os.environ["RUSTC"], "old-rustc")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Companion USB/TCP identity compatibility tests; no radios or transmissions."""
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import lora_ota as ota
|
||||
|
||||
|
||||
KEY = "a5" * 32
|
||||
VERSION = "Companion v1.17.1.5 (protocol 1, build test)"
|
||||
|
||||
|
||||
def banner(key=KEY, name="W4JEC MC OTA2", *, full=True):
|
||||
role = "Full Companion" if full else "Companion"
|
||||
return (
|
||||
f"\r\n===== MeshCore {role} Terminal =====\r\n\r\n"
|
||||
f"WELCOME {name}\r\n{key}\r\nCompanion v1.17.1.5\r\n"
|
||||
" (enter 'help' for commands)\r\n\r\n> "
|
||||
)
|
||||
|
||||
|
||||
def connection(greeting=None, reply=VERSION, *, chunks=None):
|
||||
conn = mock.MagicMock()
|
||||
conn.__enter__.return_value = conn
|
||||
conn.recv.side_effect = chunks or [
|
||||
(greeting if greeting is not None else banner()).encode("utf-8"),
|
||||
(reply + "\r\n> ").encode("utf-8"),
|
||||
]
|
||||
return conn
|
||||
|
||||
|
||||
class SourceIdentityTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.args = argparse.Namespace(
|
||||
source_cli_serial=None, source_serial=None,
|
||||
source_cli_tcp="192.0.2.10:5002", source_baud=115200,
|
||||
meshcli="meshcli", source_full_companion=True,
|
||||
)
|
||||
stack = contextlib.ExitStack()
|
||||
self.addCleanup(stack.close)
|
||||
stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
|
||||
|
||||
def test_full_tcp_uses_fresh_banner_and_supported_ver_not_get_public_key(self):
|
||||
conn = connection()
|
||||
with mock.patch.object(ota.socket, "create_connection", return_value=conn):
|
||||
self.assertEqual(ota.read_source_public_key_bounded(self.args), KEY)
|
||||
conn.sendall.assert_called_once_with(b"ver\r\n")
|
||||
|
||||
def test_tcp_fragmented_banner_and_version_reply(self):
|
||||
greeting = banner(KEY.upper()).encode("utf-8")
|
||||
conn = connection(chunks=[greeting[:41], greeting[41:103], greeting[103:], VERSION.encode(), b"\r\n> "])
|
||||
with mock.patch.object(ota.socket, "create_connection", return_value=conn):
|
||||
self.assertEqual(ota.read_source_public_key_bounded(self.args), KEY)
|
||||
|
||||
def test_tcp_probe_does_not_reuse_previous_connection_identity(self):
|
||||
first, second = connection(), connection(banner("b6" * 32))
|
||||
with mock.patch.object(ota.socket, "create_connection", side_effect=[first, second]) as connect:
|
||||
self.assertEqual(ota.read_source_public_key_bounded(self.args), KEY)
|
||||
self.assertEqual(ota.read_source_public_key_bounded(self.args), "b6" * 32)
|
||||
self.assertEqual(connect.call_count, 2)
|
||||
|
||||
def test_usb_ascii_first_full_source_gets_fresh_wrapped_banner(self):
|
||||
self.args.source_cli_tcp = None
|
||||
self.args.source_serial = "COM42"
|
||||
self.args.source_companion_terminal = False
|
||||
result = subprocess.CompletedProcess([], 0, banner() + VERSION + "\r\n> \r\nBinary mode\r\n", "")
|
||||
with mock.patch.object(ota, "run_checked", return_value=result) as run:
|
||||
self.assertEqual(ota.read_source_public_key_bounded(self.args), KEY)
|
||||
command = run.call_args.args[0]
|
||||
self.assertIn("COM42", command)
|
||||
self.assertEqual(command[-1],
|
||||
f"{ota.COMPANION_TERMINAL_STOP}\r{ota.COMPANION_TERMINAL_START}\rver\r{ota.COMPANION_TERMINAL_STOP}\r")
|
||||
self.assertNotIn("get public.key", command[-1])
|
||||
self.assertFalse(self.args.source_companion_terminal) # No persistent mode flag change.
|
||||
|
||||
def test_usb_can_use_explicit_cli_port(self):
|
||||
self.args.source_cli_tcp = None
|
||||
self.args.source_cli_serial = "COM43"
|
||||
self.args.source_serial = "COM42"
|
||||
result = subprocess.CompletedProcess([], 0, banner() + VERSION + "\r\n", "")
|
||||
with mock.patch.object(ota, "run_checked", return_value=result) as run:
|
||||
self.assertEqual(ota.read_source_public_key_bounded(self.args), KEY)
|
||||
self.assertIn("COM43", run.call_args.args[0])
|
||||
self.assertNotIn("COM42", run.call_args.args[0])
|
||||
|
||||
def test_usb_meshcli_trimmed_prompt_is_accepted(self):
|
||||
self.args.source_cli_tcp = None
|
||||
self.args.source_serial = "COM42"
|
||||
rendered = "\n".join(line.rstrip() for line in (banner() + "\r\n" + VERSION).split("\n"))
|
||||
result = subprocess.CompletedProcess([], 0, rendered, "")
|
||||
with mock.patch.object(ota, "run_checked", return_value=result):
|
||||
self.assertEqual(ota.read_source_public_key_bounded(self.args), KEY)
|
||||
|
||||
def test_usb_banner_alone_is_not_a_live_version_reply(self):
|
||||
self.args.source_cli_tcp = None
|
||||
self.args.source_serial = "COM42"
|
||||
result = subprocess.CompletedProcess([], 0, banner() + "Binary mode\r\n", "")
|
||||
with mock.patch.object(ota, "run_checked", return_value=result):
|
||||
with self.assertRaisesRegex(ota.OtaError, "live ver reply"):
|
||||
ota.read_source_public_key_bounded(self.args)
|
||||
|
||||
def test_unknown_command_falls_back_only_to_fresh_companion_identity(self):
|
||||
self.args.source_full_companion = False
|
||||
legacy = connection(reply="ERROR: unknown command: get public.key")
|
||||
companion = connection()
|
||||
with mock.patch.object(ota.socket, "create_connection", side_effect=[legacy, companion]):
|
||||
self.assertEqual(ota.read_source_public_key_bounded(self.args), KEY)
|
||||
legacy.sendall.assert_called_once_with(b"get public.key\r\n")
|
||||
companion.sendall.assert_called_once_with(b"ver\r\n")
|
||||
self.assertTrue(self.args.source_full_companion)
|
||||
|
||||
def test_repeater_keeps_its_supported_get_public_key_command(self):
|
||||
self.args.source_full_companion = False
|
||||
conn = connection("Repeater console\r\n> ", "> " + KEY)
|
||||
with mock.patch.object(ota.socket, "create_connection", return_value=conn):
|
||||
self.assertEqual(ota.read_source_public_key_bounded(self.args), KEY)
|
||||
conn.sendall.assert_called_once_with(b"get public.key\r\n")
|
||||
|
||||
def test_timeouts_and_denials_do_not_trigger_companion_fallback(self):
|
||||
self.args.source_full_companion = False
|
||||
for error in (ota.TransmissionError("timeout"), ota.OtaError("permission denied")):
|
||||
with self.subTest(error=error), mock.patch.object(ota, "source_cli_command", side_effect=error) as command:
|
||||
with self.assertRaises(type(error)):
|
||||
ota.read_source_public_key_bounded(self.args)
|
||||
command.assert_called_once_with(self.args, "get public.key", bounded=True, deadline=None)
|
||||
|
||||
def test_malformed_repeater_key_does_not_trigger_fallback(self):
|
||||
self.args.source_full_companion = False
|
||||
with mock.patch.object(ota, "source_cli_command", return_value="> incomplete") as command:
|
||||
with self.assertRaisesRegex(ota.OtaError, "exact public key"):
|
||||
ota.read_source_public_key_bounded(self.args)
|
||||
command.assert_called_once()
|
||||
|
||||
def test_full_tcp_does_not_accept_wrong_or_multiple_version_replies(self):
|
||||
for reply in ("OK", "Companion not-a-version", VERSION + "\r\n" + VERSION):
|
||||
with self.subTest(reply=reply), mock.patch.object(ota.socket, "create_connection", return_value=connection(reply=reply)):
|
||||
with self.assertRaisesRegex(ota.OtaError, "live ver reply"):
|
||||
ota.read_source_public_key_bounded(self.args)
|
||||
|
||||
def test_missing_or_ambiguous_banner_stops_before_sending_command(self):
|
||||
for greeting in (
|
||||
"Old OTA console\r\n> ", banner().replace(KEY, "incomplete"),
|
||||
banner().replace(KEY, KEY + "\r\n" + "b6" * 32),
|
||||
banner().replace("WELCOME", "RECEIVED ADVERT"),
|
||||
):
|
||||
conn = connection(greeting)
|
||||
with self.subTest(greeting=greeting), mock.patch.object(ota.socket, "create_connection", return_value=conn):
|
||||
with self.assertRaises(ota.OtaError):
|
||||
ota.read_source_public_key_bounded(self.args)
|
||||
conn.sendall.assert_not_called()
|
||||
|
||||
def test_shared_tcp_expected_key_mismatch_still_stops_before_command(self):
|
||||
self.args.shared_source_public_key = "b6" * 32
|
||||
conn = connection()
|
||||
with mock.patch.object(ota.socket, "create_connection", return_value=conn):
|
||||
with self.assertRaisesRegex(ota.OtaError, "terminal identity mismatch"):
|
||||
ota.read_source_public_key_bounded(self.args)
|
||||
conn.sendall.assert_not_called()
|
||||
|
||||
def test_status_detects_full_role_and_contact_binding_uses_supported_identity(self):
|
||||
self.args.source_full_companion = False
|
||||
self.args.target = "destination"
|
||||
self.args.relay_values = []
|
||||
self.args.source_shares_controller = False
|
||||
self.args.source_contact_value = None
|
||||
target_key = "c7" * 32
|
||||
controller = mock.Mock()
|
||||
controller._run.return_value = [{
|
||||
target_key: {"public_key": target_key, "adv_name": "destination"},
|
||||
KEY: {"public_key": KEY, "adv_name": "Saved old source name"},
|
||||
}]
|
||||
status = connection(reply="OTA seeder | install:disabled | serving:0")
|
||||
identity = connection(banner(name="W4JEC MC OTA2 \U0001f4e1"))
|
||||
with mock.patch.object(ota.socket, "create_connection", side_effect=[status, identity]):
|
||||
ota.preflight_source_cli(self.args)
|
||||
self.assertTrue(self.args.source_full_companion)
|
||||
ota.bind_contact_selectors(controller, self.args)
|
||||
self.assertEqual(self.args.source_contact_value, KEY)
|
||||
status.sendall.assert_called_once_with(b"ota status\r\n")
|
||||
identity.sendall.assert_called_once_with(b"ver\r\n")
|
||||
controller.remote_command.assert_not_called()
|
||||
|
||||
def test_deadline_is_forwarded_to_companion_probe(self):
|
||||
with mock.patch.object(ota, "source_cli_command", return_value="> " + KEY) as command:
|
||||
self.assertEqual(ota.read_source_public_key_bounded(self.args, deadline=123.0), KEY)
|
||||
command.assert_called_once_with(self.args, "ver", bounded=True, deadline=123.0, full_companion_identity=True)
|
||||
|
||||
def test_identity_mode_refuses_non_read_only_commands(self):
|
||||
with self.assertRaisesRegex(ota.OtaError, "read-only ver"):
|
||||
ota.source_cli_command(self.args, "reboot", full_companion_identity=True)
|
||||
|
||||
|
||||
class TerminalBannerTests(unittest.TestCase):
|
||||
def test_both_banner_roles_and_emoji_names(self):
|
||||
for full in (True, False):
|
||||
value = banner(KEY.upper(), "Name \U0001f4e1", full=full)
|
||||
self.assertEqual(ota.parse_source_terminal_banner(value + VERSION), (KEY, VERSION))
|
||||
|
||||
def test_duplicate_banners_are_not_one_identity(self):
|
||||
with self.assertRaisesRegex(ota.OtaError, "one fresh"):
|
||||
ota.parse_source_terminal_banner(banner() + banner())
|
||||
|
||||
def test_banner_without_prompt_is_incomplete(self):
|
||||
with self.assertRaisesRegex(ota.OtaError, "prompt"):
|
||||
ota.parse_source_terminal_banner(banner().removesuffix("> "))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -29,6 +29,10 @@ class MotatoolRepairTests(unittest.TestCase):
|
||||
self.addCleanup(stack.close)
|
||||
stack.enter_context(mock.patch.object(ota, "motatool_repair_root", return_value=self.root))
|
||||
stack.enter_context(mock.patch.object(ota.shutil, "which", return_value="cargo"))
|
||||
self.build_tools = ota.RustBuildTools("cargo", "rustc", (1, 91, 0), (1, 91, 0))
|
||||
self.select_tools = stack.enter_context(mock.patch.object(
|
||||
ota, "select_motatool_build_tools", return_value=self.build_tools,
|
||||
))
|
||||
stack.enter_context(mock.patch.object(ota.sys.stdin, "isatty", return_value=True))
|
||||
self.prompt = stack.enter_context(mock.patch("builtins.input", return_value="yes"))
|
||||
self.check = stack.enter_context(mock.patch.object(ota, "check_bootloader_tool"))
|
||||
@@ -54,6 +58,9 @@ class MotatoolRepairTests(unittest.TestCase):
|
||||
self.assertIn(ota.MOTATOOL_REPAIR_REVISION, command)
|
||||
self.assertIn("--locked", command)
|
||||
self.assertNotIn("MESHCORE_ADMIN_PASSWORD", self.install.call_args.kwargs["env"])
|
||||
self.assertEqual(self.install.call_args.kwargs["env"]["RUSTC"], "rustc")
|
||||
self.assertEqual(self.install.call_args.kwargs["env"]["CARGO"], "cargo")
|
||||
self.assertEqual(self.install.call_args.kwargs["env"]["RUSTUP_AUTO_INSTALL"], "0")
|
||||
self.assertEqual(self.install.call_args.kwargs["stdin"], subprocess.DEVNULL)
|
||||
self.assertEqual(self.install.call_args.kwargs["timeout"], ota.MOTATOOL_REPAIR_TIMEOUT_SECONDS)
|
||||
self.check.assert_called_once_with(str(self.binary), self.probe)
|
||||
@@ -89,6 +96,7 @@ class MotatoolRepairTests(unittest.TestCase):
|
||||
self.assertFalse(self.root.exists())
|
||||
|
||||
def test_missing_cargo_gives_prerequisite_without_installing_anything(self):
|
||||
self.select_tools.side_effect = ota.OtaError("install a matching toolchain from https://rustup.rs")
|
||||
with mock.patch.object(ota.shutil, "which", return_value=None):
|
||||
with self.assertRaisesRegex(ota.OtaError, "https://rustup.rs"):
|
||||
self.repair()
|
||||
@@ -96,6 +104,14 @@ class MotatoolRepairTests(unittest.TestCase):
|
||||
self.prompt.assert_not_called()
|
||||
self.assertFalse(self.root.exists())
|
||||
|
||||
def test_old_compiler_stops_before_prompt_build_or_cache_creation(self):
|
||||
self.select_tools.side_effect = ota.OtaError("rustc 1.75.0; need 1.86.0 or newer")
|
||||
with self.assertRaisesRegex(ota.OtaError, "rustc 1.75.0"):
|
||||
self.repair()
|
||||
self.prompt.assert_not_called()
|
||||
self.install.assert_not_called()
|
||||
self.assertFalse(self.root.exists())
|
||||
|
||||
def test_cargo_failure_stops_and_preserves_original_selection(self):
|
||||
self.install.side_effect = None
|
||||
self.install.return_value = subprocess.CompletedProcess([], 101)
|
||||
@@ -134,6 +150,7 @@ class MotatoolRepairTests(unittest.TestCase):
|
||||
self.repair()
|
||||
self.prompt.assert_not_called()
|
||||
self.install.assert_not_called()
|
||||
self.select_tools.assert_not_called()
|
||||
self.check.assert_called_once_with(str(self.binary), self.probe)
|
||||
self.assertEqual(self.args.motatool, str(self.binary))
|
||||
|
||||
@@ -151,6 +168,7 @@ class MotatoolRepairTests(unittest.TestCase):
|
||||
with mock.patch.object(ota.shutil, "which", return_value=None):
|
||||
self.repair()
|
||||
self.install.assert_not_called()
|
||||
self.select_tools.assert_not_called()
|
||||
self.assertEqual(self.check.call_count, 2)
|
||||
self.assertEqual(self.args.motatool, str(self.binary))
|
||||
|
||||
|
||||
Reference in new issue
Block a user