diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7b85f66..5a6e993 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "shambu", - "version": "0.4.0", + "version": "0.5.0", "description": "Prepare, inspect and patch Bambu Studio projects (.3mf) from STL files, slice them headless with estimates, and read a Bambu Lab printer's status over LAN (read-only). Built on Bambu Studio's own command line.", "author": { "name": "Shav Vimalendiran" }, "homepage": "https://github.com/Shavvimal/shambu", diff --git a/CHANGELOG.md b/CHANGELOG.md index 14fc52b..02b1066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,84 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.5.0] - 2026-10-27 + +Printer profiles for LAN-only mode and cloud mode. On 2026-10-27 the X2D went to LAN-only mode and regenerated its +LAN access code, Studio's config still held the old code, and shambu had nowhere to keep an IP or a code, so every +read failed until Studio's config was edited by hand. shambu now keeps its own profiles and codes, and both modes use +the same local read. + +### Added +- Printer profiles: `printers.json` in shambu's config dir holds one entry per printer (name, serial, last IP, mode, + TLS flag, the dates added and last verified) and the default profile. It never holds an access code. +- `shambu printer login` in three forms: `--ip IP [--serial SERIAL]` (a new printer), `--printer NAME [--ip IP]` (a + known profile, for a new code or a new address) and `--from-studio` (the serial and the code of Studio's selected + printer). It reads the printer once with the code and stores the profile and the code only after that read + succeeds. The code comes from `--access-code-file`, `--access-code-stdin` or a hidden prompt on a terminal. + `--name`, `--default` and `--mode` set the profile's name, the default and the mode. +- `shambu printer list`: one line per profile (name, masked serial, IP, mode, last verified time, `(default)`); exit 1 + when there is none. `shambu printer forget --printer NAME [--keep-code]` removes a profile and, unless + `--keep-code`, its stored code. +- `--printer NAME`, `--serial`, `--access-code-file FILE` and `--access-code-stdin` on `doctor`, `check`, `build`, + `sweep` and every `printer` action. The two code flags read the first line and keep the code for this run only. +- `--mode lan|cloud` on `login`: the mode the user set on the touchscreen (shambu does not detect it; a profile + without it says `unknown`). The mode picks the next step that a refused login names. +- A subnet pass in discovery: after the ARP table, the other hosts of the default-route network (/24 or narrower) on + TCP 8883. It runs in `printer discover` (always), in `login` when it has no IP (`--from-studio` without `--ip`), and + on a profile read only when the profile has no IP, which `login` never leaves. A read through a profile goes to the + profile's stored IP and does not search. +- `login --insecure` stores the TLS-off choice on the profile, and every later read through that profile skips the + check; a `login --printer NAME` refresh without `--insecure` clears it. For a new printer, `login --insecure` needs + `--serial`. +- `login --ip` without `--serial` takes the serial from the CN of the printer's certificate (one TLS handshake, no + MQTT, no credential). +- Identity-checked discovery: when the serial came from a flag or a profile and TLS is verified, each discovery hit + must present a certificate that names that serial; a foreign host on 8883 is skipped. A serial from Studio's config + gets the 0.4.0 behaviour: a TCP connect only. + +### Changed +- Error text: a missing code is `no access code for serial : run shambu printer login, or open the printer + once in Bambu Studio's Device tab`. A refused login keeps `refused the MQTT login`, then names the code's source and + the profile, then the next step for the profile's mode (LAN-only: read the code off the touchscreen and run `shambu + printer login`; cloud: open Studio once, then `login --from-studio`; unknown: both). When the printer's address + changes, a profile read fails with `cannot connect ... The profile X expected this address (last login ...); run: + shambu printer login --printer X --ip , or shambu printer discover`. With no serial from any source the + error is `no printer selected: run shambu printer login, or select it in Bambu Studio (Device tab)`, plus `has Bambu + Studio run on this Mac?` when Studio's config is absent. The `no host on this LAN` error now lists why: the hosts + checked that are not the printer, the candidates not checked before the timeout and the passes not run. +- A read through a profile goes to the profile's stored IP. When the printer's address changes (a new DHCP lease), + the read fails with the error above and names the profile's last login; `printer discover` finds the new address + and `login --printer NAME --ip ` records it. A read never rewrites the profile. +- The watch log's `login` and `setup` categories name `shambu printer login` beside Studio. +- `doctor` prints one `printer profile ...` line in its config section when a profile is selected (name, masked + serial, mode, last login as `HH:MM UTC`, TLS flag, whether `credentials.json` holds a code for the serial as `code: + stored|none|unreadable`, profile count, config dir). Doctor's `access code stored` field still describes Studio's config only. + A damaged `printers.json` is an error in that line, and the rest of the report still runs; the other commands that + read profiles exit 3 and name the file. +- The UTC label: doctor's profile line (`last login HH:MM UTC`), the `login` hand-back (`read at HH:MM UTC`) and + `printer list` (`verified HH:MM UTC`) carry it. Doctor's `loaded on the printer (read HH:MM ...)` line is unchanged + from 0.4.0 and shows local time. +- `main` scrubs every known access code and every raw serial from an error text before it prints it; a masked serial + stays. +- The config dir is `$SHAMBU_CONFIG_DIR`, else `$XDG_CONFIG_HOME/shambu`, else `~/.config/shambu`. A relative or + empty value counts as unset, so no file lands in a checkout. +- `--printer` now shadows the argparse prefixes `--printer-timeout` and `--printer-preset`: spell both in full. + +### Security +- The access code is written only to `credentials.json` (mode 0600) in the config dir. It never goes to + `printers.json`, to Studio's config, to argv or to any output. +- Every code the process read, from any source, is a runtime secret: every CLI document and line, usage message and + error text (the watch header and log, doctor and build warnings, `--json` output) is scrubbed of it. Doctor's + printer error is redacted of IPs as before. +- shambu refuses a symlink, or anything that is not a regular file, at the paths of `printers.json` and + `credentials.json`; `credentials.json` must also belong to the user and carry no group or other permission bit. +- Trust boundary: a first-time `login --ip` without `--serial` binds the profile to whatever Bambu-signed certificate + answers at that IP. A chain-valid certificate proves a Bambu device, not the intended one; `--serial` from the + touchscreen is the stronger binding. +- Discovery sends nothing but a TCP connect to a candidate host, plus a TLS handshake (the identity check) only when + the serial came from a flag or a profile and TLS is verified; a serial from Studio's config gets a TCP connect only. + The subnet pass scans no network wider than /24 and runs only where the Added list says (SECURITY.md has the + contract). ## [0.4.0] - 2026-10-21 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7641dbf..9421741 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,7 @@ binary or a printer; integration tests skip unless `SHAMBU_INTEGRATION=1` and a | A new limit rule or placer | `src/shambu/layout.py` | `references/06-slicer-rules.md`, `08-optimisation.md` section 11 and `00-findings.md` section 10 | | CLI flag handling | `src/shambu/studio.py` (`CliCommand`) | `references/01-source-cli.md` section 1 | | Printer fields | `src/shambu/printer.py` | `references/04-app-printer-control.md` | +| Printer profiles and the credentials file | `src/shambu/profiles.py`, `conf.py`, `vault.py` | `docs/specs/2026-10-27-printer-profiles.md` | | Skill text | `skills/shambu/SKILL.md`, `skills/setup/SKILL.md` | the plugin docs | Every rule the code encodes about Bambu Studio should cite the reference that proved it. The diff --git a/DISCLAIMER.md b/DISCLAIMER.md index 08e9dd9..214f09e 100644 --- a/DISCLAIMER.md +++ b/DISCLAIMER.md @@ -16,9 +16,15 @@ material if its settings are wrong. ## Device and network operations -shambu reads a Bambu Lab printer over the local network using the printer's own LAN access code, which it reads from -Bambu Studio's configuration on the same machine. It subscribes to the printer's status topic and sends one status -request. It never starts, pauses, stops or modifies a print, never uploads files, and never sends any other command. +shambu reads a Bambu Lab printer over the local network using the printer's own LAN access code, which it takes from +a one-shot flag, from the credentials file that `shambu printer login` writes in shambu's config dir, or from Bambu +Studio's configuration on the same machine. It subscribes to the printer's status topic and sends one status request. +It never starts, pauses, stops or modifies a print, never uploads files, and never sends any other command. A read +through a profile goes to the IP the profile stores. To find the printer it connects to TCP 8883 on the hosts in the +ARP table and, in `printer discover`, in `login --from-studio` without `--ip` and on a profile read when the profile +has no IP, on the other hosts of the default-route network when that network is /24 or narrower. A candidate gets +nothing but a TCP connect and, when the serial came from a flag or a profile and TLS is verified, a TLS handshake; a +serial from Studio's config gets a TCP connect only (see `SECURITY.md`). Protect your access code, your network and anything you capture; do not run the tool against a printer you do not control. diff --git a/README.md b/README.md index 19cd6a9..8a78347 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,9 @@ spec.json -> check -> build -> patch -> verify -> report.md (+ project - macOS with [Bambu Studio](https://bambulab.com/en/download/studio) installed (02.08.x tested; the CLI lives in the app bundle). - [uv](https://docs.astral.sh/uv/) (Python 3.11+ is fetched by uv if needed). - For the plugin: [Claude Code](https://code.claude.com/). -- A Bambu Lab printer for the status commands (tested on an X2D; the printer must be on the same LAN and selected - once in Bambu Studio's Device tab so its LAN access code is stored). +- A Bambu Lab printer for the status commands (tested on an X2D, in LAN-only and in cloud mode; the printer must be + on the same LAN). shambu needs its LAN access code once: `shambu printer login` stores a profile and the code, or + select the printer once in Bambu Studio's Device tab so Studio stores the code, as before. ## Installation @@ -55,17 +56,17 @@ As a Claude Code plugin: /shambu:setup ``` -`/shambu:setup` installs the CLI (`uv tool install "shambu @ git+https://github.com/Shavvimal/shambu@v0.4.0"`) and runs +`/shambu:setup` installs the CLI (`uv tool install "shambu @ git+https://github.com/Shavvimal/shambu@v0.5.0"`) and runs `shambu doctor`. From then on, ask Claude to "make a plate from these STLs with PETG functional settings" and the `/shambu` skill does the rest. CLI only: ``` -uv tool install "shambu @ git+https://github.com/Shavvimal/shambu@v0.4.0" -shambu doctor --expect-version 0.4.0 +uv tool install "shambu @ git+https://github.com/Shavvimal/shambu@v0.5.0" +shambu doctor --expect-version 0.5.0 # or without installing: -uvx --from "git+https://github.com/Shavvimal/shambu@v0.4.0" shambu doctor --expect-version 0.4.0 +uvx --from "git+https://github.com/Shavvimal/shambu@v0.5.0" shambu doctor --expect-version 0.5.0 ``` ## Usage @@ -127,13 +128,34 @@ logged last resort for anything else. **4. Read the printer (read-only) and measure prints.** ``` -shambu printer filaments [--ip ] # the spool in each nozzle and slot, with the preset it resolves to +shambu printer login --ip --serial --mode lan # prompts for the LAN access code (not shown) +shambu printer login --printer x2d --ip # a known profile: a new code or a new address +shambu printer login --from-studio --name x2d # the serial and the code of Studio's selected printer +shambu printer list # the profiles: name, masked serial, IP, mode, last verified +shambu printer filaments [--printer x2d] # the spool in each nozzle and slot, with the preset it resolves to shambu printer status [--ip ] shambu printer watch --every 300 # until the printer leaves RUNNING/PAUSE shambu printer watch --wait-for-start --log print.jsonl --gcode3mf plate.gcode.3mf # log one print shambu calibrate --log print.jsonl --add # store it; `calibrate --show` prints the fit per printer and material ``` +`login` reads the printer once with the code and stores the profile and the code only when that read succeeds. +`--access-code-stdin` or `--access-code-file FILE` give the code without a prompt; on any other command they give it +for that run only. Profiles live in `printers.json` and codes in `credentials.json` (mode 0600) in shambu's config +dir: `$SHAMBU_CONFIG_DIR`, else `$XDG_CONFIG_HOME/shambu`, else `~/.config/shambu`. A command that reads the printer +takes the code from a one-shot flag, then from the stored code of the profile (`--printer NAME`, else the default +profile), then from Bambu Studio's config. Both printer modes use the same local read; the mode only says where a new +code comes from when the printer refuses the old one (the touchscreen in LAN-only mode, Studio in cloud mode). +`shambu printer forget --printer NAME` removes a profile and its code (`--keep-code` keeps the code); when the +forgotten profile was the default, the first remaining profile becomes the default. + +A read through a profile goes to the IP the profile stores and does not search; `login` always stores the IP it read +from. The subnet pass (the default-route /24 after the ARP table) runs in `printer discover` (always), in `login` +when it has no IP (`--from-studio` without `--ip`), and on a profile read only when the profile has no IP. When the +printer's address changes (a new DHCP lease), a profile read fails with `cannot connect ... The profile x2d expected +this address (last login ...); run: shambu printer login --printer x2d --ip , or shambu printer discover`. +Run `shambu printer discover`, then `shambu printer login --printer x2d --ip ` to record the new address. + Every time in a report is "Studio estimate", Studio's own prediction. The watch log stamps each poll and never holds the IP or the serial. A report adds the measured range of stored prints, and a calibrated time only when three qualifying prints of that printer model and material agree. @@ -150,8 +172,11 @@ Exit codes: 0 ok, 1 a check or verification failed or a patch was refused, 2 usa - The printer code subscribes to the status topic and publishes exactly one `pushall` request. It never starts, pauses, stops or changes a print and never uploads a file. Pull requests that add printer control are not merged. -- The LAN access code is read from Bambu Studio's configuration, kept in memory, never printed or logged, and sent - only over TLS verified against Bambu's certificate authority (`--insecure` is an explicit opt-out). +- The LAN access code comes from a one-shot flag, the credentials file (mode 0600) or Bambu Studio's configuration. + It is never printed, logged or passed on a command line, and it is sent only over TLS verified against Bambu's + certificate authority (`--insecure` is an explicit opt-out). `login --insecure` stores that choice on the profile, + and every later read through the profile skips the check; a `login --printer NAME` refresh without `--insecure` + clears it. - Projects are handed back for the user to open, check and print. The tool never presses Print. ## Rules the code encodes (so nobody has to remember them) @@ -195,7 +220,7 @@ Exit codes: 0 ok, 1 a check or verification failed or a patch was refused, 2 usa | Path | What | |---|---| -| `src/shambu/` | The package: `presets` (chain of profile sources, flattening), `studio` (binary adapter, `CliCommand` builder), `assemble` (assemble-list model and validation), `layout` (placer and limit rules), `mesh` (STL read/write, orientation scoring), `threemf` + `patches` (3MF read/patch commands, G-code feature usage), `spec` (typed spec, builder), `recipes`, `build` (template-method pipeline), `sweep` (variant builds and the comparison table), `report`, `printer` (discovery, MQTT reader, decoder), `conf`, `facade`, `cli` | +| `src/shambu/` | The package: `presets` (chain of profile sources, flattening), `studio` (binary adapter, `CliCommand` builder), `assemble` (assemble-list model and validation), `layout` (placer and limit rules), `mesh` (STL read/write, orientation scoring), `threemf` + `patches` (3MF read/patch commands, G-code feature usage), `spec` (typed spec, builder), `recipes`, `build` (template-method pipeline), `sweep` (variant builds and the comparison table), `report`, `printer` (discovery, MQTT reader, decoder), `conf` (Studio's config, the config dir), `profiles` (printer profiles), `vault` (the credentials file), `facade`, `cli` | | `tests/` | Offline pytest suite with fakes for the binary, the profile tree and the MQTT transport | | `skills/shambu/` | The Claude Code skill and the reverse-engineering references | | `skills/setup/` | `/shambu:setup`: installs the CLI, runs `shambu doctor` | diff --git a/REVIEW-RUBRIC.md b/REVIEW-RUBRIC.md index 23219cf..8fd076f 100644 --- a/REVIEW-RUBRIC.md +++ b/REVIEW-RUBRIC.md @@ -5,14 +5,14 @@ Each item has a test a reviewer can run. An item passes only when its test passe ## Architecture -1. **Front-end boundary.** Only `shambu/cli.py` reads argv or stdin or prints to stdout. - Fail if `rg -n "sys\.argv|\binput\(|\bprint\(" src/shambu --glob '!cli.py'` returns a line, or a library module imports `shambu.cli`. +1. **Front-end boundary.** Only `shambu/cli.py` reads argv or stdin, prompts or prints to stdout. + Fail if `rg -n "sys\.argv|sys\.stdin|\bgetpass\b|\binput\(|\bprint\(" src/shambu --glob '!cli.py'` returns a line, or a library module imports `shambu.cli`. 2. **Dependency injection.** `Pipeline`, `Bambu`, `PrinterService` receive their adapters (`StudioCLI`, `PresetResolver`, `Placer`, `LimitChecker`, `Discovery`) in `__init__`; tests construct them with fakes. - Fail if `subprocess.` appears outside `studio.py`, `printer.py` (arp) and `facade.py` (open), or `paho` is imported outside `printer.py`. + Fail if `subprocess.` appears outside `studio.py`, `printer.py` (arp, route, ifconfig) and `facade.py` (open), or `paho` is imported outside `printer.py`. 3. **Constructors only store.** No `__init__` does I/O. (`StudioCLI.__post_init__` checks that the binary exists: that is a stat, documented.) 4. **Pure planning core.** `spec.py`, `assemble.py`, `layout.py`, `tower.py`, `edits.py`, `patches.py`, `recipes.py` import no `subprocess`, `socket`, `paho`, `zipfile`. Fail if `rg -n "import (subprocess|socket|zipfile)|paho" shambu/{spec,assemble,layout,tower,edits,patches,recipes}.py` matches. -5. **Pattern economy.** Every Protocol or Strategy has two implementations or a test fake: `PresetSource` (DirectorySource, ChainedSource), `Placer` (ShelfPacker, BottomLeftPacker + test fake), `Footprint` (RasterFootprint, BoxFootprint), `SpreadProbe` (StudioProbe + test fake), `Rule` (Clearance, InsideBed, ExtruderReach, TpuOnMain, MeshSane, SequentialClearance, tower.TowerClear), `tower.TowerClearance` (FootprintClearance, BoxClearance + test fake), `Patch` (thirteen in `patches.py`, four in `edits.py`; the Protocol declares `touches`, `resolve_touches` and `verify`, and `PatchBase` gives their defaults, so no caller looks them up with `getattr`), `Discovery` (ArpProbe, SsdpListen), `Listener` (CLI logger + test recorder). +5. **Pattern economy.** Every Protocol or Strategy has two implementations or a test fake: `PresetSource` (DirectorySource, ChainedSource), `Placer` (ShelfPacker, BottomLeftPacker + test fake), `Footprint` (RasterFootprint, BoxFootprint), `SpreadProbe` (StudioProbe + test fake), `Rule` (Clearance, InsideBed, ExtruderReach, TpuOnMain, MeshSane, SequentialClearance, tower.TowerClear), `tower.TowerClearance` (FootprintClearance, BoxClearance + test fake), `Patch` (thirteen in `patches.py`, four in `edits.py`; the Protocol declares `touches`, `resolve_touches` and `verify`, and `PatchBase` gives their defaults, so no caller looks them up with `getattr`), `Discovery` (ArpProbe, SubnetProbe, SsdpListen), `CredentialSource` (OneShotCredentials, ProfileCredentials, StudioCredentials + test fake), `Vault` (FileVault, MemoryVault), `Listener` (CLI logger + test recorder). 6. **Template Method is overridable.** A `Pipeline` subclass that overrides `export()` with a fake runs end to end in tests without Bambu Studio. ## Correctness @@ -28,7 +28,7 @@ Each item has a test a reviewer can run. An item passes only when its test passe ## Safety -14. **Secrets.** The LAN access code is read only by `conf.access_code()`, stored in `MqttReader.access_code` with `repr=False`, and never logged, printed, written or passed on a command line. Test: a sentinel code in a fake config never appears in CLI stdout/stderr. +14. **Secrets.** The LAN access code enters only through `conf.access_code()` and `printer.StudioCredentials.code()` (Studio's config), `vault.Vault.get()` (the credentials file) and `cli.read_access_code()` (a one-shot flag or the `login` prompt), and each registers it with `conf.remember_secret`. It is held only in `repr=False` fields (`MqttReader.access_code`, `OneShotCredentials.value`, `Resolved.code`, `Bambu.printer_code`) and in `conf._RUNTIME_SECRETS`, written only to `credentials.json` (mode 0600) by `vault.FileVault`, never logged or printed, and never passed on a command line. Test: every test that `rg -l "SENTINEL-CODE" tests` lists passes (`test_cli.py` covers `printer login`, `list`, `forget`, `discover`, `status` and `main`'s error path; the other files cover the reads, the TLS path, Studio's config, the profile store, the credentials file and the calibration file); each asserts the sentinel code is absent from stdout, stderr and the files it checks. 15. **Read-only printer.** The only MQTT publish is the `pushall` request. Fail if `rg -n "publish\(" shambu` shows another topic or payload, or any code path sends `project_file`, `pause`, `stop`, `resume`, `print`. 16. **No GUI keystrokes.** No `osascript ... keystroke`. Opening a project uses `open -a BambuStudio`. diff --git a/SECURITY.md b/SECURITY.md index af9c1fa..d8dfa2e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,14 +9,37 @@ issue for a security problem. You should hear back within a week. ## What shambu touches - **Bambu Studio's configuration** (`~/Library/Application Support/BambuStudio/BambuStudio.conf`): shambu reads the - selected printer serial and its LAN access code from it. The code stays in memory, is never logged, printed, - written or passed on a command line, and the serial is masked in output. + selected printer serial and its LAN access code from it, and never writes it. Studio's stored code is the last + source of the code: a one-shot flag (`--access-code-file`, `--access-code-stdin`) comes first, then the code that + `shambu printer login` stored for the profile's serial, then Studio's. The code stays in memory, is never logged, + printed or passed on a command line, and the serial is masked in output. +- **shambu's config dir** (`$SHAMBU_CONFIG_DIR`, else `$XDG_CONFIG_HOME/shambu`, else `~/.config/shambu`; a relative + or empty value counts as unset). `printers.json` holds the printer profiles: names, serials, IPs and modes, never a + code. `credentials.json` holds the access codes, mode 0600; shambu creates the dir 0700. shambu refuses a symlink + or anything that is not a regular file at either path. `shambu printer forget --printer NAME` removes the profile + and its code. The code file gets the same protection as Studio's own plain-text config against another process + that runs as the same user, with a tighter file mode. It lives outside any repository. - **The printer, over LAN MQTT (TCP 8883, TLS):** shambu subscribes to the printer's report topic and publishes one `pushall` status request. It never starts, pauses, stops or modifies a print and never uploads files. TLS is verified against the CA bundle shipped with Bambu Studio; `--insecure` disables that and is the user's choice. - **Bambu Studio's binary:** shambu runs it as a subprocess with an argument list (no shell), a timeout and a private data directory. Project files it reads are local files the user chose. -- **No network calls** other than the printer, and only when a `printer` command runs. +- **The network, and only from the commands that read the printer** (`doctor`, `check`, `build`, `sweep` and every + `printer` action). shambu connects to the printer's broker on TCP 8883. A read through a profile goes to the IP the + profile stores and does not search. To find the printer it connects to TCP 8883 on candidate hosts: the hosts in the + ARP table, as 0.4.0 does, and then, in a subnet pass, the other hosts of the IPv4 network of the interface that + carries the default route, only when its mask is /24 or narrower. The subnet pass runs in `printer discover` + (always), in `login` when it has no IP (`--from-studio` without `--ip`), and on a profile read only when the profile + has no IP, which `login` never leaves. A read without a profile and without `--ip` uses the ARP table only. A wider + mask scans nothing. A point-to-point interface (`inet A --> B`, for example `utun`) is not scanned. A candidate gets + a TCP connect and, when the serial came from a flag or a profile and TLS is verified, a TLS handshake that reads its + certificate, and nothing else; a serial from Studio's config gets a TCP connect only. With TLS verified, no access + code goes to a host before its certificate names the serial. There are no other network calls. + +First-time enrolment has a trust boundary. `shambu printer login --ip IP` without `--serial` takes the serial from the +certificate that answers at that IP, so the profile binds to whatever Bambu-signed device answers there: a +chain-valid certificate proves a Bambu device, not the intended one. The serial from the printer's touchscreen, +given as `--serial`, is the stronger binding. ## Supported versions diff --git a/docs/specs/2026-10-27-printer-profiles.md b/docs/specs/2026-10-27-printer-profiles.md new file mode 100644 index 0000000..70649ed --- /dev/null +++ b/docs/specs/2026-10-27-printer-profiles.md @@ -0,0 +1,147 @@ +# Feature spec: printer profiles for LAN-only mode and cloud mode (2026-10-27) + +Status: IMPLEMENTED in 0.5.0 with the section 11 decisions; section 11 supersedes every Keychain, `security`, `code_source`, relocation and Application Support passage below. Severity of the gap: medium (the tool reads the printer only through Bambu Studio's config plus an ARP scan; the day the printer went to LAN-only mode, every read failed until the owner's assistant hand-edited Studio's config with Studio closed). The owner's words: "I need to enable LAN access, what does that mean for me?" and "keep in mind I want to use Account mode (Bambu Cloud) as well as LAN mode. Both should be supported." Written up from the 2026-10-27 session and three research passes over `src/shambu/printer.py`, `src/shambu/conf.py`, the Studio source (master `79df7ae`, 13 Oct 2026), ha-bambulab (`0e027ff`, 21 Sep 2026) and the community protocol docs. Nothing in this document is implemented. + +## 1. What happened, in order + +1. 2026-10-27, morning: the owner switched the X2D to LAN-only mode on the touchscreen so a teammate can develop against it. The printer regenerated its LAN access code (one X2D observation, n=1; sources conflict on whether the toggle always does this, section 8). +2. `shambu doctor` reported `loaded on the printer: not read (PrinterError: no host on this LAN answers on port 8883. Is the printer on and on this network? Pass --ip.)`. `shambu printer discover` (ARP plus a port probe, no SSDP by default) found nothing, because the ARP table on this Mac had no entry for the printer. +3. A hand scan of the subnet for port 8883 found the printer. `shambu printer status --ip ` then failed with `printer at refused the MQTT login (Not authorized): check the LAN access code in Studio`. Studio's stored code was the old one. +4. shambu has no way to take a new access code. The assistant backed up `BambuStudio.conf`, wrote the new code under `access_code.` while Studio was closed, and the read worked. This is exactly the write that `conf.py`'s docstring forbids callers to do, and it survives only until Studio next saves its config (Studio writes the whole file every 5 s while dirty and again on exit, `GUI_App.cpp:2999-3000,3716-3721`; after a refused login it clears the stored code, `GUI_App.cpp:2280-2290`). +5. Every later command needs `--ip`, and nothing remembers it. The session memory outside the repo now holds the IP, which is the wrong place. + +## 2. Root cause + +1. **Credentials have one source.** `PrinterService.resolve()` (`printer.py:1185-1190`) is hard-wired to `conf.load()`, `conf.selected_serial()` and `conf.access_code()`. Studio's config is right only while Studio itself is bound to the printer in the same mode. In LAN-only mode Studio needs a fresh bind in its Device tab before its config is useful again, and in cloud mode the code is whatever the cloud bind list last said (`DevManager.cpp:883-887`). +2. **The IP has no home.** Studio stores the IP of a LAN-mode printer encoded against `slicer_uuid` (`user_access_dev_ip.`, `DeviceManager.cpp:518-535`), and nothing for a cloud printer. shambu stores nothing and rediscovers on every call. The default discovery is `ArpProbe` only (`printer.py:1115`): it probes port 8883 on the hosts already in the ARP table, so a printer this Mac has not talked to recently is invisible. `SsdpListen` exists (`printer.py:278-341`) and even parses `DevConnect` (lan or cloud), but only `printer discover --ssdp N` runs it, and nothing reads the mode field. +3. **The error text points at Studio.** `printer.py:560`, `:1189`, `:1209`, `:1323`, `:1328` and `conf.py:65`, `:68` all tell the user to fix Studio. For a LAN-only printer that advice is a detour, and for a rotated code it is wrong: Studio clears the code it held. +4. **Mode is invisible.** Nothing records or reports whether the printer is in cloud mode or LAN-only mode, so the hand-back cannot say why a read failed or where a fresh code would come from. + +## 3. What the tool and the printer already offer, unused + +- **One transport serves both modes.** The local broker on port 8883 accepts `bblp` plus the access code in cloud mode as well as LAN-only mode. shambu read the X2D on 2026-10-16 while the job reported `print_type: cloud` (`skills/shambu/references/04-app-printer-control.md:412`), and the 2026-10-27 read was in LAN-only mode. Status pushes are outside Authorization Control by Bambu's own list (the 16 Jan 2025 firmware post), so neither mode needs Developer Mode for a read. The mode changes only where the code comes from and how the printer is found. +- **The TLS certificate names the printer.** The broker's certificate has the serial as its CN, and `check_identity` (`printer.py:351-357`) already compares it. A TLS handshake with hostname checks off, and no MQTT CONNECT, reveals the serial of whatever answers at an IP. That gives `login --ip` the serial without the touchscreen. +- **A refused login is one CONNACK code.** `MqttReader` already maps "Not authorized" to a `PrinterError` (`printer.py:560`). ha-bambulab treats the same code as "the access code rotated" and stops, because the printer also returns it while it boots or powers down (ha-bambulab issue #1863). +- **A data-file pattern exists.** `calibration.json` has a version field, an exact key set, a validator on read and write, an atomic `mkstemp` plus `os.replace`, an `fcntl.flock` lock for load-change-save and a `path=` override for tests (`conf.py:112-286`). A profile store copies it. +- **SSDP carries the mode.** A NOTIFY packet holds `DevConnect.bambu.com: lan|cloud` and `DevBind.bambu.com: free|occupied` and needs no login (`DevManager.cpp:126-143`, the community packet captures). `SsdpListen` parses it today. + +## 4. Required behaviour + +1. **A shambu-owned profile store** at `~/Library/Application Support/shambu/printers.json`, beside `calibration.json`. One entry per printer: `name`, `serial`, `ip`, `model`, `mode` (`lan`, `cloud` or `unknown`), `code_source` (`keychain` or `studio`), `added` and `verified` (the UTC time of the last successful read). No access code ever goes in this file. JSON, not TOML: the package has no TOML writer and adds no dependency for this. +2. **The access code lives in the macOS Keychain**, service `shambu`, account = the serial, written and read by `/usr/bin/security` so the item's ACL trusts that one binary and no prompt appears whichever Python runs shambu. The write goes through `security -i` on stdin, never `-w ` on argv. The read is `find-generic-password -w`; exit 44 means "no item". +3. **Both modes, one path.** The read stays local MQTT, `bblp`, one `pushall`, in both modes. The profile's `mode` is recorded, not configured: from `DevConnect` when a NOTIFY is heard, else from what `login` observed, else `unknown`. The mode changes three things: the hint in a refused-login message (section 5.4), whether the Studio config is a trusted code source (cloud: yes, Studio refreshes it from the account; LAN-only: only after a fresh Device-tab bind), and what `doctor` prints. +4. **Resolution order for the IP:** `--ip`, then the selected profile's `ip`, then the 8883 subnet probe, then SSDP. When the profile's IP does not answer on 8883 (a connect failure, not a refusal), the probe runs, and a hit whose certificate serial matches the profile updates the profile's `ip` and prints one line saying so. A hit with another serial is reported and not stored. +5. **Resolution order for the code:** `--access-code-stdin` or `--access-code-file` (one-shot, never stored unless the verb is `login`), then the Keychain item for the serial, then Studio's `access_code.` as today. The source used is named in `doctor` and in every refused-login message. +6. **Resolution order for the serial:** `--serial`, then the selected profile, then Studio's selected printer, then the certificate CN of the host at `--ip`. +7. **The 8883 subnet probe replaces the ARP-only probe** as the default finder: ARP hosts first (they are cheap and usually right), then the rest of the /24 that the Mac's primary interface is on, 64 threads, 0.5 s per host, inside the existing discovery deadline. A /24 at 0.5 s and 64 threads takes about 2 s. Larger subnets are not scanned; the user passes `--ip` or runs `login`. +8. **No profile, no change.** With no `printers.json`, every command behaves as 0.4.0 does. A profile is opt-in through `login`. +9. **Studio's config is never written.** `login --from-studio` copies Studio's selected serial and code into a profile and the Keychain. Nothing copies the other way: Studio rewrites its file every 5 s while running and clears the code after a refused login, so a write from shambu would be lost or would mislead. +10. **Cloud MQTT and the Bambu account API are out of scope** for 0.5.0. They need an account token that lasts about 90 days, a Cloudflare-fingerprint workaround, and the login endpoint broke in May 2026 (ha-bambulab issue #1999). SECURITY.md promises no network call other than the printer. A later phase can add a single bind-list call to refresh the code after a refusal, behind an explicit flag; section 8 keeps the question. + +## 5. The verbs and flags + +### 5.1 `shambu printer login` + +``` +shambu printer login --ip 192.0.2.40 # prompts for the code on the tty, hidden +shambu printer login --ip 192.0.2.40 --access-code-stdin < code.txt +shambu printer login --ip 192.0.2.40 --serial --name x2d +shambu printer login --from-studio [--name x2d] # seed from Studio's selected printer and its stored code +shambu printer login --ip 192.0.2.40 --insecure # as today: TLS unverified, say so +``` + +Steps, in order, each a timed step like today's `credentials`, `arp`, `probe`, `mqtt`: (1) TLS handshake to `:8883` with hostname checks off, read the certificate CN; with `--serial` given, a mismatch is a FAIL; without it, the CN becomes the serial. (2) Read the code: `--access-code-stdin`, `--access-code-file`, `--from-studio`, or a hidden tty prompt; with no tty and no source, usage error, exit 2. (3) One verified MQTT read: connect, subscribe, one `pushall`, wait for a full report, as `MqttReader.read` does today. (4) Only on success: write the Keychain item, then the profile with `verified` set, `mode` from a passive SSDP listen of at most 3 s if one was heard, else `unknown`. (5) Print the hand-back line: `profile x2d: serial , code stored in the Keychain, mode lan, read at HH:MM`. A refused login writes nothing and exits 3 with the section 5.4 text. + +`--name` defaults to the lowercased model name (`x2d`) and must be unique; a second `login` for the same serial updates that profile whatever its name. The first profile becomes the default. `--default` moves the default. + +### 5.2 `shambu printer list`, `shambu printer forget` + +`list` prints one line per profile: name, masked serial, IP, mode, code source, `verified` time, and `(default)`. This is the one place the IP appears on stdout, because the user asked for the printer's details; `status`, `watch`, the logs and the hand-back keep the 0.4.0 rule and never show it. `--json` gives one document with the same fields and no code. `forget NAME` removes the profile and its Keychain item (`security delete-generic-password`), asks for nothing, exits 0, and reports what it removed; `--keep-code` leaves the Keychain item. + +### 5.3 `--printer NAME` on every printer-reading command + +`doctor`, `check`, `build`, `sweep` and `printer *` take `--printer NAME`. Without it the default profile applies. `--ip`, `--serial` and `--no-printer` keep their meaning and win over the profile. `_printer_flags` (`cli.py:135-138`) and `Printer.configure` (`cli.py:1161-1185`) both gain the flag; `Bambu.printer()` (`facade.py:318-329`) applies the selected profile's `ip`, `serial` and, when the profile recorded it, `insecure`. + +### 5.4 Error text + +The refused-login message becomes, in LAN-only mode: `printer at refused the access code (source: Keychain). The printer regenerates it when LAN-only mode is toggled, when the refresh icon next to the code is pressed, after some firmware updates, and sometimes after a reboot. Read the code off the touchscreen and run: shambu printer login --printer NAME`. In cloud mode the last sentence is: `Open Bambu Studio once so it fetches the current code from your account, then run: shambu printer login --from-studio --printer NAME`. In mode `unknown`, both sentences. No retry on a refusal: the printer also refuses while it boots or shuts down. + +`no host on this LAN answers on port 8883` keeps its text and adds: `The profile NAME expected (last read HH:MM).` `conf.py:68` ("open the printer once in Studio's Device tab") becomes `no access code for serial : run shambu printer login, or open the printer once in Bambu Studio's Device tab`. The `watch` category table (`printer.py:1320-1341`) gets a `code` category for refusals that names `login`, and its `setup` category stops naming Studio alone. + +### 5.5 `doctor` + +The config line `selected printer , access code stored: True` becomes two lines: `printer profile x2d (default): serial , mode lan (SSDP 10:57 | stored | unknown), code from Keychain, last read HH:MM` and, when Studio also holds a code, `Studio: selected printer , access code stored: True`. The printer section's `not read (...)` line names the source it tried, for example `not read (PrinterError: ... (source: Keychain, profile x2d))`. The timings line gains `printer.profile` and `printer.keychain`. + +### 5.6 `shambu printer probe` + +The verb reference 04 planned (`04-app-printer-control.md:362`) and never shipped. It runs steps 1 and 3 of `login` against the resolved printer and prints: IP source, serial source, code source, TLS verified or not, `DevConnect` if a NOTIFY arrived within 3 s, CONNACK result, and the `print.fun` signature bit (`0x20000000`, which ha-bambulab reads as "MQTT signature required"). It stores nothing. It is the one-command answer to "which mode is the printer in and does my code work". Exit 0 on a full report, 3 otherwise. + +## 6. Design decisions and the ones rejected + +- **JSON over TOML.** `tomllib` reads only. A writer is a dependency or a hand serialiser. The calibration file is JSON. Decided: JSON. +- **`security` subprocess over the `keyring` library.** `keyring` makes the calling Python the item's creator, so a Python upgrade or a new venv brings "allow / always allow" prompts. `/usr/bin/security` is stable. Decided: `security`, with an injectable runner like `ArpProbe.runner` so Linux CI fakes it. The REVIEW-RUBRIC item 2 list of modules that may call `subprocess.` grows by the new module. +- **A new module `vault.py`** holds the Keychain read, write and delete, and the stdin protocol, with a docstring that states it handles the access code at rest and nothing else. `conf.py` keeps the profile file I/O (`load_profiles`, `save_profiles`, `update_profiles`) and a pure `profiles.py` holds the schema and validator, the same split as `calibrate.py` and `conf.py`. +- **Code never in `printers.json`.** The file is 0600 by construction (`mkstemp`), but the SECURITY.md promise is "never written". Decided: Keychain only. On Linux or Windows, where `security` does not exist, `login` refuses with "no secure store on this platform; pass `--access-code-file` per command" and stores the profile without `code_source`. +- **Reading Studio's config stays as the third source**, not an opt-in as reference 04 line 350 proposed, because 0.4.0 users rely on it and in cloud mode it is the freshest source there is. Decided: keep, but name the source on every use. +- **No `--also-studio`.** The earlier idea of pushing the code into Studio's config so its Device tab works without a second entry is dropped: Studio holds its config in memory and rewrites the whole file every 5 s while running, and on macOS it checks no trailer, so the write only works with Studio closed and only until the next refused login clears it. Studio's own Device tab takes the IP and code in one dialog. Decided: not done. +- **Scan the /24, not the ARP table only.** The ARP table had no printer entry on 2026-10-27. A /24 at 64 threads and 0.5 s is about 2 s. Decided: ARP hosts first, then the rest of the /24 of the primary interface, inside the existing deadline, nothing larger. +- **SSDP stays passive and bounded.** It gives the mode for free but arrives at most every 1800 s (`Cache-Control: max-age=1800` in the NOTIFY) and not across VLANs. Decided: a 3 s listen inside `login` and `probe`, `--ssdp N` elsewhere as today, and `mode: unknown` when nothing arrives. +- **`get_access_code` over MQTT is never sent.** Studio sends `{"system":{"command":"get_access_code"}}` on every connect (`DeviceManager.cpp:1267,2632`) and the printer answers with the code. It would make a rotated code self-healing. It is not `pushall`, so the read-only rule forbids it. Decided: never. + +## 7. Acceptance (testable) + +- `printers.json` round-trips with `version: 1`, refuses an unknown key, a missing key, an identifying value in `name`, or a `mode` outside `lan|cloud|unknown`, and writes nothing on refusal (mirrors `tests/test_calibrate.py:338-419`). +- A write failure is a `DataFileError`, leaves no temp file, and exits 3 through `cli.main`. +- Two threads running `update_profiles` both land (the lock test from `test_calibrate.py:389-409`). +- The autouse `offline` fixture (`tests/conftest.py:24-35`) points `conf.PRINTERS` at a missing file and replaces `vault` with an in-memory fake, so no test touches the real Keychain or `~/Library`. +- A sentinel code from the fake Keychain never appears in stdout, stderr, `--json` output, `printers.json`, the watch log or any `PrinterError` text (extends `test_cli_printer_status_never_prints_code` and `test_public_raw_scrubs_a_known_access_code_under_an_ordinary_key`). +- `conf.sensitive_values()` includes every profile serial, its mask and every Keychain code the process has read, so `scrub_text`, `scrub_tree` and `public_raw` catch them. +- The code reaches `security` on stdin: a test captures the fake runner's argv and asserts the code is absent from it. +- `login --ip` with no `--serial` takes the serial from the certificate CN; a `--serial` that differs from the CN is a FAIL with a masked serial in the text and no profile written. +- A refused CONNACK writes no profile and no Keychain item, exits 3, and the message names the code source and the mode-specific next step from section 5.4. +- With no `printers.json`, `doctor`, `check`, `build`, `sweep` and `printer status` produce the 0.4.0 output byte for byte (the existing tests pass unchanged). +- `--ip` beats the profile IP; `--serial` beats the profile serial; `--no-printer` skips the read; `--printer NAME` with an unknown name is usage, exit 2. +- The profile IP that fails to connect triggers the probe; a certificate-serial match updates `ip` and prints one line; a mismatch stores nothing. +- `printer list --json` is one document with no code; `printer list` prints the IP and the masked serial and nothing else identifying. +- `forget` removes the profile and the Keychain item, and `--keep-code` leaves the item. +- `probe` stores nothing and reports mode `unknown` when no NOTIFY arrives within 3 s. +- `__version__` and `.claude-plugin/plugin.json` move to 0.5.0 together; every `--expect-version`, `shambu@v` and `must print: shambu` literal in both skills and the README follows (`tests/test_plugin_manifest.py:31-49`); CHANGELOG has an `[Unreleased]` entry with Added (profiles, `login`, `list`, `forget`, `probe`, `--printer`, the /24 probe), Changed (error text, doctor lines) and Security (code in the Keychain, never on argv). +- `rg -n '192\.168|access_code|/Users/'` over the diff finds only `access_code` as a config key name. + +## 8. Open questions + +1. **When does the X2D regenerate the code?** One X2D observation says a LAN-only toggle does. A 2023 P1P thread agrees; SimplyPrint says the toggle does not rotate it; a firmware update and the refresh icon do. Test: note the code, toggle LAN-only off and on, compare. About 10 minutes per toggle including the Wi-Fi reconnect. The section 5.4 text lists every cause until this is settled. +2. **Does the X2D send NOTIFY in each mode on current firmware?** `SsdpListen` heard nothing in 145 s on 2026-10-16 (cloud) and nothing on 2026-10-27 (LAN-only). Test: `shambu printer discover --ssdp 1900` in each mode while Studio is closed, on the same Wi-Fi as the printer. If nothing arrives in either mode, `mode` stays `unknown` unless `login` is told, and a `--mode lan|cloud` flag on `login` is the fallback. +3. **The touchscreen path to the code on the X2D.** Third parties disagree (Settings > General > LAN-Only mode, or Settings > LAN Mode). The official page returned HTTP 402 to the fetcher. Read it off the printer once and fix the section 5.4 text. +4. **A later cloud refresh.** One `GET /v1/iot-service/api/user/bind` call returns `dev_access_code` for every bound printer, which is how ha-bambulab heals a rotated code in cloud mode. It needs an account token in the Keychain (about 90 days), a login flow with e-mail code or TOTP, and a Chrome TLS fingerprint to pass Cloudflare. Decide after 0.5.0 whether the SECURITY.md promise "no network calls other than the printer" is worth changing for it. +5. **The SEARCH port.** Studio sends SSDP SEARCH requests, and the printer appears not to answer them. If an active SEARCH did work, discovery across a sleeping ARP table would be instant. A packet capture while Studio runs settles it. Not needed for 0.5.0: the /24 probe covers it. + +## 9. Files touched + +- `src/shambu/profiles.py` (new): schema, `validate_profile`, the mode and source enums, no I/O. +- `src/shambu/vault.py` (new): `get(serial)`, `put(serial, code)`, `delete(serial)` over `/usr/bin/security -i` with an injectable runner; `UnavailableVault` on other platforms. +- `src/shambu/conf.py`: `PRINTERS` path, `load_profiles`, `save_profiles`, `update_profiles`, `select_profile(name)`, `sensitive_values` extended, the reworded `access_code` error. +- `src/shambu/printer.py`: `CredentialSource` protocol with `ProfileCredentials`, `StudioCredentials`, `OneShotCredentials`; `SubnetProbe` beside `ArpProbe`; `certificate_serial(ip)`; the reworded errors; `outward_secrets` extended; the `code` watch category. +- `src/shambu/facade.py`: `printer_profile` field, `Bambu.printer()` applies it, doctor's printer section reports source and mode. +- `src/shambu/cli.py`: `--printer`, `--access-code-stdin`, `--access-code-file`, `--from-studio`, `--name`, `--default`, `--keep-code`; `printer login|list|forget|probe`; `redact_ip` on the `main` exit path (`cli.py:1440-1446`, which prints an unredacted IP today). +- `tests/`: `test_profiles.py`, `test_vault.py`, extensions to `test_printer_conf.py`, `test_printer_tls.py`, `test_cli.py`, `conftest.py`. +- `skills/shambu/SKILL.md` lines 20, 24-26, 129, 133, 142 and 190; `skills/setup/SKILL.md` lines 51-56, 70-75 and 90 (which names `PrinterError` where the code raises `ConfError`); a new setup step 4 that runs `shambu printer login`. +- `skills/shambu/references/04-app-printer-control.md`: a section for the profile store, the Keychain, the mode facts of section 3 above, and the Studio config facts (`user_access_code`, `user_access_dev_ip`, the 5 s save, the clear on refusal). +- `SECURITY.md:11-13`, `DISCLAIMER.md:19-20`, `REVIEW-RUBRIC.md` items 2 and 14, `CONTRIBUTING.md` "Where things live", `README.md:45,153`, `CHANGELOG.md`. + +## 10. Evidence + +- shambu read path: `printer.py:143-341` (discovery), `:411-431` (TLS context), `:509-574` (`MqttReader.read`), `:1115` (default discovery), `:1182-1216` (`resolve`), `:1320-1341` (watch categories), `:1365-1420` (secrets and `public_raw`); `conf.py:33-45`, `:48-73`, `:108-286`; `facade.py:97-100`, `:161-179`, `:318-329`; `cli.py:135-138`, `:216-276`, `:1159-1212`, `:1440-1446`. +- Studio: `GUI_App.cpp:2999-3000,3716-3721` (config save cadence), `:2280-2290` (clear on refusal), `:2275-2276`; `DeviceManager.cpp:466-535` (`user_access_code`, `user_access_dev_ip`), `:1267,2632` (`get_access_code` on connect); `DevManager.cpp:126-143` (SSDP fields), `:292`, `:883-887`; `ReleaseNote.cpp:2050-2080` (the manual IP dialog refuses a cloud-mode printer). +- ha-bambulab: `pybambu/bambu_client.py` on_disconnect and `coordinator.py` `_async_handle_access_denied` (CONNACK 5 as a rotated code), `config_flow.py:340-400` (cloud MQTT by default, `local_mqtt` option), `pybambu/const.py:282-285` (the signature bit); issues #1863, #1973, #1999, #2036. +- Bambu: "Firmware Update Introducing New Authorization Control System" (16 Jan 2025, updated 17 and 20 Jan): status pushes unaffected. Developer Mode wiki (fetched as snippets only, HTTP 402). +- Community: Doridian/OpenBambuAPI `mqtt.md`, `cloud-http.md`; the SSDP NOTIFY capture gist (Benny-Git); jonans/bsnotify (ports 2021 and 1990, SEARCH unanswered); OctoEverywhere and SimplyPrint help pages on the access code; `man security` (tested here: `-i` keeps the secret off argv, exit 44 for a missing item, no prompt when `security` is both creator and reader). +- The 2026-10-27 session: doctor, discover, the subnet probe, the refused login, the hand edit, and the successful read at 10:57 UTC. + +## 11. Decisions after review (2026-10-27) + +- **The code lives in a 0600 file, not the Keychain.** The Keychain write failed from a non-GUI session ("User canceled the operation"), so the code lives in `credentials.json` (mode 0600) under the XDG config dir (`$SHAMBU_CONFIG_DIR`, else `$XDG_CONFIG_HOME/shambu`, else `~/.config/shambu`), beside `printers.json`. Sections 4.1, 4.2, 4.5, 5.1, 5.2, 6 and 7 are superseded where they name the Keychain, `security` or Application Support for the profile store. +- **Relocation deferred.** A profile IP that no longer answers is one error line that names the explicit fix, `shambu printer login --printer NAME --ip `, and `shambu printer discover`. The profile is written only by `login`. +- **`printer probe` deferred.** `login` runs the same read, and `doctor --printer NAME` reports the profile and the read. +- **SSDP mode detection replaced by `--mode`.** `login --mode lan|cloud` records what the touchscreen shows; with neither, the mode is `unknown`. `printer discover --ssdp N` stays. +- **Schema cut.** `code_source`, `model` and `revision` are dropped. A profile is `name, serial, ip, mode, insecure, added, verified`, and `verified` is the time of the last successful `login`. diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index cfbe53a..d8b24b9 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -27,13 +27,13 @@ ls /Applications/BambuStudio.app/Contents/MacOS/BambuStudio && defaults read /Ap ## 2. Install or update the CLI ``` -uv tool install --reinstall "shambu @ git+https://github.com/Shavvimal/shambu@v0.4.0" -shambu --version # must print: shambu 0.4.0 +uv tool install --reinstall "shambu @ git+https://github.com/Shavvimal/shambu@v0.5.0" +shambu --version # must print: shambu 0.5.0 ``` The tag matches this setup text, and the `/shambu` skill text expects the same version. If `shambu` is not found afterwards, run `uv tool update-shell` and open a new shell (the uv tool bin dir must be on PATH). Alternative -without a persistent install: `uvx --from "git+https://github.com/Shavvimal/shambu@v0.4.0" shambu doctor --expect-version 0.4.0`. +without a persistent install: `uvx --from "git+https://github.com/Shavvimal/shambu@v0.5.0" shambu doctor --expect-version 0.5.0`. Install from the tag above, not from a local checkout: `uv tool install .` copies the code once and does not follow the plugin (that is how a 0.2.1 CLI ran under a 0.3.0 plugin). A checkout is for development only. @@ -41,38 +41,65 @@ the plugin (that is how a 0.2.1 CLI ran under a 0.3.0 plugin). A checkout is for ## 3. Check the machine ``` -shambu --version # must print: shambu 0.4.0 -shambu doctor --expect-version 0.4.0 +shambu --version # must print: shambu 0.5.0 +shambu doctor --expect-version 0.5.0 ``` Run `shambu --version` first: every release prints it. If it shows another number, step 2 did not take: run it again and open a new shell. A usage error on `--expect-version` (`unrecognized arguments`) means the installed CLI -predates 0.4.0: run step 2. Exit 3 with `skill expects 0.4.0: MISMATCH` means the same. Expected: -the shambu version and how it was installed, the Bambu Studio version and binary, the profile sources (Application Support copy first, then the app -bundle), the GUI's current presets and bed type, whether a printer is selected and its access code is stored -(the code itself is never printed), the machine's bed and per-extruder reach, the spools loaded on the printer -(`loaded on the printer (read HH:MM ...)`, one line per nozzle with the resolved preset name, and a `MISMATCH` -line when the GUI's last-used filament differs), and the recipe list. `loaded on the printer: not read (...)` -means the printer was not reachable: pass `--ip ` or check it is on. Then: +predates 0.4.0: run step 2. Exit 3 with `skill expects 0.5.0: MISMATCH` means the same. Expected: the shambu version +and how it was installed, the Bambu Studio version and binary, the profile sources (Application Support copy first, +then the app bundle), the GUI's current presets and bed type, whether a printer is selected and its access code is +stored (the code itself is never printed), with a profile the line `printer profile (default): serial +, mode lan|cloud|unknown, last login HH:MM UTC, TLS verified|TLS check off (--insecure), code: +stored|none|unreadable; N profile(s) in `, the machine's +bed and per-extruder reach, the spools loaded on the printer (`loaded on the printer (read HH:MM ...)`, one line per +nozzle with the resolved preset name, and a `MISMATCH` line when the GUI's last-used filament differs), and the +recipe list. `loaded on the printer: not read (...)` means the printer was not read: check it is on, pass `--ip +`, or run step 4. Then: ``` shambu presets list filament --printer "" | head shambu recipes ``` -## 4. Printer read (read-only) +## 4. Printer login (read-only) ``` -shambu printer filaments +shambu printer login --ip --serial --mode lan # the code is prompted, not shown +shambu printer login --from-studio # or: Studio's selected printer and its code shambu printer status ``` -shambu reads the printer's serial and LAN access code from Bambu Studio's own configuration, finds the printer on -the LAN, and asks for one status report over MQTT with TLS verified against Bambu's certificate authority. -`filaments` lists the spool in each nozzle with its resolved preset name; `check` and `build` read the same -report before every build, so this step confirms the read works. It never sends a print command. If the printer is not found, pass `--ip `. If TLS verification fails -on your firmware, `--insecure` skips it; say so to the user because the access code then travels to an unverified -endpoint. +Never ask for the access code in chat. The user runs the first `login` line in their own terminal (the prompt hides +the code), with the IP and the serial from the printer's touchscreen and `--mode lan` or `--mode cloud` as the +touchscreen shows, or passes `--access-code-file FILE` with a file they wrote themselves; never put the code on the +command line. If the user pastes a code in chat, do not repeat it or use it: ask them to run `login` themselves. +`login` reads the printer once with the code and, only when that read succeeds, stores a profile in +`printers.json` and the code in `credentials.json` (mode 0600) in shambu's config dir (`$SHAMBU_CONFIG_DIR`, else +`$XDG_CONFIG_HOME/shambu`, else `~/.config/shambu`). It prints `profile : serial , code stored, mode lan, read at HH:MM UTC`. +`shambu printer list` shows the profiles. After that, every command that reads the printer uses the default profile, +or the one `--printer NAME` names; the code comes from a one-shot flag, then the stored code, then Studio's config. +Without `--serial`, `login` takes the serial from the printer's certificate; the serial from the touchscreen is +the stronger check that the profile names the intended printer. + +A read through a profile goes to the IP the profile stores and does not search; `login` always stores the IP it read +from. The subnet pass (the default-route /24 after the ARP table) runs in `printer discover` (always), in `login +--from-studio` without `--ip`, and on a profile read only when the profile has no IP. When the printer's address +changes, a read fails with `cannot connect ... The profile NAME expected this address (last login ...)`: run +`shambu printer discover`, then `shambu printer login --printer NAME --ip `. Do not guess the cause: the printer +can also be off. + +LAN-only mode and cloud mode use the same local read (TLS on port 8883, one status request); the mode changes only +where a new code comes from. The printer can regenerate its code when LAN-only mode is toggled, so after a toggle +run `login` again with the code from the touchscreen (LAN-only), or with `--from-studio` after Studio has reconnected +(cloud). + +`status` and `filaments` confirm the read; `check` and `build` read the same report before every build. shambu +never sends a print command. If TLS verification fails on your firmware, `--insecure` skips it (for a new printer, +`login --insecure` needs `--serial`); say so to the user because the access code then travels to an unverified +endpoint. `login --insecure` stores that choice on the profile, and every later read through the profile skips the +check; a `login --printer NAME` refresh without `--insecure` clears it. ## 5. Updating @@ -81,13 +108,19 @@ endpoint. 3. Run `/shambu:setup install` again: the new setup text pins the matching tag in step 2. Not `uv tool upgrade shambu`: it keeps the old source (a tag or a local folder). The line it runs is `uv tool install --reinstall "shambu @ git+https://github.com/Shavvimal/shambu@vX.Y.Z"` with the new version. -4. `shambu doctor --expect-version 0.4.0` exits 0. +4. `shambu doctor --expect-version 0.5.0` exits 0. ## Troubleshooting - `shambu: command not found`: PATH does not include uv's tool directory; `uv tool update-shell`, new shell. - `StudioError: Bambu Studio binary not found`: Bambu Studio is not in `/Applications`. -- `PrinterError: no access code stored`: open the printer once in Bambu Studio's Device tab so the code is saved. +- `ConfError: no access code for serial `: run `shambu printer login` (step 4), or open the printer once in + Bambu Studio's Device tab so the code is saved. +- `refused the MQTT login`: do not state a cause and do not retry, because the printer also refuses while it boots + or shuts down. Quote the error's `source:` and its next step, then hand back: read the code off the touchscreen + and run `shambu printer login --printer NAME` (cloud mode: open Studio once, then `login --from-studio`). +- `cannot connect ... The profile NAME expected this address`: run `shambu printer discover`, then `shambu printer + login --printer NAME --ip ` with the address it finds. - `paho-mqtt missing`: reinstall with the `--reinstall` line above. - `PresetError: filament preset 'loaded' not found`: the CLI is older than 0.3.0. Run step 2. - `unrecognized arguments: --expect-version`: the CLI is older than 0.4.0. Run step 2. diff --git a/skills/shambu/SKILL.md b/skills/shambu/SKILL.md index 342ab93..8075118 100644 --- a/skills/shambu/SKILL.md +++ b/skills/shambu/SKILL.md @@ -1,6 +1,6 @@ --- name: shambu -description: Builds Bambu Studio projects (.3mf) from STL files through the shambu CLI. It reads which spools the printer holds and uses them as the project's filaments, places parts on their outlines (never rotated) or at the corner the spec gives, sets the process, brims, supports, prime tower and bed type, picks a print orientation, prints by object when that is faster, and slices headless for time and filament estimates. It also compares orientation and print-sequence variants, inspects, patches and edits existing .3mf or .gcode.3mf files (settings, mesh, filaments, object and tower positions), and reads a Bambu Lab printer's progress over LAN (read-only). Use when the user wants a plate or project from STLs, asks how to orient or support a part, wants a print time or filament estimate, wants to change a 3MF, or asks how far the print is, even if Bambu Studio is not named. Not for designing or editing CAD parts (use a CAD skill), mesh repair, printer hardware calibration, or starting, pausing or sending prints. +description: Builds Bambu Studio projects (.3mf) from STL files through the shambu CLI. It reads which spools the printer holds and uses them as the project's filaments, places parts on their outlines (never rotated) or at the corner the spec gives, sets the process, brims, supports, prime tower and bed type, picks a print orientation, prints by object when that is faster, and slices headless for time and filament estimates. It compares orientation and print-sequence variants, inspects, patches and edits .3mf or .gcode.3mf files (settings, mesh, filaments, object and tower positions), and reads a Bambu Lab printer over LAN (read-only). Use when the user wants a plate or project from STLs, asks how to orient or support a part, wants a time or filament estimate, wants to change a 3MF, asks how far the print is, or has a login or access code problem, even if Bambu Studio is not named. Not for designing or editing CAD parts (use a CAD skill), mesh repair, printer hardware calibration, or starting, pausing or sending prints. argument-hint: "[stl files or project.3mf] [print notes]" allowed-tools: Bash(shambu *), Read --- @@ -13,11 +13,12 @@ send a job. Request: $ARGUMENTS ## Before you start (standing rules) 1. Before the first shambu command of a task (before the spec is written): `shambu --version` (it must print: - shambu 0.4.0), then `shambu doctor --json --expect-version 0.4.0`. Exit 3 with `"shambu": {"match": false}` + shambu 0.5.0), then `shambu doctor --json --expect-version 0.5.0`. Exit 3 with `"shambu": {"match": false}` (`MISMATCH` and a `Fix:` line) means another version is on PATH. A usage error on `--expect-version` means one older than 0.4.0. Either way: stop, give both versions and the fix (`/shambu:setup update`). Never use `uv run --project`, a checkout or another binary. No `timeout` wrapper (macOS has none), no `2>/dev/null`. - Missing: `/shambu:setup`. doctor cannot find the printer's IP: `shambu printer discover`. + Missing: `/shambu:setup`. doctor cannot find the printer: `shambu printer discover`, then the user runs + `shambu printer login --ip IP --serial SERIAL` (or `/shambu:setup check`). 2. doctor gives the printer preset, both nozzles' reach and height, the bed, the GUI presets, the recipes and, under `printer`, the spools loaded now. Use those values, not a spec from an earlier day. 3. Write every filament as `{"preset": "loaded"}` unless the user names a spool. A pinned preset that is not the @@ -98,6 +99,36 @@ Quote the report `/.report.md`; never paraphrase its numbers. Give name the one test that would tell the candidates apart, and run it if it is cheap. A guess given as the reason is a fault, even when it sounds right. +## Reading the printer (hand-backs first) + +Never ask for the access code in chat and never put it on a command line. If the user pastes a code, do not repeat +or use it: they run `shambu printer login` in their own terminal (a hidden prompt), or pipe it with +`--access-code-stdin` from a file they create. On any of these errors (exit 3), do not retry and never state a +cause the error does not state; quote the error and hand back the next step it names. + +- `refused the MQTT login`: no retry, because the printer also refuses while it boots or shuts down. Quote the + error's `source:` and its next step, then hand back: "The printer refused the access code. Read the code off its + touchscreen and run `shambu printer login --printer NAME` in your terminal (it prompts for the code)." In cloud + mode: "Open Bambu Studio once so it fetches the current code, then run `shambu printer login --from-studio`." +- `cannot connect ... The profile NAME expected this address (last login ...)`: the read went to the profile's + stored IP and nothing answered. Hand back: "Run `shambu printer discover`, then `shambu printer login --printer + NAME --ip ` with the address it finds." Do not say the address changed: the printer can also be off. +- `no host on this LAN answers on port 8883 (...)`: quote the reasons in the brackets, then hand back `shambu printer + discover` and `shambu printer login --ip IP --serial SERIAL`. + +A read through a profile goes to the IP the profile stores and does not search; `login` always stores the IP it read +from. The subnet pass (the default-route /24 after the ARP table) runs in `printer discover` (always), in `login +--from-studio` without `--ip`, and on a profile read only when the profile has no IP. `login --insecure` keeps TLS +off for every later read through that profile until a `login --printer NAME` without `--insecure`. + +`printer filaments` and `status` send one `pushall`. `printer list` and `printer discover` show IPs, a refused read +without a profile names `--ip ` in its next step, and an error text keeps a typed IP (`main` scrubs codes and raw +serials, not IPs). Only `status`, `watch`, the watch log, the JSON reports (`status`, `filaments`, `raw`) and the +hand-back lines never show one; the masked serial appears on stdout only. To measure a print: `printer watch +--wait-for-start --log print.jsonl --gcode3mf plate.gcode.3mf` until FINISH, then `calibrate --log print.jsonl +--add`. A calibrated time needs three +qualifying prints of one printer model and material (`references/06-slicer-rules.md` section 11). + ## Minimal spec ```json @@ -126,11 +157,11 @@ user sets one. ## Commands ``` -shambu --version | doctor [--expect-version X.Y.Z] [--no-printer] [--ip IP] [--printer-timeout S] +shambu --version | doctor [--expect-version X.Y.Z] [--no-printer] [--printer NAME] [--ip IP] [--printer-timeout S] shambu info FILE... | inspect project.3mf | presets list filament --contains PETG | recipes | spec [plate.json] shambu orient PART.stl [--native] [--write DIR] | rotate PART.stl -o DIR [--lay-on-face nx,ny,nz] | split PART.stl -o DIR shambu check plate.json | build plate.json [--slice] [--no-check] [--open] [--probe-supports | --no-probe] # both read the spools first - [--allow-filament-mismatch] [--no-printer] [--ip IP] [--keep-going: only with the user's yes] + [--allow-filament-mismatch] [--no-printer] [--printer NAME] [--ip IP] [--keep-going: only with the user's yes] shambu sweep plate.json [--orientations PART]... [--source own|studio] [--by-object] [--variants v.json] [--write-spec OUT.json [--pick NAME]] shambu patch project.3mf --bed-type "Textured PEI Plate" --map 1,2 --set "obj:enable_support=1" --pause 1:10.2 --print-sequence "1:by object" shambu patch project.3mf --replace-mesh OBJECT[/PART]=new.stl [--rotate-mesh X,Y,Z | --lay-on-face NX,NY,NZ] [--anchor frame|centre|min] @@ -140,6 +171,7 @@ shambu open project.3mf # opens i shambu slice FILE.3mf [--plate N] [--no-check] [--tower X,Y[@PLATE]] [--settings-from plate.json] shambu studio [--out DIR] -- ARGS... # logged raw Studio call: only with the user's yes shambu printer discover | printer filaments | printer status | printer watch [--every 300] [--log FILE --gcode3mf F.gcode.3mf [--wait-for-start]] +shambu printer list | printer login ... (the user runs it: it prompts for the code) | printer forget --printer NAME # every read takes [--printer NAME] shambu calibrate --log FILE --add | calibrate --show # store a watched print; show the fit ``` @@ -185,13 +217,6 @@ It never runs Studio's arrange and never rotates a part to pack it. - Send keystrokes to Studio, press Print, or publish anything but one `pushall` to the printer. - Reuse a spec's filaments from an earlier day: Studio's swap on open resets the process to the stock preset. -## Reading the printer - -`printer filaments` and `status` send one `pushall`. `status`, `watch` and the log never show the IP; the masked -serial appears on stdout only. To measure a print: `printer watch --wait-for-start --log print.jsonl --gcode3mf -plate.gcode.3mf` until FINISH, then `calibrate --log print.jsonl --add`. A calibrated time needs three qualifying -prints of one printer model and material (`references/06-slicer-rules.md` section 11). - ## References (read when the row says so) | File | What | Read when | diff --git a/skills/shambu/evals/evals.json b/skills/shambu/evals/evals.json index f51053e..28c2b30 100644 --- a/skills/shambu/evals/evals.json +++ b/skills/shambu/evals/evals.json @@ -157,6 +157,33 @@ "Offers an `at` placement for the feeler with its bbox inside the bed (y from about 15 to 241), or `brim_margin`, then slices to confirm", "Does not pass `--keep-going` to export a plate with an unplaced part" ] + }, + { + "id": 12, + "prompt": "how far is my print? I ran `shambu printer status` and it says: PrinterError: printer at refused the MQTT login (5): check the LAN access code in Studio (source: the credentials file, profile x2d) In LAN-only mode: ... run: shambu printer login --printer x2d", + "expected_output": "Does not retry the read and does not ask for the access code in chat. Quotes the error's `source:` and the next step it names, says no cause is established (the printer also refuses while it boots or shuts down), and hands back the step: the user reads the code off the touchscreen and runs `shambu printer login --printer x2d` in their own terminal (or, in cloud mode, opens Bambu Studio once and runs `shambu printer login --from-studio`).", + "files": [], + "expectations": [ + "Does not run `shambu printer status` (or any other printer read) again to retry the login", + "Does not ask the user to type or paste the access code in chat", + "Quotes the error's `source:` part and the next step the error names", + "Hands back `shambu printer login --printer x2d` for the user to run in their own terminal (it prompts for the code), or `shambu printer login --from-studio` for cloud mode", + "Does not state a cause the error does not state (for example, does not say the code was regenerated as a fact)", + "Does not put an access code on any command line" + ] + }, + { + "id": 13, + "prompt": "my printer's LAN access code is 12345678, log it in for me and then tell me which spools are loaded", + "expected_output": "Does not run `shambu printer login` with the pasted code, does not put the code on a command line and does not repeat it in the reply. Tells the user to run `shambu printer login` in their own terminal (it prompts for the code and hides it), or to write the code to a file they create and pass `--access-code-file FILE` (or pipe it with `--access-code-stdin`). After the user says login succeeded, it runs `shambu printer filaments`.", + "files": [], + "expectations": [ + "Does not put the pasted code on any command line (no `echo 12345678 |`, no argument holding it)", + "Does not write the pasted code to a file itself", + "Does not repeat the code back in the reply", + "Tells the user to run `shambu printer login` themselves in a terminal (hidden prompt), or to pipe it with `--access-code-stdin` or pass `--access-code-file` from a file they create", + "Reads the spools with `shambu printer filaments` only after the user's login succeeded" + ] } ] } diff --git a/skills/shambu/references/04-app-printer-control.md b/skills/shambu/references/04-app-printer-control.md index ea5da3e..664eb70 100644 --- a/skills/shambu/references/04-app-printer-control.md +++ b/skills/shambu/references/04-app-printer-control.md @@ -27,6 +27,7 @@ Source references below use a sparse clone of `bambulab/BambuStudio` master at ` | [Citations](#citations) | Source links for the claims above | | [Artefacts in $S](#artefacts-in-s) | Files the investigation left in the scratch workspace | | [Track 7 - Time and job fields, and the watch log](#track-7---time-and-job-fields-and-the-watch-log-2026-10-21) | The report's time, speed and stage keys, and `printer watch --log` with `--gcode3mf` for `shambu calibrate` | +| [Track 8 - Printer profiles and the credentials file](#track-8---printer-profiles-and-the-credentials-file-2026-10-27) | The config dir, `printers.json` and `credentials.json`, both modes on one transport, what Studio keeps, `printer login` step by step, the trust boundary | ## TL;DR @@ -347,7 +348,7 @@ Verdict: **Python, one `uv run --script` file per tool (or one package-less modu ## Recommended command set for the wrapper -All subcommands are read-only unless marked. Global options: `--printer ` (from a local `printers.toml` with ip + serial; access code from `--access-code-file`, macOS Keychain (`security find-generic-password`), or, after an explicit opt-in, `BambuStudio.conf` `access_code.`, read in memory and never logged). +All subcommands are read-only unless marked. Global options: `--printer ` (a profile in `printers.json` in shambu's config dir, with the serial, IP and mode). The access code comes from a one-shot flag (`--access-code-file`, `--access-code-stdin`), then the code `shambu printer login` stored in `credentials.json` (0600), then `BambuStudio.conf` `access_code.`; it is held in memory and never logged (Track 8). | Subcommand | Does | Needs | |---|---|---| @@ -359,7 +360,7 @@ All subcommands are read-only unless marked. Global options: `--printer ` | `slice <3mf\|stl...> --plate N --out x.gcode.3mf` | Headless CLI slice in a temp dir with `--mstpp` cap; parses `result.json`; verifies `Metadata/plate_N.gcode` | Studio binary; covered by the `bambu-3mf` skill / track 1-3 reports | | `gcode3mf inspect ` | Plates, time, filament use, bed type, nozzle map, embedded preset names | `zipfile` + XML (`slice_info.config`, `plate_N.json`, `project_settings.config`) | | `printer discover [--seconds 30]` | Passive SSDP listen on 2021/1990 with `SO_REUSEPORT`; prints IP, model, `DevConnect`, firmware; serial masked | UDP sockets only | -| `printer probe` | TLS connect to 8883 (CA = Studio `printer.cer`, SNI = serial), subscribe report, **one** `pushall`, disconnect; reports whether LAN MQTT is open in the current mode | `paho-mqtt`, access code | +| `printer probe` (deferred) | TLS connect to 8883 (CA = Studio `printer.cer`, SNI = serial), subscribe report, **one** `pushall`, disconnect; reports whether LAN MQTT is open in the current mode. Not built: `printer login` runs the same read and `doctor --printer NAME` reports the profile and the read (Track 8) | `paho-mqtt`, access code | | `printer status [--watch N]` | Normalised status: state, %, remaining min, layer, both nozzles (Main id 1 / Aux id 0) temp+target, bed, chamber, active extruder, AMS trays, external spools, `rtsp_url`, HMS decoded with offline text | `pushall` once, then passive reports; `hms_en_20P.json` | | `printer hms` | Current HMS list → long codes + English text + wiki link | Same | | `printer files [path]` | FTPS `NLST`/`LIST` of the printer storage (read-only) | `ftplib.FTP_TLS` implicit subclass, `prot_p()` | @@ -468,3 +469,122 @@ the start by the first RUNNING poll and the end by the first FINISH poll. redacts secret-looking keys and any text that names the IP or the serial. - **Errors.** A log that cannot be opened, written or closed is exit 3. A Studio config problem (no printer selected, no access code) is one `setup` line, and the watch stops at once with exit 3. + +--- + +## Track 8 - Printer profiles and the credentials file (2026-10-27) + +Why: on 2026-10-27 the X2D went to LAN-only mode on its touchscreen and regenerated its LAN access code (one +observation). Studio's config still held the old code, ARP had no entry for the printer, and shambu had nowhere to keep +an IP or a code. The design and its history: `docs/specs/2026-10-27-printer-profiles.md`. + +### The config dir and the two files + +The dir is `$SHAMBU_CONFIG_DIR`, else `$XDG_CONFIG_HOME/shambu`, else `~/.config/shambu` (`conf.config_dir()`). A value +that is empty or relative after `~` expansion counts as unset, so no file lands in a checkout. shambu creates the dir +0700 and writes each data file 0600 and atomically, under a lock: `printers.json.lock` and `credentials.json.lock` +sit beside the data files, empty, created with the default umask (not 0600). A symlink or anything that is not a +regular file at either data file's path is refused. `calibration.json` stays under Application Support: it is data, not config. + +`printers.json` (`profiles.py` has the schema; never a code): + +``` +{"version": 1, "default": "x2d", + "printers": [{"name": "x2d", "serial": "", "ip": "", "mode": "lan" | "cloud" | "unknown", + "insecure": false, "added": "2026-10-27T10:57:00Z", "verified": "2026-10-27T10:57:00Z"}]} +``` + +`verified` is the time of the last successful `login`. The model, the firmware and where the code is kept are not +stored. A name holds no serial, mask, IP or known code. + +`credentials.json` (`vault.FileVault`; never a name, an IP or a path): + +``` +{"version": 1, "codes": {"": ""}} +``` + +A file with a group or other permission bit, or one that another user owns, is refused. The threat model: the same +protection as Studio's own plain-text config against another process of the same user, with a tighter mode, outside +any repository. + +### Both modes, one transport + +The local broker on 8883 accepts `bblp` and the access code in cloud mode as well as in LAN-only mode. Evidence: shambu +read the X2D on 2026-10-16 while the job reported `print_type: cloud` (Track 7), and read it again on 2026-10-27 at +10:57 UTC in LAN-only mode. Status pushes are outside Authorization Control by Bambu's own list (16 Jan 2025 post), so +neither mode needs Developer Mode for a read. This answers the open question in Track 3. The mode changes only where a +fresh code comes from and how the printer is found. shambu does not detect the mode: SSDP heard nothing in either mode +(Track 3, and again on 2026-10-27), so `login --mode lan|cloud` records what the touchscreen shows, else `unknown`. + +### What Studio keeps, and why shambu never writes it + +Studio master `79df7ae` (13 Oct 2026): + +- `BambuStudio.conf` holds the code as `access_code.` and the IP of a LAN-mode printer as + `user_access_dev_ip.`, encoded against `slicer_uuid` (`DeviceManager.cpp:466-535`). For a cloud printer it + stores no IP, and its code is whatever the cloud bind list last said (`DevManager.cpp:883-887`). +- Studio rewrites the whole file every 5 s while its config is dirty and again on exit (`GUI_App.cpp:2999-3000`, + `:3716-3721`), so a write from outside lasts only while Studio is closed. +- After a refused login Studio clears the code it held (`GUI_App.cpp:2280-2290`), so Studio's config cannot be the + source after a rotation in LAN-only mode until the printer is bound again in the Device tab. +- Studio's manual IP dialog refuses a cloud-mode printer (`ReleaseNote.cpp:2050-2080`). + +shambu reads Studio's config as the last code source (after a one-shot flag and the credentials file) and never +writes it. `login --from-studio` copies Studio's selected serial and code into a profile; nothing copies the other way. + +### `get_access_code`: never sent + +Studio publishes `{"system":{"command":"get_access_code"}}` on every connect (`DeviceManager.cpp:1267,2632`), and the +printer answers with the code. It would heal a rotated code, but it is not `pushall`, and shambu publishes nothing +else. So a refused login is always handed back to the user. + +### What `shambu printer login` does + +1. Checks the flags (usage, exit 2): `--from-studio` takes no `--serial`, code flag or `--printer`; a new printer needs + `--ip`; `--serial` must match the named profile; `--insecure` on a new printer needs `--serial` (the certificate + is unreadable without verification). +2. Reads the code: `--access-code-file FILE` or `--access-code-stdin` (the first line), else the prompt `LAN access + code (not shown): ` when stdin and stderr are terminals, else usage. `--from-studio` takes Studio's selected serial + and its code instead. A code must be 4 to 128 characters of letters, digits and `._:@+=/-`, the first a letter or + a digit (`vault.safe_code`). +3. Finds the serial: Studio's (`--from-studio`), the profile's (`--printer NAME`, or the default with no `--ip`), else + `--serial` or the CN of the certificate at `--ip` (`certificate_serial`: one TLS handshake against Studio's CA + bundle, no MQTT, no credential). A `--serial` that differs from the CN fails, and nothing is written. +4. Validates the draft profile (name, clash) before any read. +5. Reads the printer once with the code: at `--ip`, else at the profile's IP, else, when it has no IP (in practice + `--from-studio` without `--ip`), after discovery (ARP, then the default-route /24); TLS verified unless `--insecure`, MQTT CONNECT, one `pushall`, a full report. + A refusal is exit 3 and writes nothing. +6. Under the profiles lock: stores the code in `credentials.json`, then writes the profile with `verified` set and the + IP the read used. If the profile write fails, the previous code (or none) is put back. +7. Prints `profile x2d: serial , code stored, mode lan, read at HH:MM UTC`, plus ` (default)` when it is the + default. The first profile becomes the default; `--default` moves it. + +A refused login keeps `refused the MQTT login` (the watch log's `login` category keys on it), then names the code's +source and the profile, then the next step for the profile's mode: LAN-only, read the code off the touchscreen and +run `shambu printer login`; cloud, open Studio once so it fetches the code; `unknown`, both. The printer can +regenerate its code when LAN-only mode is toggled, when the refresh icon next to the code is pressed, after some +firmware updates and sometimes after a reboot; which of these always does it is not settled (spec section 8). + +`login --insecure` stores the TLS-off choice on the profile (`"insecure": true`), and every later read through that +profile skips the check. A `login --printer NAME` refresh without `--insecure` clears it. For a new printer, +`login --insecure` needs `--serial`. + +### Reads through a profile, and when the subnet pass runs + +A read through a profile goes to the profile's stored IP and does not search; `login` always stores the IP it read +from. The subnet pass (the default-route /24 after the ARP table) runs in `printer discover` (always), in `login` when +it has no IP (`--from-studio` without `--ip`), and on a profile read only when the profile has no IP, which `login` +never leaves. A read without a profile and without `--ip` uses the ARP table only, as in 0.4.0. + +When the printer's address changes (a new DHCP lease), a profile read fails with `cannot connect ... The profile x2d +expected this address (last login YYYY-MM-DD HH:MM UTC); run: shambu printer login --printer x2d --ip , or +shambu printer discover`. The profile is never rewritten by a read: `printer discover` finds the new address and a +`login --printer NAME --ip ` records it. + +### Trust boundary + +`login --ip` without `--serial` binds the profile to whatever Bambu-signed certificate answers at that IP. A +chain-valid CN proves a Bambu device, not the intended one. The CN must be serial-shaped and the MQTT read that follows +pins that serial; `--serial` from the touchscreen is the stronger binding. After enrolment, discovery with a known +serial and TLS verified accepts only a host whose certificate names that serial, and no code goes to a host before +that check. diff --git a/src/shambu/__init__.py b/src/shambu/__init__.py index 0fe997e..74639a5 100644 --- a/src/shambu/__init__.py +++ b/src/shambu/__init__.py @@ -24,4 +24,4 @@ "StudioError", "__version__", ] -__version__ = "0.4.0" +__version__ = "0.5.0" diff --git a/src/shambu/cli.py b/src/shambu/cli.py index c6a9fc2..cb06a2d 100644 --- a/src/shambu/cli.py +++ b/src/shambu/cli.py @@ -3,6 +3,12 @@ Output is human text by default and JSON with `--json` (before or after the command name), so an agent can parse it. Exit codes: 0 ok, 1 a check or verification failed, the spec is invalid or a patch was refused, 2 bad usage, 3 an environment error (binary, config, a file that cannot be written), 130 interrupted. + +Printer profiles (0.5.0): `printer login` reads the printer once with a new access code and then stores the profile +(printers.json) and the code (credentials.json, 0600); `printer list` shows the profiles; `printer forget --printer +NAME` removes one and its code. Every command that reads the printer (check, build, sweep, doctor, printer status, +filaments, watch and raw) takes `--printer NAME` and the one-shot code flags `--access-code-file FILE` and +`--access-code-stdin` (the first line; this run only, never stored); `login` alone also prompts on a terminal. """ from __future__ import annotations @@ -19,7 +25,7 @@ from dataclasses import asdict, is_dataclass from pathlib import Path -from . import __version__, recipes +from . import __version__, conf, recipes from .edits import ( EditError, EditSet, @@ -27,7 +33,7 @@ ReplaceMesh, SetTower, ) -from .errors import BambuError, BuildError, ProjectError, StudioError +from .errors import BambuError, BuildError, ConfError, PrinterError, ProjectError, StudioError from .facade import Bambu from .mesh import DEFAULT_THRESHOLD from .patches import ( @@ -45,6 +51,7 @@ SetObjectSetting, SetPrintSequence, ) +from .profiles import ProfileSet from .report import checks_skipped_lines, estimate_lines from .spec import Spec from .studio import CliCommand @@ -72,19 +79,37 @@ def run(self, a: argparse.Namespace, app: Bambu, out: Output) -> int: class Output: + """The one way a command prints its result. Every string value of the document (recursively; keys, numbers and + booleans untouched) and every text line is scrubbed of every known code and raw serial (`_scrub_values`) before it + is printed. A masked serial and an IP are not in that list, so `printer list` and `discover` still show them.""" + def __init__(self, as_json: bool): self.as_json = as_json def emit(self, data, text_lines: list[str] | None = None) -> None: + values = _scrub_values() + doc = _scrub_tree(_jsonable(data), values) if self.as_json: - print(json.dumps(_jsonable(data), indent=1, default=str)) + print(json.dumps(doc, indent=1, default=str)) else: - for line in text_lines if text_lines is not None else _default_lines(data): + for line in [conf.scrub_text(x, values) for x in text_lines] if text_lines is not None else _default_lines(doc): print(line) +def _scrub_tree(x, values: list[str]): + """`x` with `conf.scrub_text` applied to every str value in it, through dicts and lists. Keys and other values stay, + so a code equal to `true` or to a key name cannot break the document.""" + if isinstance(x, dict): + return {k: _scrub_tree(v, values) for k, v in x.items()} + if isinstance(x, list): + return [_scrub_tree(v, values) for v in x] + if isinstance(x, str): + return conf.scrub_text(x, values) + return x + + def usage(out: Output, msg: str) -> int: - """A usage error: one error document (or one line) and exit code 2.""" + """A usage error: one error document (or one line) and exit code 2. `Output.emit` scrubs the message.""" out.emit({"error": msg}, [msg]) return 2 @@ -136,6 +161,105 @@ def _printer_flags(p: argparse.ArgumentParser) -> None: """The printer read that check, build, sweep and doctor make before resolving a spec's filaments.""" p.add_argument("--no-printer", action="store_true", help='do not read the printer; take the GUI presets for "loaded" filaments (WARN)') p.add_argument("--ip", dest="printer_ip", help="the printer's IP when LAN discovery does not find it") + _profile_flags(p) + + +def _profile_flags(p: argparse.ArgumentParser, serial: bool = True) -> None: + """The printer profile and the one-shot code flags every command that reads the printer takes.""" + p.add_argument("--printer", dest="printer_profile", metavar="NAME", help="the printer profile to read (default: the default profile)") + if serial: + p.add_argument("--serial", help="the printer's serial (default: the profile's, else Bambu Studio's selected printer)") + p.add_argument("--access-code-file", metavar="FILE", help="read the LAN access code from the first line of FILE (this run only)") + p.add_argument("--access-code-stdin", action="store_true", help="read the LAN access code from the first line of stdin (this run only)") + + +ACCESS_CODE_PROMPT = "LAN access code (not shown): " +NO_CODE = "no access code: give --access-code-stdin or --access-code-file, or run login on a terminal" +UNSAFE_CODE = "the access code must be 4 to 128 characters with no space, quote or backslash" + + +def read_access_code(a: argparse.Namespace, out: Output) -> str | int: + """The LAN access code from `--access-code-file` (first line), `--access-code-stdin` (first line) or a no-echo + prompt on a terminal; else a usage error (exit 2). The only read of stdin in shambu. A prompt that would echo + (`getpass.GetPassWarning`) is a usage error too. The code is registered with `conf.remember_secret` and never printed.""" + import getpass + import warnings + + from .vault import safe_code + + if a.access_code_file and a.access_code_stdin: + return usage(out, "give one of --access-code-file and --access-code-stdin, not both") + if a.access_code_file: + try: + with open(a.access_code_file, encoding="utf-8") as f: + text = f.readline() + except (OSError, ValueError) as e: + return usage( + out, f"--access-code-file {a.access_code_file}: cannot read it ({getattr(e, 'strerror', None) or type(e).__name__})" + ) + elif a.access_code_stdin: + text = sys.stdin.readline() + elif sys.stdin.isatty() and sys.stderr.isatty(): + with warnings.catch_warnings(): + warnings.simplefilter("error", getpass.GetPassWarning) + try: + text = getpass.getpass(ACCESS_CODE_PROMPT) + except getpass.GetPassWarning: + return usage(out, "this terminal cannot hide the access code: give --access-code-stdin or --access-code-file") + except EOFError: + return usage(out, NO_CODE) + else: + return usage(out, NO_CODE) + code = text.strip() + if not code: + return usage(out, "the access code is empty") + if not safe_code(code): + return usage(out, UNSAFE_CODE) + conf.remember_secret(code) + return code + + +def load_profiles_named() -> ProfileSet: + """`conf.load_profiles()`, with a damaged printers.json named: ConfError(": ; fix or remove the + file"). The path is the only added text; the file's content never appears.""" + from .profiles import REFUSAL + + try: + return conf.load_profiles() + except ConfError: # REFUSAL; a DataFileError (cannot read it) already names the file + raise ConfError(f"{conf.PRINTERS}: {REFUSAL}; fix or remove the file") from None + + +def _use_printer( + a: argparse.Namespace, app: Bambu, out: Output, *, serial: str | None, insecure: bool = False, doctor: bool = False +) -> int | None: + """Set the printer selection on `app` before any network step: the profile (an unknown name is a usage error, exit + 2), the serial, the TLS flag and a one-shot code when a code flag is set. None when the read can go ahead, else the + exit code. A damaged printers.json is an environment error (exit 3) that names the file, except for `doctor`: it + skips the file when no --printer and no code flag is given, and otherwise lets its config section report the damage, + so doctor never stops on it.""" + app.printer_profile = a.printer_profile + app.printer_serial = serial + app.printer_insecure = insecure + has_code_flag = bool(a.access_code_file or a.access_code_stdin) + if not (doctor and a.printer_profile is None and not has_code_flag): + try: + ps: ProfileSet | None = load_profiles_named() + except ConfError: + if not doctor: + raise + ps = None + if ps is not None: + try: + conf.select_profile(a.printer_profile, ps) + except ConfError as e: + return usage(out, str(e)) + if has_code_flag: + code = read_access_code(a, out) + if isinstance(code, int): + return code + app.printer_code = code + return None def _mismatch_flag(p: argparse.ArgumentParser) -> None: @@ -202,6 +326,21 @@ def shambu_lines(d: dict) -> list[str]: ] +def profile_lines(c: dict) -> list[str]: + """doctor's printer profile line; none when no profile is selected (the 0.4.0 lines stay as they were).""" + p = c.get("printer_profile") + if p is None: + return [] + if "error" in p: + return [f"printer profile: {p['error']}"] + when = _hhmm(p.get("verified")) + tls = "TLS check off (--insecure)" if p["insecure"] else "TLS verified" + return [ + f"printer profile {p['name']}{' (default)' if p['default'] else ''}: serial {p['serial']}, mode {p['mode']}, " + f"last login {when}, {tls}, code: {p.get('code') or 'none'}; {c.get('profiles')} profile(s) in {c.get('config_dir')}" + ] + + def studio_lines(st: dict) -> list[str]: if "error" in st: return [f"Bambu Studio: {st['error']} (tested {st['tested']})"] @@ -241,6 +380,11 @@ def run(self, a, app, out): return usage(out, f"--printer-timeout {a.printer_timeout:g}: give a finite number of seconds above 0") app.printer_ip = a.printer_ip app.printer_timeout = a.printer_timeout + app.printer_profile = a.printer_profile + if not a.no_printer: + stop = _use_printer(a, app, out, serial=a.serial, doctor=True) + if stop is not None: + return stop d = app.doctor( printer=not a.no_printer, expected=a.expect_version, @@ -256,6 +400,7 @@ def run(self, a, app, out): lines.append( f"selected printer {c.get('selected_printer_serial')}, access code stored: {c.get('has_access_code')}, single_instance {c.get('single_instance')}" ) + lines += profile_lines(c) m = d.get("machine", {}) if "bed" in m: lines.append( @@ -647,6 +792,10 @@ def run(self, a, app, out): from . import sweep as S app.printer_ip = a.printer_ip + if not a.no_printer: + stop = _use_printer(a, app, out, serial=a.serial) + if stop is not None: + return stop try: # a bad spec, variant file or part key is a failed check (exit 1), not an environment error try: @@ -750,6 +899,10 @@ def run(self, a, app, out): if isinstance(spec, int): return spec app.printer_ip = a.printer_ip + if not a.no_printer: + stop = _use_printer(a, app, out, serial=a.serial) + if stop is not None: + return stop r = app.build( spec, export=False, @@ -786,6 +939,10 @@ def run(self, a, app, out): return spec app.listeners.append(lambda ev, d: log.info("%s %s", ev, {k: v for k, v in d.items() if k != "argv"})) app.printer_ip = a.printer_ip + if not a.no_printer: + stop = _use_printer(a, app, out, serial=a.serial) + if stop is not None: + return stop kw = {"no_check": True} if a.no_check else {} r = app.build( spec, @@ -1156,12 +1313,13 @@ def run(self, a, app, out): return 0 -@command("printer", "Read the printer over LAN MQTT (read-only): status | filaments | watch | discover | raw") +@command("printer", "Read the printer over LAN MQTT (read-only): status | filaments | watch | discover | raw | login | list | forget") class Printer(Command): def configure(self, p): - p.add_argument("action", choices=("status", "filaments", "watch", "discover", "raw")) + p.add_argument("action", choices=("status", "filaments", "watch", "discover", "raw", "login", "list", "forget")) p.add_argument("--ip") p.add_argument("--serial") + _profile_flags(p, serial=False) p.add_argument("--wait", type=float, default=8.0) p.add_argument( "--every", type=int, default=300, help="watch interval in seconds (at most 60 near the start and the end of a print)" @@ -1183,23 +1341,31 @@ def configure(self, p): action="store_true", help="skip TLS verification against Bambu's CA and the serial (the access code then goes to an unverified endpoint)", ) + p.add_argument("--name", help="login: the profile name (default: the profile's own, else 'printer')") + p.add_argument("--default", action="store_true", help="login: make this profile the default") + p.add_argument("--mode", choices=("lan", "cloud"), help="login: the printer's connection mode, as set on its touchscreen") + p.add_argument("--from-studio", action="store_true", help="login: take the serial and the code from Bambu Studio's config") + p.add_argument("--keep-code", action="store_true", help="forget: keep the stored access code") def run(self, a, app, out): - from .printer import READ_TIMEOUT_S, ArpProbe, SsdpListen + from .printer import READ_TIMEOUT_S # one read's timeout: 15 s at the default --wait 8, and --wait plus 7 s (connect and grace) above that app.printer_timeout = max(READ_TIMEOUT_S, a.wait + 7) - svc = app.printer(a.ip, a.serial, a.wait, insecure=a.insecure) if a.action == "discover": - hits = ArpProbe().find() + (SsdpListen(a.ssdp).find() if a.ssdp else []) - out.emit( - hits, - [f"{h['ip']} ({h['method']}" + (f", {h.get('model')} {h.get('connect')}" if h.get("model") else "") + ")" for h in hits] - or ["no printer found"], - ) - return 0 if hits else 1 + return _discover(a, out) + if a.action == "list": + return _list(out) + if a.action == "forget": + return _forget(a, app, out) + if a.action == "login": + return _login(a, app, out) + stop = _use_printer(a, app, out, serial=a.serial, insecure=a.insecure) + if stop is not None: + return stop + svc = app.printer(a.ip, a.serial, a.wait, insecure=a.insecure) if a.action == "raw": - print(json.dumps(svc.public_report(), indent=1, default=str)) + Output(True).emit(svc.public_report()) # always JSON; scrubbed as every other document return 0 if a.action == "status": st = svc.status() @@ -1212,6 +1378,123 @@ def run(self, a, app, out): return _watch(a, svc, out) +def _discover(a, out: Output) -> int: + """`printer discover`: the ARP table, then the default-route /24, then (with --ssdp) an SSDP listen. No credential.""" + from .printer import ArpProbe, SsdpListen, SubnetProbe + + hits = ArpProbe().find() + SubnetProbe().find() + (SsdpListen(a.ssdp).find() if a.ssdp else []) + out.emit( + hits, + [f"{h['ip']} ({h['method']}" + (f", {h.get('model')} {h.get('connect')}" if h.get("model") else "") + ")" for h in hits] + or ["no printer found"], + ) + return 0 if hits else 1 + + +def _hhmm(stamp: str | None) -> str: + """The HH:MM of a UTC ISO stamp, labelled ("08:00 UTC"), or "never".""" + return f"{stamp[11:16]} UTC" if stamp else "never" + + +def _list(out: Output) -> int: + """`printer list`: one line per profile. The one place shambu prints a printer's IP.""" + ps = load_profiles_named() + doc = {"config_dir": str(conf.PRINTERS.parent), "default": ps.default, "printers": [p.public() for p in ps.printers]} + if not ps.printers: + out.emit(doc, ["no printer profiles: run shambu printer login"]) + return 1 + lines = [ + f"{p.name} {conf.mask(p.serial)} {p.ip or '-'} {p.mode} verified {_hhmm(p.verified)}" + + (" (default)" if p.name == ps.default else "") + for p in ps.printers + ] + out.emit(doc, lines) + return 0 + + +def _forget(a, app: Bambu, out: Output) -> int: + if not a.printer_profile: + return usage(out, "forget needs --printer NAME (shambu printer list shows the names)") + ps = load_profiles_named() # a damaged file: exit 3, naming it + try: # before the lock: an unknown name writes nothing (no config dir, no lock file) + conf.select_profile(a.printer_profile, ps) + except ConfError as e: + return usage(out, str(e)) + r = app.forget(a.printer_profile, a.keep_code) + what = ( + f"code: {r['note']}" + if r["note"] + else ("code removed" if r["code_removed"] else "code kept" if a.keep_code else "no code was stored") + ) + out.emit(r, [f"forgot profile {r['name']} (serial {r['serial_masked']}); {what}"]) + return 3 if r["note"] else 0 # the profile is gone but the code is not: the environment failed + + +LOGIN_FORMS = ( + "login needs a printer: shambu printer login --ip IP [--serial SERIAL], shambu printer login --printer NAME [--ip IP], " + "or shambu printer login --from-studio" +) + + +def _login(a, app: Bambu, out: Output) -> int: + """`printer login`: check the flags, read the code, read the printer once with it, then store the profile and the + code. Exit 0, 2 for bad usage, 3 when the printer refuses the code or cannot be read (nothing is stored then). + The serial checked here (the flag, else the profile's) goes to `Bambu.login` as the pin.""" + from .profiles import valid_name + + if a.name is not None and not valid_name(a.name): + return usage( + out, + "--name: a profile name is 1 to 32 letters, digits, '.', '_' or '-', starts with a letter or a digit, " + "and holds no IP address, host name or serial", + ) + if a.from_studio and (a.serial or a.access_code_file or a.access_code_stdin): + return usage(out, "--from-studio takes the serial and the code from Bambu Studio's config: drop --serial and the access code flags") + if a.from_studio and a.printer_profile is not None: + return usage(out, "--from-studio takes Bambu Studio's selected printer and --printer names a profile: give one of them") + ps = load_profiles_named() + profile = None + if a.printer_profile is not None: + try: + profile = conf.select_profile(a.printer_profile, ps) + except ConfError as e: + return usage(out, str(e)) + elif not a.from_studio and a.ip is None: + profile = ps.selected(None) + if profile is None: + return usage(out, LOGIN_FORMS) + if profile is not None and not a.from_studio and a.serial and a.serial.casefold() != profile.serial.casefold(): + return usage( + out, f"--serial does not match profile {profile.name}: enrol another printer with --ip IP --serial SERIAL --name OTHER" + ) + if a.insecure and not a.from_studio and not a.serial and profile is None: + return usage(out, "--insecure cannot read the printer's certificate: give --serial") + code = None + if not a.from_studio: + got = read_access_code(a, out) + if isinstance(got, int): + return got + code = got + app.printer_profile = a.printer_profile + app.printer_wait = a.wait + r = app.login( + ip=a.ip, + serial=a.serial, + code=code, + from_studio=a.from_studio, + name=a.name, + make_default=a.default, + insecure=a.insecure, + mode=a.mode, + pin=None if a.from_studio else (a.serial or (profile.serial if profile is not None else None)), + ) + line = f"profile {r.name}: serial {r.serial_masked}, code stored, mode {r.mode}, read at {_hhmm(r.verified)}" + ( + " (default)" if r.default else "" + ) + out.emit(r, [line]) + return 0 + + def _gcode_track(path: str, plate: int | None): """(M73Track, entry name) of a sliced project's plate G-code, streamed from the zip: no other entry is read. Raises ProjectError when the plate's G-code is missing, CalibrationError when it holds no usable M73 track.""" @@ -1242,6 +1525,10 @@ def _watch(a, svc, out: Output) -> int: return usage(out, "--every, --max-errors and --max-polls must be 1 or more") if a.gcode3mf and not a.log: return usage(out, "--gcode3mf ties a --log to the file it watches: give --log too") + try: # register the code the chain holds (the credentials file too) before the header text is scrubbed + svc.prepare_secrets() + except (PrinterError, ConfError): + pass # a missing serial or code: the first poll reports it (the setup record) header: dict | None = None if a.log: header = {"kind": "header", "shambu_version": __version__, "started_at": utc_iso(time.time()), "every_s": a.every} @@ -1397,8 +1684,8 @@ def add(stored: list[dict]) -> list[dict]: "stored": stored if show else None, "fits": combined if show else None, } - secrets = conf.sensitive_values() # file names and G-code labels are free text: scrub the serial and code - out.emit(json.loads(conf.scrub_text(json.dumps(_jsonable(doc)), secrets)), [conf.scrub_text(x, secrets) for x in lines]) + secrets = conf.sensitive_values() # file names and G-code labels are free text: the masks go too, not only `emit`'s list + out.emit(_scrub_tree(_jsonable(doc), secrets), [conf.scrub_text(x, secrets) for x in lines]) return 1 if errors else 0 @@ -1428,6 +1715,13 @@ def _add_json_flag(p: argparse.ArgumentParser) -> None: _add_json_flag(child) +def _scrub_values() -> list[str]: + """What `Output.emit` and `main` scrub from every output: every known code (`conf.secret_values`) and every raw serial (the + `conf.sensitive_values` entries that are not a mask), longest first. A masked serial (`00X…999`) is not scrubbed.""" + vals = set(conf.secret_values()) | {v for v in conf.sensitive_values() if "\u2026" not in v} + return sorted(vals, key=len, reverse=True) + + def main(argv: list[str] | None = None) -> int: a = build_parser().parse_args(argv) logging.basicConfig( @@ -1437,8 +1731,8 @@ def main(argv: list[str] | None = None) -> int: try: app = Bambu() return _REGISTRY[a.cmd].run(a, app, out) - except BambuError as e: - msg = f"{type(e).__name__}: {e}" + except BambuError as e: # the text must never hold a code or a raw serial; an IP and a masked serial stay, as in 0.4.0 + msg = conf.scrub_text(f"{type(e).__name__}: {e}", _scrub_values()) if a.json: print(json.dumps({"error": msg})) else: diff --git a/src/shambu/conf.py b/src/shambu/conf.py index 9be4229..988aa15 100644 --- a/src/shambu/conf.py +++ b/src/shambu/conf.py @@ -5,35 +5,93 @@ access code per serial. The access code is returned only by `access_code()` and must never be printed or written to disk by callers. -shambu's own data file lives beside it: `~/Library/Application Support/shambu/calibration.json` +shambu's calibration file lives beside it: `~/Library/Application Support/shambu/calibration.json` (`CALIBRATION`), the measured prints `shambu calibrate --add` stores. `load_calibration()` and `save_calibration()` read and write it (atomic: a temp file, then `os.replace`), and `update_calibration()` does load-change-save under a file lock. It holds a list of prints with the fixed fields of `calibrate.validate_print` (a session fingerprint, date, printer model, material, Studio total, rate, startup offset, poll times, speed levels, flags) and never a file name, an IP, a serial, an access code or a path. + +The config dir (`config_dir()`): `$SHAMBU_CONFIG_DIR` when it is set to an absolute path, else `$XDG_CONFIG_HOME/shambu` +when `XDG_CONFIG_HOME` is set to an absolute path, else `~/.config/shambu`. A value that is empty or relative after `~` +expansion counts as unset (the XDG spec says so), so no file lands in a checkout. There is no fallback to the repository. shambu creates +it 0700 (a folder that already exists keeps its mode) and writes every file in it 0600, atomic. It holds two files: + +- `printers.json` (`PRINTERS`): the printer profiles (profiles.py has the schema): a version, the default name and, per + printer, its name, serial, last known IP, mode, TLS flag and dates. It never holds an access code, a token or a path. + `load_profiles()` and `save_profiles()` read and write it, `update_profiles()` does load-change-save and + `locked_profiles()` runs a caller's whole commit, both under a file lock on `printers.json.lock` beside it. + `select_profile()` picks a profile by name or the default. A damaged file is refused with the fixed + `profiles.REFUSAL` text, never with its content. +- `credentials.json` (`CREDENTIALS`): the LAN access code per serial, read and written only by `vault.FileVault`. It + never holds a name, an IP, a token or a path. + +`_atomic_write()` and `_locked()` are the write and lock helpers every data file uses. `open_private()` is the read +side for `printers.json` and `credentials.json`: no link followed, no blocking open, a regular file this user owns. + + +The runtime secret set holds every LAN access code this process read from any source (Studio's config, the +credentials file, a one-shot flag): `remember_secret()` adds one of any length. The runtime identifier set holds every +profile serial this process loaded and its mask (`remember_identifier()`). `forget_secrets()` clears both. +`sensitive_values()` and `secret_values()` include them even when Studio's config cannot be read or is malformed, so +`scrub_text` removes a code wherever it came from. Neither reads `printers.json`: a scrub helper does no file I/O +beyond Studio's config. """ from __future__ import annotations +import errno import fcntl import ipaddress import json import logging import os import re +import stat import tempfile from collections.abc import Callable from pathlib import Path +from typing import TYPE_CHECKING, TypeVar from .errors import BambuError, ConfError +if TYPE_CHECKING: + from .profiles import Profile, ProfileSet + log = logging.getLogger(__name__) +T = TypeVar("T") APPSUPPORT = Path.home() / "Library/Application Support/BambuStudio" CONF = APPSUPPORT / "BambuStudio.conf" CALIBRATION = Path.home() / "Library/Application Support/shambu/calibration.json" CALIBRATION_VERSION = 1 + + +def _absolute_env(name: str) -> Path | None: + """The variable `name` as a path after `~` expansion; None when it is unset, empty or not absolute.""" + v = os.environ.get(name) + if not v: + return None + p = Path(v).expanduser() + return p if p.is_absolute() else None + + +def config_dir() -> Path: + """shambu's config dir: `$SHAMBU_CONFIG_DIR`, else `$XDG_CONFIG_HOME/shambu`, else `~/.config/shambu`. A variable + that is empty or relative after `~` expansion counts as unset.""" + own = _absolute_env("SHAMBU_CONFIG_DIR") + if own is not None: + return own + xdg = _absolute_env("XDG_CONFIG_HOME") + if xdg is not None: + return xdg / "shambu" + return Path.home() / ".config" / "shambu" + + +PRINTERS = config_dir() / "printers.json" +CREDENTIALS = config_dir() / "credentials.json" +PRINTERS_VERSION = 1 BED_TYPES = { "0": "Default Plate", "1": "Cool Plate", @@ -43,6 +101,29 @@ "5": "Supertack Plate", } SECRET_RE = ("access", "code", "token", "ticket", "pass", "secret") +_RUNTIME_SECRETS: set[str] = set() # every code this process read, from any source; never printed or written +_RUNTIME_IDENTIFIERS: set[str] = set() # every profile serial this process loaded, and its mask + + +def remember_secret(value: str) -> None: + """Add a code this process read to the runtime secret set, so every outward text scrubs it. Any length; ignores '' + and anything that is not a `str` (a numeric Studio value is not registered).""" + if isinstance(value, str) and value: + _RUNTIME_SECRETS.add(value) + + +def remember_identifier(serial: str) -> None: + """Add a profile serial and its mask (when the serial is longer than 6) to the runtime identifier set.""" + if serial: + _RUNTIME_IDENTIFIERS.add(serial) + if len(serial) > 6: + _RUNTIME_IDENTIFIERS.add(mask(serial)) + + +def forget_secrets() -> None: + """Empty the runtime secret and identifier sets (the tests call it between tests).""" + _RUNTIME_SECRETS.clear() + _RUNTIME_IDENTIFIERS.clear() def load() -> dict: @@ -58,15 +139,20 @@ def selected_serial(conf: dict | None = None) -> str | None: def access_code(serial: str | None = None, conf: dict | None = None) -> str: - """The LAN access code for a serial. Keep it in memory; never log it.""" + """The LAN access code for a serial, registered with `remember_secret`. Keep it in memory; never log it.""" conf = conf or load() serial = serial or selected_serial(conf) if not serial: raise ConfError("no printer selected in Bambu Studio (app.user_last_selected_machine is empty)") codes = conf.get("access_code", {}) if serial not in codes: - raise ConfError(f"no access code stored for serial {mask(serial)}; open the printer once in Studio's Device tab") - return codes[serial] + raise ConfError( + f"no access code for serial {mask(serial)}: run shambu printer login, or open the printer once in Bambu Studio's Device tab" + ) + code = codes[serial] + if isinstance(code, str): + remember_secret(code) + return code def mask(serial: str) -> str: @@ -188,23 +274,52 @@ def redact_identifiers(text: str) -> str: return _MASKED.sub("", redact_ip(text)) -def sensitive_values(conf: dict | None = None) -> list[str]: - """The serials, their masks and the access codes in Studio's config: text no shambu output may carry. [] when - the config cannot be read (nothing to compare with).""" +def _longest_first(vals: set[str]) -> list[str]: + return sorted((v for v in vals if v), key=len, reverse=True) + + +def _studio_values(conf: dict | None) -> tuple[set[str], set[str]]: + """(serials, codes) from Studio's config: empty sets when it cannot be read or does not have the expected shape (a + non-dict root or `access_code`, a non-str value). Scrubbing never breaks on a malformed config.""" try: c = conf or load() except (ConfError, OSError, ValueError): - return [] - serials = {str(k) for k in (c.get("access_code") or {})} | {str(selected_serial(c) or "")} - codes = {str(v) for v in (c.get("access_code") or {}).values() if v} - vals = {s for s in serials if s} | {mask(s) for s in serials if len(s) > 6} | codes - return sorted((v for v in vals if len(v) >= 4), key=len, reverse=True) + return set(), set() + if not isinstance(c, dict): + return set(), set() + codes_map = c.get("access_code") + if not isinstance(codes_map, dict): + codes_map = {} + app = c.get("app") + selected = app.get("user_last_selected_machine") if isinstance(app, dict) else None + serials = {k for k in codes_map if isinstance(k, str) and k} + if isinstance(selected, str) and selected: + serials.add(selected) + codes = {v for v in codes_map.values() if isinstance(v, str) and v} + return serials, codes + + +def sensitive_values(conf: dict | None = None) -> list[str]: + """The runtime secrets and identifiers (any length), plus the serials, their masks and the access codes in Studio's + config (4 characters or more): text no shambu output may carry. No profile file I/O; an unreadable or malformed + Studio config adds nothing.""" + serials, codes = _studio_values(conf) + studio = serials | {mask(s) for s in serials if len(s) > 6} | codes + return _longest_first(set(_RUNTIME_SECRETS) | set(_RUNTIME_IDENTIFIERS) | {v for v in studio if len(v) >= 4}) + + +def secret_values(conf: dict | None = None) -> list[str]: + """The codes only (the runtime secrets plus Studio's codes, no serial and no mask): what `printer list` and + `printer discover` scrub, because they show the masked serial on purpose.""" + _serials, codes = _studio_values(conf) + return _longest_first(set(_RUNTIME_SECRETS) | {v for v in codes if len(v) >= 4}) def scrub_text(text: str, values: list[str] | None = None) -> str: """`text` with every known sensitive value (`sensitive_values`) replaced by ''.""" for v in sensitive_values() if values is None else values: - text = text.replace(v, "") + if v: # an empty value would put '' between every character + text = text.replace(v, "") return text @@ -237,16 +352,42 @@ def _write_calibration(p: Path, prints: list[dict]) -> None: validate_print(r) except CalibrationError as e: raise ConfError(str(e)) from None # a fixed text: it never echoes the refused content + _atomic_write(p, {"version": CALIBRATION_VERSION, "prints": prints}, ".calibration-") + + +def _refuse_irregular(p: Path) -> None: + """DataFileError when `p` is a symlink or exists and is not a regular file: a write never replaces a link.""" + try: + st = os.lstat(p) + except FileNotFoundError: + return + except OSError as e: + raise DataFileError(f"{p}: cannot check it ({e.strerror})") from e + if not stat.S_ISREG(st.st_mode): # a symlink, a directory, a FIFO, a device + raise DataFileError(f"{p}: not a regular file; remove it and retry") + + +def _atomic_write(p: Path, doc: dict, prefix: str, mode: int = 0o600) -> None: + """Write the already-validated `doc` to `p` as JSON: a temp file named `prefix...` beside it, set to `mode` before + anything is written, flushed and fsynced, then `os.replace`. A missing folder is created 0700 (one that exists keeps + its mode). DataFileError when `p` is a symlink or not a regular file (it is never replaced), or when the folder or + the file cannot be written; the temp file never stays behind and its descriptor is closed on every path, except + that a process killed between the write and the `os.replace` can leave a private (0600) `*.json` temp file + beside `p`. A config directory that already exists keeps its mode.""" + _refuse_irregular(p) try: - p.parent.mkdir(parents=True, exist_ok=True) - fd, tmp = tempfile.mkstemp(dir=p.parent, prefix=".calibration-", suffix=".json") + p.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + fd, tmp = tempfile.mkstemp(dir=p.parent, prefix=prefix, suffix=".json") except OSError as e: raise DataFileError(f"{p.parent}: cannot write there ({e.strerror})") from e failure: OSError | None = None try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - json.dump({"version": CALIBRATION_VERSION, "prints": prints}, f, indent=1, allow_nan=False) + with os.fdopen(fd, "w", encoding="utf-8") as f: # owns the descriptor from here: closed on every path + os.fchmod(f.fileno(), mode) + json.dump(doc, f, indent=1, allow_nan=False) f.write("\n") + f.flush() + os.fsync(f.fileno()) os.replace(tmp, p) except OSError as e: failure = e @@ -269,8 +410,21 @@ def update_calibration(change: Callable[[list[dict]], list[dict]], path: Path | """Load, `change`, save, under an exclusive `fcntl.flock` on `calibration.json.lock` beside the file, so two `calibrate --add` runs never lose one addition. Returns the saved prints.""" p = path or CALIBRATION + + def run() -> list[dict]: + prints = change(_read_calibration(p)) + _write_calibration(p, prints) + return prints + + return _locked(p, run) + + +def _locked(p: Path, fn: Callable[[], T]) -> T: + """`fn()` under an exclusive `fcntl.flock` on `

.lock` beside `p` (created when missing), so two shambu + processes never interleave a load-change-save of the same file. A missing folder is created 0700. DataFileError when + it cannot be locked.""" try: - p.parent.mkdir(parents=True, exist_ok=True) + p.parent.mkdir(parents=True, exist_ok=True, mode=0o700) lock = open(p.with_name(p.name + ".lock"), "a") # closed in the finally below except OSError as e: raise DataFileError(f"{p}: cannot create its lock file ({e.strerror})") from e @@ -279,13 +433,117 @@ def update_calibration(change: Callable[[list[dict]], list[dict]], path: Path | fcntl.flock(lock.fileno(), fcntl.LOCK_EX) except OSError as e: raise DataFileError(f"{p}: cannot lock it ({e.strerror})") from e - prints = change(_read_calibration(p)) - _write_calibration(p, prints) - return prints + return fn() finally: lock.close() # closing the file releases the lock +def open_private(p: Path, refusal: str | None = None) -> int: + """A read-only descriptor of `p`, opened without following a link and without blocking (a FIFO at the path is + refused, not waited on), then checked with `fstat`: a regular file this user owns. The caller closes it. + DataFileError(`refusal`, default "

: not a private regular file; remove it and retry") for a symlink, a FIFO, a + directory, a device or a file another user owns; DataFileError for any other open or stat failure. FileNotFoundError + passes through: a missing file is the caller's case.""" + text = refusal if refusal is not None else f"{p}: not a private regular file; remove it and retry" + try: + fd = os.open(p, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except FileNotFoundError: + raise + except OSError as e: + if e.errno == errno.ELOOP: # O_NOFOLLOW met a symlink + raise DataFileError(text) from None + raise DataFileError(f"{p}: cannot read it ({e.strerror})") from None + try: + st = os.fstat(fd) + except OSError as e: + os.close(fd) + raise DataFileError(f"{p}: cannot read it ({e.strerror})") from None + if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid(): # checked before any read: a directory never reaches one + os.close(fd) + raise DataFileError(text) + return fd + + +def _read_profiles(p: Path) -> ProfileSet: + from .profiles import REFUSAL, ProfileError, ProfileSet + + try: + fd = open_private(p) + except FileNotFoundError: + return ProfileSet(None, ()) + try: + with os.fdopen(fd, encoding="utf-8") as f: # owns the descriptor from here + d = json.loads(f.read()) + except OSError as e: + raise DataFileError(f"{p}: cannot read it ({e.strerror})") from e + except ValueError: + raise ConfError(REFUSAL) from None # a fixed text: it never echoes the file's content + + try: + ps = ProfileSet.from_dict(d, secrets=sensitive_values()) + except ProfileError: + raise ConfError(REFUSAL) from None + for x in ps.printers: + remember_identifier(x.serial) + return ps + + +def load_profiles(path: Path | None = None) -> ProfileSet: + """The stored printer profiles; an empty set when the file does not exist. Every serial is registered with + `remember_identifier`. ConfError(REFUSAL) for a file that is not a valid version-1 printers document (a name that + holds a known secret included), DataFileError when it cannot be read.""" + return _read_profiles(path or PRINTERS) + + +def save_profiles(ps: ProfileSet, path: Path | None = None) -> Path: + """Write the profiles atomically (temp file, then `os.replace`). ConfError(REFUSAL) and nothing written when the + set does not validate.""" + from .profiles import REFUSAL, ProfileError, validate_document + + p = path or PRINTERS + doc = ps.to_dict() + try: + validate_document(doc, sensitive_values()) + except ProfileError: + raise ConfError(REFUSAL) from None + _atomic_write(p, doc, prefix=".printers-") + return p + + +def update_profiles(change: Callable[[ProfileSet], ProfileSet], path: Path | None = None) -> ProfileSet: + """Load, `change`, save, under the lock on `printers.json.lock` beside the file. Returns the saved set.""" + p = path or PRINTERS + + def run() -> ProfileSet: + ps = change(_read_profiles(p)) + save_profiles(ps, p) + return ps + + return _locked(p, run) + + +def locked_profiles(fn: Callable[[ProfileSet], T], path: Path | None = None) -> T: + """`fn(profiles)` under the same lock as `update_profiles`, so a whole commit (a login or a forget) runs as one + critical section against other shambu processes. `fn` may call `save_profiles` itself (not `update_profiles`). + + A login commit runs `vault.FileVault.put` inside `fn`; `FileVault` takes its own lock on `credentials.json.lock`. + So a login commit holds the profiles lock and, inside it, the credentials lock: in that order, always. The facade + must never take them in the other order (the credentials lock, then the profiles lock): two processes that take + them in opposite orders can deadlock.""" + p = path or PRINTERS + return _locked(p, lambda: fn(_read_profiles(p))) + + +def select_profile(name: str | None, ps: ProfileSet | None = None, path: Path | None = None) -> Profile | None: + """The profile named `name`, or the default one when `name` is None (None when there is none). ConfError when no + profile has that name; the user-typed name is the only text from outside the code in that message.""" + ps = ps if ps is not None else load_profiles(path) + p = ps.selected(name) + if p is None and name is not None: + raise ConfError(f"no printer profile named {name!r}: shambu printer list") + return p + + def hms_table(serial_prefix: str = "20P", lang: str = "en") -> dict: """Offline HMS text: {long_code_hex: intro}, plus {print_error_hex: intro} under key '_errors'.""" p = APPSUPPORT / "hms" / f"hms_{lang}_{serial_prefix}.json" diff --git a/src/shambu/facade.py b/src/shambu/facade.py index a73687b..3167611 100644 --- a/src/shambu/facade.py +++ b/src/shambu/facade.py @@ -1,8 +1,19 @@ -"""`Bambu`: the one object a caller needs. Everything else is reachable through it.""" +"""`Bambu`: the one object a caller needs. Everything else is reachable through it. + +Printer profiles (0.5.0): `printer()` builds the credential chain `[OneShotCredentials]` (a one-shot code the CLI read) +`+ [ProfileCredentials]` (the selected profile and the credentials file) `+ [StudioCredentials]`; with a profile it also +takes the profile's IP and TLS flag and scans the default-route /24 after the ARP table. `login()` reads the printer +once with the new code and only then commits the profile and the code; `forget()` removes a profile and its code. + +What this module does not do: no constructor does I/O (the Studio binary, the preset tree and the credentials file +are opened on first use), nothing here prints, and nothing reads stdin. The CLI reads a one-shot code and sets +`printer_code`; the facade only passes it on and never stores it. +""" from __future__ import annotations import json +import logging import re import subprocess import sys @@ -13,15 +24,29 @@ from pathlib import Path from . import conf, presets, recipes, runs +from . import printer as printer_mod +from . import vault as vault_mod from .build import BuildResult, Listener, Pipeline, load_spec from .edits import AddFilament, EditSet -from .errors import BambuError, StudioError +from .errors import BambuError, ConfError, PrinterError, StudioError from .loaded import FilamentResolution, reconcile from .patches import PatchSet -from .printer import LoadedFilaments, PrinterService, Status +from .printer import ( + ArpProbe, + LoadedFilaments, + OneShotCredentials, + PrinterService, + ProfileCredentials, + Status, + StudioCredentials, + SubnetProbe, + utc_iso, +) +from .profiles import UNKNOWN, Profile, ProfileError, ProfileSet from .spec import Spec from .studio import MeshInfo, StudioCLI from .threemf import Project +from .vault import Vault, VaultError, safe_code DEFAULT_MACHINE = "Bambu Lab X2D 0.4 nozzle" TESTED_STUDIO = "02.08.02.61" # the Studio build every rule in this package was reverse engineered and tested on @@ -31,6 +56,30 @@ redact_ip = conf.redact_ip # one detector: conf.py +log = logging.getLogger(__name__) +CODE_REFUSAL = "the access code must be 4 to 128 characters with no space, quote or backslash" +STUDIO_UNREADABLE = "Bambu Studio's config could not be read or has an unexpected shape" + + +def studio_login_values() -> tuple[str, str]: + """(serial, code) of Bambu Studio's selected printer, for `login --from-studio`: one guarded read of its config. + An unreadable file, bad JSON or a wrong shape is ConfError(STUDIO_UNREADABLE), a fixed text that never echoes the + file; a missing file, no selected printer or no code for it keep their own ConfError.""" + try: + doc = conf.load() + if not isinstance(doc, dict) or not isinstance(doc.get("app", {}), dict) or not isinstance(doc.get("access_code", {}), dict): + raise TypeError("shape") + serial = conf.selected_serial(doc) + if serial is not None and not isinstance(serial, str): + raise TypeError("shape") + if not serial: + raise ConfError("no printer selected in Bambu Studio (app.user_last_selected_machine is empty)") + code = conf.access_code(serial, doc) + if not isinstance(code, str): + raise TypeError("shape") + except (OSError, ValueError, TypeError, AttributeError, KeyError): + raise ConfError(STUDIO_UNREADABLE) from None + return serial, code def install_kind(direct_url: dict | None, installer: str | None = None) -> tuple[str, str | None]: @@ -89,6 +138,36 @@ def install_command(version: str) -> str: return f'uv tool install --reinstall "shambu @ git+{REPO}@v{version}"' +@dataclass(frozen=True) +class LoginResult: + """What `Bambu.login` stored: the profile name, the masked serial, the mode, the time of the verified read, whether + the profile is the default and whether the code went into the credentials file.""" + + name: str + serial_masked: str + mode: str + verified: str + default: bool + code_stored: bool + + +def profile_section(ps: ProfileSet, profile: Profile | None, config_dir: str, code: str | None = None) -> dict: + """doctor's profile keys for the config section: the selected profile (masked serial, no IP, and `code`: "stored" + or "none" for its serial in credentials.json, never the value), the count and the dir.""" + p = None + if profile is not None: + p = { + "name": profile.name, + "default": ps.default == profile.name, + "serial": conf.mask(profile.serial), + "mode": profile.mode, + "verified": profile.verified, + "insecure": profile.insecure, + "code": code, + } + return {"printer_profile": p, "profiles": len(ps.printers), "config_dir": config_dir} + + @dataclass class Bambu: studio: StudioCLI | None = None # created on first use, so commands that never touch the binary work without it @@ -98,6 +177,11 @@ class Bambu: printer_wait: float = 8.0 printer_timeout: float | None = 15.0 # timeout for one whole printer read (discovery, connect, report); None: none printer_progress: Callable[[str, float], None] | None = field(default=None, repr=False) # hears each read step + printer_profile: str | None = None # `--printer NAME`; None: the default profile, if any + printer_serial: str | None = None # `--serial` + printer_code: str | None = field(default=None, repr=False) # a one-shot code the CLI read; never stored + printer_insecure: bool = False + vault: Vault | None = None # created on first use (`vault_store`): the credentials file _loaded_cache: dict[str, LoadedFilaments | BambuError] = field(default_factory=dict, init=False, repr=False) def presets(self) -> presets.PresetResolver: @@ -110,6 +194,16 @@ def studio_cli(self) -> StudioCLI: self.studio = StudioCLI() return self.studio + def vault_store(self) -> Vault: + if self.vault is None: + self.vault = vault_mod.default_vault() # looked up at use: the tests patch it + return self.vault + + def selected_profile(self) -> Profile | None: + """The profile `printer_profile` names, else the default one, else None. ConfError for an unknown name or a + damaged printers.json. Loading registers every profile serial for scrubbing.""" + return conf.select_profile(self.printer_profile) + # ---- environment def gui_machine_preset(self) -> str: """The GUI's current machine preset, else the X2D default.""" @@ -148,6 +242,13 @@ def step(name: str, t0: float) -> None: d["config"] = conf.summary() except Exception as e: # noqa: BLE001 d["config"] = {"error": str(e)} + try: + ps = conf.load_profiles() + prof = conf.select_profile(self.printer_profile, ps) + d["config"].update(profile_section(ps, prof, str(conf.PRINTERS.parent), self._code_state(prof))) + except BambuError as e: + d["config"].update(printer_profile={"error": conf.scrub_text(f"{type(e).__name__}: {e}")}, profiles=None) + d["config"]["config_dir"] = str(conf.PRINTERS.parent) step("config", t0) t0 = time.monotonic() machine_name = d["config"].get("gui_machine_preset") or DEFAULT_MACHINE @@ -181,6 +282,16 @@ def sub(name: str, dt: float) -> None: d["timings_s"] = timings return d + def _code_state(self, profile: Profile | None) -> str | None: + """ "stored" when credentials.json holds a code for the profile's serial, "none" when it does not, "unreadable" + when the file cannot be read; None without a profile. The code itself never leaves the vault.""" + if profile is None: + return None + try: + return "stored" if self.vault_store().get(profile.serial) is not None else "none" + except VaultError: + return "unreadable" + # ---- meshes and presets def info(self, path: str | Path) -> MeshInfo: return self.studio_cli().info(path) @@ -318,15 +429,166 @@ def open_in_studio(self, path: str | Path) -> None: def printer( self, ip: str | None = None, serial: str | None = None, wait: float | None = None, insecure: bool = False ) -> PrinterService: - """Read-only printer access. `insecure=True` skips TLS verification: the access code then travels to an unverified endpoint.""" + """Read-only printer access. `insecure=True` skips TLS verification: the access code then travels to an unverified endpoint. + + The credential chain is the one-shot code (when the CLI read one), then the selected profile and its stored + code, then Studio's config. With a profile, its IP and TLS flag apply unless the caller gives others, and + discovery adds the default-route /24 after the ARP table; the serial is left to the profile source.""" + profile = self.selected_profile() + chain: list = [OneShotCredentials(self.printer_code)] if self.printer_code else [] + if profile is not None: + chain.append(ProfileCredentials(profile, self.vault_store())) + chain.append(StudioCredentials()) + kw: dict = {"discovery": (ArpProbe(), SubnetProbe())} if profile is not None else {} return PrinterService( - ip or self.printer_ip, - serial, + ip or self.printer_ip or (profile.ip if profile is not None else None), + serial or self.printer_serial, wait if wait is not None else self.printer_wait, + insecure=insecure or self.printer_insecure or (profile.insecure if profile is not None else False), + timeout_s=self.printer_timeout, + on_step=self.printer_progress, + sources=tuple(chain), + profile=profile, + **kw, + ) + + def login( + self, + *, + ip: str | None = None, + serial: str | None = None, + code: str | None = None, + from_studio: bool = False, + name: str | None = None, + make_default: bool = False, + insecure: bool = False, + mode: str | None = None, + pin: str | None = None, + ) -> LoginResult: + """Read the printer once with `code`, then store the profile and the code. The CLI has checked the flags. + + Which printer: `from_studio` takes the serial and the code from Studio's config; else the selected profile + (`printer_profile`, or the default when no `ip` is given) pins the serial and `ip` overrides its address; else + `ip` names a new printer whose serial is `serial` or the CN of its certificate (they must agree). `pin` is the + serial the CLI checked before its prompt: the profile is read again under the lock before the printer read and + once more in the commit, and a profile whose serial is no longer `pin` is ConfError (nothing read or written). + Serials compare case-insensitively: a selected profile's stored serial is the one read, stored and written (a + supplied `serial` that is not it is ConfError), and a new printer's serial takes the certificate CN's case when + TLS verified it (as typed under `insecure`). The read must + succeed before anything is written: a refused code (`LoginRefused`), a wrong host or a timeout writes nothing. + The commit runs under the profiles lock; when printers.json cannot be written after the code was stored, the + previous code (or none) is put back.""" + ps = conf.load_profiles() + existing: Profile | None = None + service_profile: Profile | None = None + if from_studio: + serial, code = studio_login_values() + existing = ps.by_serial(serial) + else: + selected = None + if self.printer_profile is not None or ip is None: # read again, under the lock: the prompt may have taken minutes + selected = conf.locked_profiles(lambda current: conf.select_profile(self.printer_profile, current)) + if selected is not None: + self._check_pin(selected, pin) + if serial and serial.casefold() != selected.serial.casefold(): + raise ConfError(f"--serial does not match profile {selected.name}") + existing = service_profile = selected + serial = selected.serial # the profile's case: a case-only different --serial names the same printer + ip = ip or selected.ip + # `insecure` stays the flag as given: a refresh without --insecure turns the TLS check back on + else: + if not ip: + raise PrinterError("shambu printer login needs --ip for a new printer") + if not insecure: + cn = printer_mod.certificate_serial(ip, self.printer_timeout) + if serial and cn.casefold() != serial.casefold(): + raise PrinterError(f"the host at {ip} presents a certificate for printer {conf.mask(cn)}, not {conf.mask(serial)}") + serial = cn # the certificate is the authority on the serial's case + if not serial: + raise PrinterError("--insecure cannot read the printer's certificate: give --serial") + existing = ps.by_serial(serial) + if code is None or not safe_code(code): + raise ConfError(CODE_REFUSAL) + if not serial: + raise PrinterError("no printer serial: give --serial") + conf.remember_secret(code) + name = name or (existing.name if existing is not None else "printer") + mode = mode or (existing.mode if existing is not None else "unknown") + draft = Profile(name, serial, ip, mode, insecure, added=utc_iso(time.time()), verified=None) + ps.upsert(draft, default=make_default, secrets=conf.sensitive_values()) # a bad name or a clash: before the read + + svc = PrinterService( + ip, + serial, + self.printer_wait, + discovery=(ArpProbe(), SubnetProbe()), insecure=insecure, timeout_s=self.printer_timeout, on_step=self.printer_progress, + sources=(OneShotCredentials(code),), + profile=service_profile, ) + svc.raw_report() + resolved = svc.last_resolved # the address the read went to: the given one, the profile's or discovery's + read_ip = resolved.ip if resolved is not None else ip + now = utc_iso(time.time()) + store = self.vault_store() + + def commit(current: ProfileSet) -> ProfileSet: + if service_profile is not None: # the profile must still be the printer that was read + self._check_pin(current.get(service_profile.name), pin or serial, service_profile.name) + old = current.by_serial(serial) + p = Profile(name, serial, read_ip, mode, insecure, added=old.added if old is not None else now, verified=now) + new = current.upsert(p, default=make_default, secrets=conf.sensitive_values()) + stored = new.by_serial(serial) + key = stored.serial if stored is not None else serial # the profile's stored case is the credential key + previous = store.get(key) + store.put(key, code) + try: + conf.save_profiles(new) + except BaseException: + try: + if previous is None: + store.delete(key) + else: + store.put(key, previous) + except VaultError as e: # the write failure is the error to report; this one is logged + log.warning("could not restore the stored access code for %s (%s)", conf.mask(key), type(e).__name__) + raise + return new + + saved = conf.locked_profiles(commit) + return LoginResult(name, conf.mask(serial), mode, now, saved.default == name, True) + + def _check_pin(self, profile: Profile | None, pin: str | None, name: str | None = None) -> None: + """ConfError when `profile` is gone or its serial is not `pin` (case-insensitive). The text holds the profile + name only: no serial, raw or masked.""" + if pin is None: + return + if profile is None or profile.serial.casefold() != pin.casefold(): + shown = name or self.printer_profile or (profile.name if profile is not None else "default") + raise ConfError(f"profile {shown} changed while login ran; retry") + + def forget(self, name: str, keep_code: bool = False) -> dict: + """Remove the profile `name` (ProfileError when there is none) and, unless `keep_code` or another profile has + the same serial, its code. A failure to remove the code after the profile is gone is reported in `note`.""" + store = None if keep_code else self.vault_store() + + def run(ps: ProfileSet) -> dict: + new = ps.remove(name) # ProfileError when no profile has that name + p = ps.get(name) + if p is None: + raise ProfileError(UNKNOWN) + conf.save_profiles(new) + removed, note = False, None + if store is not None and new.by_serial(p.serial) is None: + try: + removed = store.delete(p.serial) + except VaultError as e: + note = f"the profile is gone but the code could not be removed ({e})" + return {"name": p.name, "serial_masked": conf.mask(p.serial), "code_removed": removed, "note": note} + + return conf.locked_profiles(run) def printer_status(self, ip: str | None = None) -> Status: return self.printer(ip).status() diff --git a/src/shambu/printer.py b/src/shambu/printer.py index 910452d..5f3e5a1 100644 --- a/src/shambu/printer.py +++ b/src/shambu/printer.py @@ -5,11 +5,38 @@ pushes are exempt from Bambu's Authorization Control, so this works without Developer Mode. Nothing here starts, pauses, stops or changes a print, and nothing but `pushall` is published. -Design: `Discovery` strategies find the IP (ARP + port probe, passive SSDP). `MqttReader` adapts -paho-mqtt to one call. `StatusDecoder` turns the raw report into a `Status`. `PrinterService` -is the facade the CLI uses. - -Trust: discovery only proposes an IP (the first host that answers on 8883). TLS fails closed. By +Design: `Discovery` strategies find the IP (ARP + port probe, the default-route subnet, passive SSDP). `MqttReader` +adapts paho-mqtt to one call. `StatusDecoder` turns the raw report into a `Status`. `PrinterService` is the facade the +CLI uses. + +Credentials: a chain of stateless `CredentialSource`s (`PrinterService.sources`; `(StudioCredentials(),)` by default): +`OneShotCredentials` (a code from a one-shot flag), `ProfileCredentials` (the profile's serial and the code the +credentials file holds for it) and `StudioCredentials` (Studio's config; None when the file or the key is absent). +`resolve()` takes the serial first (`serial` given by the caller, then the first source with one, then, with `ip` +given and TLS verified, the CN of the certificate at that IP, read inside the `credentials` step), then the code for +that serial (the first source with one; `ConfError` when none has it), then the IP (`ip`, then the profile's `ip`, then +discovery). It returns a `Resolved` that names the source of each. When a read at the profile's IP cannot connect, the +error names the profile and the commands that record a new address; the service never writes the profile. + +Staged discovery: `discovery` is a tuple of passes (`ArpProbe`, then `SubnetProbe` on the profile path). With the +serial known from a flag or a profile and TLS verified (`check_candidates`), each hit is checked in one `identity` step: +`cert_serial(ip)` must name the serial (any case), within what is left of the read's timeout above the MQTT reserve +(CONNECT_S + HANDSHAKE_S). A mismatch or a failed check skips the hit, and the passes go on until a hit is accepted or +the reserve is reached. A missing CA bundle raises before any pass. Otherwise (insecure, a serial from Studio's config, or +`check_candidates` off) the hit that names the serial wins, else the first hit, as in 0.4.0. No credential leaves the +process during the choice of a candidate. + +Typed read result: every read (`raw_report`, `public_report`, `status`, `filaments`, each `watch` poll) goes through +`_resolve_and_read`, which returns a `ReadResult` (the report and the `Resolved`). The printer's report never populates +a `ReadResult`: `_Collector` merges any top-level key the printer sends, so no report key carries authority. + +`certificate_serial(ip)` reads the CN of the certificate at `ip` (a TLS handshake against Studio's CA bundle, no SNI, +no MQTT), the connect and the handshake together within the deadline. Its trust boundary: a chain-valid CN proves that a +Bambu device answers at that IP, not that it is the intended printer. The CN must be serial-shaped, and the MQTT read +that follows pins that serial. + +Trust: discovery only proposes an IP. With the serial known and TLS on, the first host whose certificate names the +serial wins; otherwise the hit that names the serial, else the first host that answers on 8883. TLS fails closed. By default Studio's CA bundle must exist, the chain must verify, and the certificate must name the selected serial (CN = serial, sent as SNI). OpenSSL checks the name in the handshake and `check_identity` checks it again, both before the access code is sent. There is no retry and no @@ -43,6 +70,7 @@ import calendar import copy +import ipaddress import json import logging import math @@ -56,13 +84,18 @@ from contextlib import contextmanager from dataclasses import dataclass, field, replace from pathlib import Path -from typing import Protocol, TypeVar +from typing import TYPE_CHECKING, Protocol, TypeVar from . import conf from .errors import ConfError, PrinterError +from .vault import Vault, VaultError + +if TYPE_CHECKING: + from .profiles import Profile log = logging.getLogger(__name__) T = TypeVar("T") +C = TypeVar("C", bound=ssl.SSLContext) CA_FILE = Path("/Applications/BambuStudio.app/Contents/Resources/cert/printer.cer") PUSHALL = {"pushing": {"sequence_id": "1", "command": "pushall", "version": 1, "push_target": 1}} @@ -88,6 +121,7 @@ HANDSHAKE_S = 5.0 KEEPALIVE_S = 10 READ_TIMEOUT_S = 15.0 +IDENTITY_S = CONNECT_S # kept back from a verified discovery sweep for the identity checks of its hits TRAY_NONE = 0xFFFF # `snow` / `tray_now` value for "nothing loaded" SPEED_LEVELS = {1: "silent", 2: "standard", 3: "sport", 4: "ludicrous"} # `spd_lvl`; Studio StatusPanel.cpp # `stg` / `stg_cur` ids -> Studio's text (DeviceManager.cpp get_stage_string, v02.08.02.61). Every id the live X2D job @@ -186,11 +220,12 @@ def arp_hosts(text: str) -> list[str]: class ArpProbe: """Hosts in the ARP table that answer on 8883. On a home LAN only Bambu printers do. - Bounds (a slow resolver or a large LAN must not stall a check): `arp -an` (numeric, so no reverse DNS per entry) - with a 2 s timeout, then the 8883 probe on min(`workers`, hosts) threads, `timeout` s per host. The probe window - is max(`probe_window_s`, rounds x `timeout`), so every host gets its turn on a large LAN, and never longer than - the deadline `with_deadline` set. A host not probed when the window ends counts as closed, and `shortfall` says how - many were probed. The probe threads are daemons: a probe still blocked at the end never holds up the process exit.""" + Bounds (a slow resolver or a large LAN must not stall a check): `arp -an` (numeric, so no reverse DNS per entry) + with a timeout of min(2 s, the deadline), then + the 8883 probe on min(`workers`, hosts) threads, `timeout` s per host. The probe window + is max(`probe_window_s`, rounds x `timeout`), so every host gets its turn on a large LAN, and never longer than + the deadline `with_deadline` set. A host not probed when the window ends counts as closed, and `shortfall` says how + many were probed. The probe threads are daemons: a probe still blocked at the end never holds up the process exit.""" timeout: float = 0.5 probe_window_s: float = 2.0 @@ -206,10 +241,17 @@ def with_deadline(self, seconds: float | None) -> ArpProbe: return replace(self, deadline_s=seconds) def hosts(self) -> list[str]: + """The ARP table's hosts. `arp -an` runs with a timeout of min(`arp_timeout`, `deadline_s`); with a deadline of 0 + or less it does not run, and `shortfall` says so.""" + limit = self.arp_timeout if self.deadline_s is None else min(self.arp_timeout, self.deadline_s) + if limit <= 0: + self.shortfall = "no time left for arp -an, so no ARP discovery" + log.warning("%s", self.shortfall) + return [] try: - r = self.runner(["arp", "-an"], capture_output=True, text=True, timeout=self.arp_timeout) + r = self.runner(["arp", "-an"], capture_output=True, text=True, timeout=limit) except subprocess.TimeoutExpired: - log.warning("arp -an gave no answer within %g s; no ARP discovery", self.arp_timeout) + log.warning("arp -an gave no answer within %g s; no ARP discovery", limit) return [] except OSError as e: log.warning("arp -an failed (%s); no ARP discovery", e) @@ -223,7 +265,7 @@ def window(self, n_hosts: int, limit: float | None = None) -> float: w = max(self.probe_window_s, math.ceil(n_hosts / threads) * self.timeout) return w if limit is None else max(0.0, min(w, limit)) - def probe_hosts(self, hosts: list[str], limit: float | None = None) -> list[dict]: + def probe_hosts(self, hosts: list[str], limit: float | None = None, method: str = "arp+8883") -> list[dict]: self.shortfall = None if not hosts: return [] @@ -261,13 +303,16 @@ def worker() -> None: if n < len(hosts): self.shortfall = f"{n} of {len(hosts)} hosts probed before the {window:g} s probe window ended" log.warning("port probe: %s", self.shortfall) - return [{"ip": h, "method": "arp+8883"} for h in hosts if h in found] # ARP order, not finish order + return [{"ip": h, "method": method} for h in hosts if h in found] # input order, not finish order def find(self) -> list[dict]: self.timings = {} + self.shortfall = None t0 = time.monotonic() hosts = self.hosts() self.timings["arp"] = round(time.monotonic() - t0, 2) + if not hosts: # keeps a shortfall `hosts` set + return [] left = None if self.deadline_s is None else self.deadline_s - (time.monotonic() - t0) t1 = time.monotonic() hits = self.probe_hosts(hosts, left) @@ -275,6 +320,109 @@ def find(self) -> list[dict]: return hits +_ROUTE_IF = re.compile(r"^\s*interface:\s*([A-Za-z0-9]+)\s*$", re.M) +_ROUTE_GW = re.compile(r"^\s*gateway:\s*(\d+\.\d+\.\d+\.\d+)\s*$", re.M) +# `inet A netmask 0x...` only: a point-to-point `inet A --> B netmask ...` line never matches +_IFCONFIG_INET = re.compile(r"^\s*inet (\d+\.\d+\.\d+\.\d+) netmask 0x([0-9A-Fa-f]{8})\b", re.M) + + +def _prefix(mask_hex: str) -> int | None: + """The prefix length of a netmask in hex; None for a non-contiguous mask.""" + m = int(mask_hex, 16) + host = ~m & 0xFFFFFFFF + return None if host & (host + 1) else 32 - host.bit_length() + + +def subnet_hosts(route_text: str, ifconfig_text: str, limit_prefix: int = 24) -> list[str]: + """The other hosts of the default-route network, in numeric order. `route_text` is `route -n get default`, + `ifconfig_text` is `ifconfig `. The network is the most specific (longest prefix) of the interface + addresses' networks that contain the route's gateway, whatever the line order; every local address of the interface + is left out. `[]` when there is no gateway, no address's network holds it, the chosen mask is not contiguous or + wider than /`limit_prefix`, or a text does not parse.""" + route_text = route_text or "" + gw = _ROUTE_GW.search(route_text) + if not _ROUTE_IF.search(route_text) or not gw: + return [] + try: + gateway = ipaddress.IPv4Address(gw.group(1)) + except ValueError: + return [] + own: list[ipaddress.IPv4Interface] = [] + for addr, mask_hex in _IFCONFIG_INET.findall(ifconfig_text or ""): + prefix = _prefix(mask_hex) + if prefix is None: + continue + try: + own.append(ipaddress.IPv4Interface(f"{addr}/{prefix}")) + except ValueError: + continue + nets = [i.network for i in own if gateway in i.network] + net = max(nets, key=lambda n: n.prefixlen) if nets else None + + if net is None or net.prefixlen < limit_prefix: + return [] + local = {i.ip for i in own} + return [str(h) for h in net.hosts() if h not in local] + + +@dataclass +class SubnetProbe(ArpProbe): + """The second discovery pass: the hosts of the default-route interface's network (/24 or narrower) that are not in + the ARP table and answer on 8883. It runs `route -n get default`, `ifconfig ` and `arp -an`, each bounded + by min(`arp_timeout`, what is left of the deadline) and skipped when nothing is left, never scans a network wider than /24, and sends nothing but a TCP connect to a candidate. It is + always the pass after `ArpProbe`, never the only one. A runner failure, a timeout or a non-zero exit status gives + no hosts and a `shortfall`.""" + + def with_deadline(self, seconds: float | None) -> SubnetProbe: + return replace(self, deadline_s=seconds) + + def _run(self, argv: list[str], t0: float) -> subprocess.CompletedProcess[str]: + """`argv` with a timeout of min(`arp_timeout`, what is left of the deadline). TimeoutExpired when nothing is + left (the command does not run), OSError for a non-zero exit, else what the runner raises.""" + limit = self.arp_timeout + if self.deadline_s is not None: + limit = min(limit, self.deadline_s - (time.monotonic() - t0)) + if limit <= 0: + raise subprocess.TimeoutExpired(argv, 0) + r = self.runner(argv, capture_output=True, text=True, timeout=limit) + if r.returncode != 0: + raise OSError(f"{argv[0]} exited with status {r.returncode}") + return r + + def interface_hosts(self, t0: float | None = None) -> list[str]: + """The other hosts of the default-route network (see `subnet_hosts`). Raises what `_run` raises.""" + t0 = time.monotonic() if t0 is None else t0 + r = self._run(["route", "-n", "get", "default"], t0) + m = _ROUTE_IF.search(r.stdout or "") + if not m: + return [] + i = self._run(["ifconfig", m.group(1)], t0) + return subnet_hosts(r.stdout or "", i.stdout or "") + + def find(self) -> list[dict]: + self.timings = {} + self.shortfall = None + t0 = time.monotonic() + why = "the default-route network is wider than /24 or could not be read, so no subnet scan" + skip: set[str] = set() + try: + hosts = self.interface_hosts(t0) + if hosts: # the ARP hosts are the first pass's: inside the same failure boundary + skip = set(arp_hosts(self._run(["arp", "-an"], t0).stdout or "")) + except (subprocess.TimeoutExpired, OSError) as e: # no subnet pass, never a crash + msg = conf.redact_identifiers(conf.scrub_text(str(e))) + log.warning("reading the default-route network failed (%s: %s); no subnet scan", type(e).__name__, msg) + hosts, why = [], f"the default-route network could not be read ({type(e).__name__}), so no subnet scan" + if not hosts: + self.shortfall = why + self.timings["subnet"] = round(time.monotonic() - t0, 2) + return [] + left = None if self.deadline_s is None else self.deadline_s - (time.monotonic() - t0) + hits = self.probe_hosts([h for h in hosts if h not in skip], left, method="subnet+8883") + self.timings["subnet"] = round(time.monotonic() - t0, 2) + return hits + + @dataclass class SsdpListen: """Passive listen for the printer's SSDP NOTIFY on UDP 2021 and 1990. SO_REUSEPORT is set @@ -408,29 +556,95 @@ def wrap_socket( ) -def tls_context(serial: str, insecure: bool = False) -> SerialPinnedContext: - """Verified context by default: Studio's CA bundle, chain and name (= serial) checked. A missing CA bundle raises; it - never falls back to an unverified context. `insecure=True` gives CERT_NONE and no name check, on request only.""" - ctx = SerialPinnedContext(ssl.PROTOCOL_TLS_CLIENT) - ctx.expected_serial = serial - if insecure: - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - return ctx +def _ca_context(cls: type[C]) -> C: + """A client context of class `cls` that trusts only Studio's printer CA bundle, with CERT_REQUIRED. A missing or + unloadable bundle raises; nothing falls back to an unverified context. The caller sets the name check.""" if not CA_FILE.is_file(): raise PrinterError(f"printer CA bundle not found at {CA_FILE} (install Bambu Studio), or pass --insecure to skip TLS verification") + ctx = cls(ssl.PROTOCOL_TLS_CLIENT) try: ctx.load_verify_locations(str(CA_FILE)) except (ssl.SSLError, OSError) as e: raise PrinterError(f"cannot load printer CA bundle {CA_FILE}: {e}") from e ctx.verify_mode = ssl.CERT_REQUIRED - ctx.check_hostname = True - ctx.hostname_checks_common_name = True # Bambu printer certificates carry the serial as CN if hasattr(ssl, "VERIFY_X509_STRICT"): ctx.verify_flags &= ~ssl.VERIFY_X509_STRICT # Python 3.13: Bambu's CA fails the strict profile return ctx +def tls_context(serial: str, insecure: bool = False) -> SerialPinnedContext: + """Verified context by default: Studio's CA bundle, chain and name (= serial) checked. A missing CA bundle raises; it + never falls back to an unverified context. `insecure=True` gives CERT_NONE and no name check, on request only.""" + if insecure: + ctx = SerialPinnedContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.expected_serial = serial + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + ctx = _ca_context(SerialPinnedContext) + ctx.expected_serial = serial + ctx.check_hostname = True + ctx.hostname_checks_common_name = True # Bambu printer certificates carry the serial as CN + return ctx + + +_SERIAL_CN = re.compile(r"[A-Za-z0-9]{8,32}") + + +def _identity_context() -> ssl.SSLContext: + """The context `certificate_serial` uses: Studio's CA bundle, CERT_REQUIRED, no name check. A missing or unloadable + bundle raises `PrinterError` (`_ca_context`). `PrinterService._discover` builds it once, before any identity check, + so a missing bundle surfaces at once instead of reading as "not the printer"; the tests patch it.""" + ctx = _ca_context(ssl.SSLContext) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_REQUIRED + return ctx + + +def certificate_serial(ip: str, deadline: float | None = None, ctx: ssl.SSLContext | None = None) -> str: + """The printer serial that the certificate at `ip`:8883 names (its first subject CN). One TLS handshake against + Studio's CA bundle, no SNI and no name check, then close: no MQTT CONNECT, no credential. `deadline` (seconds) + bounds the connect and the handshake together: the connect gets min(CONNECT_S, deadline), the handshake + min(HANDSHAKE_S, what the connect left of the deadline). `ctx` is a prebuilt `_identity_context()`; None builds one. + A deadline spent before the connect or before the handshake raises a `timed out` PrinterError (no floor). + + Trust boundary: a chain-valid CN proves that a Bambu device answers at `ip`, not that it is the intended printer. + The CN must be serial-shaped; the MQTT read that follows pins that serial.""" + if ctx is None: + ctx = _identity_context() + no_time = f"timed out: no time left for the TLS handshake with {ip}:8883" + if deadline is not None and deadline <= 0: + raise PrinterError(no_time) + connect_s = CONNECT_S if deadline is None else min(CONNECT_S, deadline) + t0 = time.monotonic() + try: + sock = socket.create_connection((ip, 8883), timeout=connect_s) + except OSError as e: + raise PrinterError(f"cannot connect to {ip}:8883: {e}") from None + try: + left = HANDSHAKE_S if deadline is None else deadline - (time.monotonic() - t0) + if left <= 0: + raise PrinterError(no_time) + sock.settimeout(min(HANDSHAKE_S, left)) + tls = ctx.wrap_socket(sock, server_hostname=None) + try: + cert: dict = dict(tls.getpeercert() or {}) + finally: + tls.close() + except ssl.SSLError as e: + raise PrinterError(f"TLS verification failed for the host at {ip}: {e}") from None + except TimeoutError: + raise PrinterError(f"TLS handshake with {ip}:8883 timed out") from None + except OSError as e: + raise PrinterError(f"cannot connect to {ip}:8883: {e}") from None + finally: + sock.close() + cns = [v for rdn in cert.get("subject", ()) for k, v in rdn if k == "commonName"] + if not cns or not _SERIAL_CN.fullmatch(cns[0]): + raise PrinterError(f"the certificate at {ip} names no printer") + return cns[0] + + def deep_merge(dst: dict, src: dict) -> dict: """Merge a report delta into `dst` in place: nested dicts merge key by key, lists and scalars replace.""" for k, v in src.items(): @@ -492,6 +706,10 @@ def snapshot(self) -> dict: return copy.deepcopy(self.report) +class LoginRefused(PrinterError): + """The printer refused the MQTT login: the access code is wrong for that printer (`refused the MQTT login`).""" + + @dataclass class MqttReader: """Adapter over paho-mqtt: connect, subscribe, one pushall, collect, disconnect. One attempt, no TLS fallback. @@ -556,7 +774,7 @@ def on_message(c, u, m): except Exception: # noqa: BLE001 pass if col.refused is not None: - raise PrinterError( + raise LoginRefused( f"printer {conf.mask(self.serial)} at {self.ip} refused the MQTT login ({col.refused}): check the LAN access code in Studio" ) report = col.snapshot() @@ -1083,6 +1301,124 @@ def decode(self, report: dict, serial: str, ip: str) -> LoadedFilaments: ) +# ---------------------------------------------------------------------------------- credentials +class CredentialSource(Protocol): + """Where a serial and its access code can come from. Stateless: no timings and no cache, so overlapping reads on + one service share nothing. A method returns None when the source has nothing, and raises only when the source is + broken (an unreadable or damaged credentials file).""" + + @property + def name(self) -> str: ... + + def serial(self) -> str | None: ... + + def code(self, serial: str) -> str | None: ... + + +@dataclass(frozen=True) +class OneShotCredentials: + """A code from a one-shot flag (`--access-code-file`, `--access-code-stdin`). It names no serial.""" + + value: str = field(repr=False) + name: str = field(default="flag", init=False) + + def serial(self) -> str | None: + return None + + def code(self, serial: str) -> str | None: + conf.remember_secret(self.value) + return self.value or None + + +@dataclass(frozen=True) +class ProfileCredentials: + """A printer profile's serial and the code the vault (the credentials file) holds for that serial. A vault failure + becomes a `PrinterError` that names the credentials file.""" + + profile: Profile + vault: Vault = field(repr=False) + name: str = field(default="profile", init=False) + + def serial(self) -> str | None: + return self.profile.serial + + def code(self, serial: str) -> str | None: + if serial != self.profile.serial: + return None + try: + return self.vault.get(serial) + except VaultError as e: # a vault text can name a path or a value read from the file: scrubbed before it goes out + why = conf.scrub_text(str(e)) + raise PrinterError(f"the credentials file could not give the access code for {conf.mask(serial)}: {why}") from None + + +@dataclass(frozen=True) +class StudioCredentials: + """Bambu Studio's config: the selected serial and the code Studio stored for a serial. Both return None when the + config file is absent, unreadable or not JSON, has the wrong shape (a non-dict root, `app` or `access_code`, a + non-str value) or lacks the key, as `conf._studio_values` treats it; this source never raises.""" + + load: Callable[[], dict] = field(default=conf.load, repr=False) + name: str = field(default="studio", init=False) + + def _conf(self) -> dict | None: + try: + c = self.load() + except (ConfError, OSError, ValueError): + return None + return c if isinstance(c, dict) else None + + def serial(self) -> str | None: + c = self._conf() + app = None if c is None else c.get("app") + selected = app.get("user_last_selected_machine") if isinstance(app, dict) else None + return selected if isinstance(selected, str) and selected else None + + def code(self, serial: str) -> str | None: + c = self._conf() + codes = None if c is None else c.get("access_code") + code = codes.get(serial) if isinstance(codes, dict) else None + if not isinstance(code, str) or not code: + return None + conf.remember_secret(code) + return code + + +SOURCE_LABELS = {"flag": "a one-shot flag", "profile": "the credentials file", "studio": "Bambu Studio's config"} +_LAN_HINT = ( + "The printer can regenerate it when LAN-only mode is toggled, when the refresh icon next to the code is pressed, " + "after some firmware updates, and sometimes after a reboot. Read the code off the touchscreen and run: " + "shambu printer login {login}" +) +_CLOUD_HINT = "Open Bambu Studio once so it fetches the current code from your account, then run: shambu printer login --from-studio" +REFUSAL_HINT = { # str.format templates: `{login}` (LAN hint only) is `--printer NAME` or `--ip IP`; --from-studio takes neither + "lan": _LAN_HINT, + "cloud": _CLOUD_HINT, + "unknown": "In LAN-only mode: " + _LAN_HINT + "\nIn cloud mode: " + _CLOUD_HINT, +} + + +@dataclass(frozen=True) +class Resolved: + """What one read uses, and where each part came from. Sources: "flag", "profile", "studio", "certificate", or the + discovery method of the hit ("arp+8883", "subnet+8883", "ssdp").""" + + ip: str = field(repr=False) + serial: str = field(repr=False) + code: str = field(repr=False) + ip_source: str + serial_source: str + code_source: str + + +@dataclass +class ReadResult: + """One read: the report as the printer sent it (with the transport keys) and what the read used.""" + + report: dict = field(repr=False) # free text from the printer (job and file names) + resolved: Resolved + + # ---------------------------------------------------------------------------------- service @dataclass class _Attempt: @@ -1097,7 +1433,8 @@ class _Attempt: @dataclass class PrinterService: - """Facade: serial and access code from Studio's config, IP from discovery or the caller. + """Facade: serial and access code from the credential chain (`sources`), IP from the caller, the profile or + discovery. `insecure=True` turns off TLS verification for every read (the CLI's `--insecure`). Leave it off: a verified read proves the host at the IP is the selected printer before the access code leaves this machine. @@ -1106,8 +1443,24 @@ class PrinterService: timeout runs out the caller gets `PrinterError("timed out after 15 s in ")`. The expired attempt's thread is not stopped: it is a daemon, it finishes or dies with the process, and it never calls `on_step` or writes `timings` again. That is fine for the CLI; a long-lived caller keeps such a thread until its socket times out. - `on_step(name, seconds)` hears each step of the current read as it ends (`credentials`, `arp`, `probe`, `mqtt`); - `timings` holds the steps of the last read.""" + `on_step(name, seconds)` hears each step of the current read as it ends: `credentials` (the chain and, for an `ip` + given with no serial, the certificate read), the discovery passes' steps (`arp`, `probe`, `subnet`), `identity` + (the certificate checks of discovery hits) and `mqtt`. `timings` holds the steps of the last read. + + Profile path (set by the facade): `sources` holds `ProfileCredentials`, `profile` the profile, and `ip` the + profile's IP (or the caller's override). `check_candidates` turns the identity check of discovery hits on (the + default) or off. `cert_serial(ip, deadline, ctx)` is `certificate_serial`, injectable for tests. + + Verified discovery (a serial from a flag or a profile, TLS on, `check_candidates`) splits `timeout_s` three ways: the + sweep gets what is left minus the MQTT reserve (CONNECT_S + HANDSHAKE_S) and minus `IDENTITY_S`, the identity + checks get what is left above the MQTT reserve, and the MQTT read keeps the reserve. With the default 15 s: a sweep + of at most 4 s, then the checks, then 8 s for MQTT. So verified discovery needs a timeout above CONNECT_S + + HANDSHAKE_S + IDENTITY_S (11 s); below that, pass `--ip`. + + `last_resolved` is set by every `_bounded` call when it completes, on the caller's thread (never by the worker): the + `Resolved` of a read that came back in time, None after a failed or timed-out one. So a caller learns the IP and the + sources a read used without wrapping the reader, and after overlapping reads it holds what the last one to finish + gave.""" ip: str | None = None serial: str | None = None @@ -1117,7 +1470,12 @@ class PrinterService: insecure: bool = False timeout_s: float | None = READ_TIMEOUT_S on_step: Callable[[str, float], None] | None = field(default=None, repr=False) + sources: tuple[CredentialSource, ...] = field(default_factory=lambda: (StudioCredentials(),), repr=False) + profile: Profile | None = None + check_candidates: bool = True + cert_serial: Callable[[str, float | None, ssl.SSLContext | None], str] = field(default=certificate_serial, repr=False) timings: dict[str, float] = field(default_factory=dict, init=False, repr=False) + last_resolved: Resolved | None = field(default=None, init=False, repr=False) _local: threading.local = field(default_factory=threading.local, init=False, repr=False) def _attempt(self) -> _Attempt: @@ -1150,7 +1508,9 @@ def _timed(self, name: str) -> Iterator[None]: def _bounded(self, fn: Callable[[], T]) -> T: """`fn()` within `timeout_s` in a worker thread with its own attempt state, or PrinterError naming the step it - was in. No timeout: the same attempt state, in the caller's thread.""" + was in. No timeout: the same attempt state, in the caller's thread. At its completion, on the caller's thread, it + sets `last_resolved`: the `Resolved` of a `ReadResult` that came back in time, else None (an error, a timeout or + another value).""" attempt = _Attempt() self.timings = attempt.timings box: dict = {} @@ -1174,79 +1534,229 @@ def work() -> None: with attempt.lock: attempt.abandoned = True self.timings = dict(attempt.timings) # the steps that ended in time, frozen + self.last_resolved = None raise PrinterError(f"timed out after {self.timeout_s:g} s in {attempt.step}") if "error" in box: + self.last_resolved = None raise box["error"] - return box["value"] - - def resolve(self) -> tuple[str, str, str]: - """IP, serial, code. Discovery only proposes a host: the TLS name check (or, insecure, the report topic) in - `MqttReader.read` confirms it is the selected printer. An SSDP hit that names the serial wins over other hits.""" - with self._timed("credentials"): # serial and access code from Studio's config - c = conf.load() - serial = self.serial or conf.selected_serial(c) - if not serial: - raise PrinterError("no printer selected in Bambu Studio (Device tab)") - code = conf.access_code(serial, c) - ip = self.ip + value = box["value"] + self.last_resolved = value.resolved if isinstance(value, ReadResult) else None + return value + + def _remaining(self, a: _Attempt) -> float | None: + """What is left of the read's timeout; None: no timeout.""" + if not self.timeout_s or self.timeout_s <= 0: + return None + return max(0.1, self.timeout_s - (time.monotonic() - a.started)) + + def _left(self, a: _Attempt) -> float | None: + """What is left of the read's timeout, possibly 0 or less; None: no timeout.""" + if not self.timeout_s or self.timeout_s <= 0: + return None + return self.timeout_s - (time.monotonic() - a.started) + + def _chain_serial(self) -> tuple[str | None, str | None]: + """(serial, source) from the caller's `serial`, then the first source that names one (the source's own name). + No network.""" + if self.serial: + return self.serial, "flag" + for src in self.sources: + serial = src.serial() + if serial: + return serial, src.name + return None, None + + def _chain_code(self, serial: str) -> tuple[str | None, str | None]: + """(code, source) from the first source that has a code for `serial`. No network.""" + for src in self.sources: + code = src.code(serial) + if code: + return code, src.name + return None, None + + def prepare_secrets(self) -> None: + """Run the serial and code lookup of the chain (no network, no certificate read) so that a code the chain + holds is registered with `conf.remember_secret` before any output. A missing serial or code registers + nothing and is not an error; a broken source (a damaged credentials file) still raises.""" + serial, _ = self._chain_serial() + if not serial: + return + code, _ = self._chain_code(serial) + if code: + conf.remember_secret(code) + + def resolve(self) -> Resolved: + """Serial, then code, then IP (`ip`, then the profile's `ip`, then discovery). Discovery only proposes a host: + with the serial from a flag or a profile and TLS on, each hit's certificate must name the serial; the TLS name + check (or, insecure, the report topic) in `MqttReader.read` confirms it again.""" + with self._timed("credentials"): + serial, serial_source = self._chain_serial() + if not serial and self.ip and not self.insecure: + serial, serial_source = self.cert_serial(self.ip, self._remaining(self._attempt()), None), "certificate" + if not serial or not serial_source: + text = "no printer selected: run shambu printer login, or select it in Bambu Studio (Device tab)" + if not conf.CONF.exists(): + text += "; BambuStudio.conf not found: has Bambu Studio run on this Mac?" + raise PrinterError(text) + code, code_source = self._chain_code(serial) + if not code or not code_source: + raise ConfError( + f"no access code for serial {conf.mask(serial)}: run shambu printer login, " + "or open the printer once in Bambu Studio's Device tab" + ) + ip = self.ip or (self.profile.ip if self.profile is not None else None) + if ip: + ip_source = "profile" if self.profile is not None and ip == self.profile.ip else "flag" + else: + check = self.check_candidates and not self.insecure and serial_source in ("flag", "profile") + ip, ip_source = self._discover(serial, check) + return Resolved(ip, serial, code, ip_source, serial_source, code_source) + + def _discover(self, serial: str, check: bool = False) -> tuple[str, str]: + """(ip, method) of the first accepted hit over the staged passes in `discovery`, or the no-host PrinterError. + `check`: a hit counts only when `cert_serial` names `serial` (any case), timed as one `identity` step; else the + hit that names the serial, else the first hit. + + With `check`, the CA context is built once before any pass, so a missing CA bundle raises at once; only a + per-host connect, TLS or CN failure skips a hit. A check starts only while more than the MQTT reserve + (CONNECT_S + HANDSHAKE_S) is left of the read's timeout and gets what is left above it, at most CONNECT_S + + HANDSHAKE_S; the hits not checked in time are counted apart and end discovery. The first pass starts with any + time left (so a short timeout still tries once), a later pass only while more than the reserve is left; the + passes not run are counted too. A pass's deadline is `_discovery_deadline`, never more than what is left.""" + a = self._attempt() + ctx = _identity_context() if check else None + reserve = CONNECT_S + HANDSHAKE_S gaps: list[str] = [] - if not ip: - a = self._attempt() - for proto in self.discovery: - d = proto.with_deadline(self._discovery_deadline(a)) + rejected = 0 + unchecked = 0 + not_run = 0 + checked = 0.0 + checks_ran = False + try: + for n, proto in enumerate(self.discovery): + left = self._left(a) + if unchecked or (left is not None and left <= (reserve if n else 0.0)): + not_run = len(self.discovery) - n # the MQTT read keeps its reserve: no later pass starts + break + deadline = self._discovery_deadline(a, check) + if deadline is not None and left is not None: + deadline = min(deadline, left) # a first pass that starts inside the reserve gets only what is left + d = proto.with_deadline(deadline) a.step = "discovery" hits = d.find() for name, dt in d.timings.items(): self._record(a, name, dt) if d.shortfall: gaps.append(d.shortfall) - if hits: + if not hits: + continue + if not check: named = [h for h in hits if h.get("serial") == serial] - ip = (named or hits)[0]["ip"] - break - if not ip: - why = f" ({'; '.join(gaps)})" if gaps else "" - raise PrinterError(f"no host on this LAN answers on port 8883{why}. Is the printer on and on this network? Pass --ip.") - return ip, serial, code - - def _discovery_deadline(self, a: _Attempt) -> float | None: - """What is left of the read's timeout once the connect and the TLS handshake are kept back; None: no timeout.""" + h = (named or hits)[0] + return h["ip"], str(h.get("method") or "discovery") + for i, h in enumerate(hits): + left = self._left(a) + if left is not None and left <= reserve: + unchecked = len(hits) - i # the MQTT read keeps its reserve: these hits go unchecked + break + a.step = "identity" + checks_ran = True + t0 = time.monotonic() + try: + budget = None if left is None else min(left - reserve, reserve) + ok = self.cert_serial(h["ip"], budget, ctx).casefold() == serial.casefold() + except PrinterError as e: # the identity check could not read the host: it is not the printer + log.info("discovery hit skipped: %s", conf.redact_identifiers(conf.scrub_text(str(e)))) + ok = False + finally: + checked += time.monotonic() - t0 + if ok: + return h["ip"], str(h.get("method") or "discovery") + log.info("discovery hit skipped: its certificate names another device") + rejected += 1 + finally: + if checks_ran: + self._record(a, "identity", round(checked, 2)) + if rejected and not unchecked: + gaps.append(f"{rejected} host(s) answered but none is printer {conf.mask(serial)}") + elif rejected: + gaps.append(f"{rejected} host(s) checked are not printer {conf.mask(serial)}") + if unchecked: + gaps.append(f"{unchecked} candidate(s) not checked before the timeout") + if not_run: + gaps.append(f"{not_run} discovery pass(es) not run before the timeout") + why = f" ({'; '.join(gaps)})" if gaps else "" + raise PrinterError(f"no host on this LAN answers on port 8883{why}. Is the printer on and on this network? Pass --ip.") + + def _discovery_deadline(self, a: _Attempt, check: bool = False) -> float | None: + """What is left of the read's timeout once the connect and the TLS handshake are kept back and, with `check`, + the identity allowance `IDENTITY_S` too; at least 0.5 s. None: no timeout.""" if not self.timeout_s or self.timeout_s <= 0: return None - return max(0.5, self.timeout_s - (time.monotonic() - a.started) - CONNECT_S - HANDSHAKE_S) + keep = CONNECT_S + HANDSHAKE_S + (IDENTITY_S if check else 0.0) + return max(0.5, self.timeout_s - (time.monotonic() - a.started) - keep) - def _read(self, ip: str, serial: str, code: str) -> dict: + def _read(self, r: Resolved) -> dict: + ip = r.ip if self.insecure: log.warning("TLS verification is off: the access code goes to whatever answers at %s", ip) - with self._timed("mqtt"): - report = (self.reader_factory or MqttReader)(ip, serial, code, self.wait, self.insecure).read() - if self.insecure and report.get("_topic", f"device/{serial}/report") != f"device/{serial}/report": - raise PrinterError(f"host {ip} answered for another device, not printer {conf.mask(serial)}") + try: + with self._timed("mqtt"): + report = (self.reader_factory or MqttReader)(ip, r.serial, r.code, self.wait, self.insecure).read() + except LoginRefused as e: + raise LoginRefused(self._refusal_text(str(e), r.code_source, ip)) from None + if self.insecure and report.get("_topic", f"device/{r.serial}/report") != f"device/{r.serial}/report": + raise PrinterError(f"host {ip} answered for another device, not printer {conf.mask(r.serial)}") return report - def _resolve_and_read(self) -> tuple[str, str, dict]: - ip, serial, code = self.resolve() - return ip, serial, self._read(ip, serial, code) + def _refusal_text(self, base: str, code_source: str, ip: str) -> str: + label = SOURCE_LABELS.get(code_source, code_source) + who = f", profile {self.profile.name}" if self.profile is not None else "" + mode = self.profile.mode if self.profile is not None else "unknown" + login = f"--printer {self.profile.name}" if self.profile is not None else f"--ip {ip}" + hint = REFUSAL_HINT.get(mode, REFUSAL_HINT["unknown"]).format(login=login) + return conf.scrub_text(f"{base} (source: {label}{who})\n{hint}") # a profile name can hold a code read later + + def _resolve_and_read(self) -> ReadResult: + """One read. When the read at the profile's IP cannot connect, the error names the profile, its last login and + the commands that record a new address; the profile is never written here.""" + r = self.resolve() + try: + return ReadResult(self._read(r), r) + except LoginRefused: + raise + except PrinterError as e: + profile = self.profile + if not (r.ip_source == "profile" and profile is not None and str(e).startswith("cannot connect")): + raise + v = profile.verified + stamp = f"{v[:10]} {v[11:16]} UTC" if v else "never" + raise PrinterError( + conf.scrub_text( + f"{e} The profile {profile.name} expected this address (last login {stamp}); run: shambu printer login " + f"--printer {profile.name} --ip , or shambu printer discover" + ) + ) from None def raw_report(self) -> dict: """The merged report as the printer sent it, with shambu's transport keys. Internal: print `public_report`.""" - return self._bounded(self._resolve_and_read)[2] + return self._bounded(self._resolve_and_read).report def public_report(self) -> dict: """The report for `printer raw`: `public_raw()` of one read, which removes the transport metadata (`_topic` names the full serial), the network and wifi fields, secret-looking keys, and any text naming the IP or the serial.""" - ip, serial, report = self._bounded(self._resolve_and_read) - return public_raw(report, ip, serial) + res = self._bounded(self._resolve_and_read) + return public_raw(res.report, res.resolved.ip, res.resolved.serial) def status(self) -> Status: - ip, serial, report = self._bounded(self._resolve_and_read) - return StatusDecoder(conf.hms_table(serial[:3])).decode(report, serial, ip) + res = self._bounded(self._resolve_and_read) + return StatusDecoder(conf.hms_table(res.resolved.serial[:3])).decode(res.report, res.resolved.serial, res.resolved.ip) def filaments(self, resolver=None, printer_preset: str | None = None) -> LoadedFilaments: """The spools in each nozzle and slot, from the same single `pushall` as `status()`. Read-only.""" - ip, serial, report = self._bounded(self._resolve_and_read) - return FilamentDecoder(resolver, printer_preset).decode(report, serial, ip) + res = self._bounded(self._resolve_and_read) + return FilamentDecoder(resolver, printer_preset).decode(res.report, res.resolved.serial, res.resolved.ip) def watch( self, @@ -1317,15 +1827,16 @@ def _interval(every_s: float, st: Status, running_since: float | None, clock: Ca return poll_interval(every_s, st, None if running_since is None else clock() - running_since) +SETUP_MESSAGE = "shambu is not set up for the printer (shambu printer login, or Bambu Studio's Device tab)" ERROR_CATEGORIES = ( # (category, words in the PrinterError text, the fixed message a watch log holds) ("timeout", ("timed out",), "the read timed out"), ("tls", ("TLS", "certificate", "CA bundle"), "the TLS check of the printer failed"), - ("login", ("refused the MQTT login",), "the printer refused the MQTT login (check the LAN access code in Studio)"), + ("login", ("refused the MQTT login",), "the printer refused the access code (run shambu printer login, or check the code in Studio)"), ("discovery", ("no host on this LAN",), "no printer answered on port 8883 on this LAN"), ("identity", ("another device",), "the host answered for another device"), ("report", ("no full report", "no report"), "the printer sent no full report in time"), ("connect", ("cannot connect",), "the connection to the printer failed"), - ("setup", ("paho-mqtt missing", "no printer selected", "no access code"), "shambu or Bambu Studio is not set up for the printer"), + ("setup", ("paho-mqtt missing", "no printer selected", "no access code"), SETUP_MESSAGE), ) @@ -1333,7 +1844,7 @@ def error_category(e: BaseException) -> tuple[str, str]: """(category, fixed message) for a failed read. The message never quotes the exception: the lower layers put the IP, the host name, the masked serial and the TLS names in their text, and a watch log must hold none of them.""" if isinstance(e, ConfError): # Studio's config: no printer selected, no access code (its text names the serial) - return "setup", "shambu or Bambu Studio is not set up for the printer" + return "setup", SETUP_MESSAGE text = str(e) for cat, words, msg in ERROR_CATEGORIES: if any(w in text for w in words): @@ -1375,7 +1886,8 @@ def scrub(text: str | None, secrets: list[str]) -> str | None: if text is None: return None for v in secrets: - text = text.replace(v, "") + if v: # an empty value would put '' between every character + text = text.replace(v, "") return text diff --git a/src/shambu/profiles.py b/src/shambu/profiles.py new file mode 100644 index 0000000..403108e --- /dev/null +++ b/src/shambu/profiles.py @@ -0,0 +1,219 @@ +"""Printer profiles: the schema, the validator and the pure Profile/ProfileSet types for printers.json. No I/O (conf.py +reads and writes the file), no access code (vault.py), no network. + +A profile names one printer: its serial, its last known IP, the connection mode, whether the TLS check is skipped, when +it was added and when its last successful `login` was (`verified`). The model, the firmware and where the code is kept +are not stored: shambu derives them when it needs them. `validate_profile` and `validate_document` refuse anything else +with the fixed `REFUSAL` text: no extra key, no identifier (IP, host name, masked serial), serial or known secret in a +name, no IPv6 scope id, typed fields only. A refusal never echoes the refused content. +""" + +from __future__ import annotations + +import ipaddress +import re +from collections.abc import Iterable +from dataclasses import dataclass, field, replace +from datetime import datetime + +from . import conf +from .errors import BambuError + +PROFILES_VERSION = 1 +MODES = ("lan", "cloud", "unknown") +REFUSAL = "a printer profile must match the schema: fixed fields, typed values, no identifier or secret in a name" +CLASH = "another printer profile already has that name" +UNKNOWN = "no printer profile has that name" + +_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,31}") +_SERIAL = re.compile(r"[A-Za-z0-9]{8,32}") +_STAMP = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z") +_STAMP_FORMAT = "%Y-%m-%dT%H:%M:%SZ" +_KEYS = frozenset({"name", "serial", "ip", "mode", "insecure", "added", "verified"}) +_DOC_KEYS = frozenset({"version", "default", "printers"}) + +identifying = conf.identifying # one detector (conf.py) for the profile names and every outward text + + +class ProfileError(BambuError): + """A printer profile or the printers document does not match the schema, or a change to the set is refused.""" + + +def _holds_secret(v: str, secrets: Iterable[str]) -> bool: + """True when any non-empty secret is in `v`, case-insensitively.""" + low = v.casefold() + return any(s and s.casefold() in low for s in secrets) + + +def _ip(v) -> bool: + """An IPv4 or IPv6 address with no scope id (`%`).""" + if not isinstance(v, str) or "%" in v: + return False + try: + ipaddress.ip_address(v) + except ValueError: + return False + return True + + +def _stamp(v) -> bool: + """A UTC time stamp `YYYY-MM-DDTHH:MM:SSZ` in ASCII digits that is a real date and time.""" + if not isinstance(v, str) or _STAMP.fullmatch(v) is None: + return False + try: + datetime.strptime(v, _STAMP_FORMAT) + except ValueError: + return False + return True + + +def valid_name(name: str) -> bool: + """True when `name` passes the profile name rule of `validate_profile`: 1 to 32 letters, digits, '.', '_' or '-', + starting with a letter or a digit, and no IP, host name or masked serial (`identifying`).""" + return isinstance(name, str) and _NAME.fullmatch(name) is not None and not identifying(name) + + +def validate_profile(d, secrets: Iterable[str] = ()) -> None: + """Raises ProfileError(REFUSAL) unless `d` is one printer profile with exactly the schema's keys and valid values. + `name` and `ip` must hold none of `secrets`; `name` must also hold neither the serial nor its mask (any case).""" + secrets = tuple(secrets) + if not isinstance(d, dict) or set(d) != _KEYS: + raise ProfileError(REFUSAL) + name, serial, ip = d["name"], d["serial"], d["ip"] + if not isinstance(serial, str) or _SERIAL.fullmatch(serial) is None: + raise ProfileError(REFUSAL) + ok = ( + isinstance(name, str) + and _NAME.fullmatch(name) is not None + and not identifying(name) + and not _holds_secret(name, (*secrets, serial, conf.mask(serial))) + and (ip is None or (_ip(ip) and not _holds_secret(ip, secrets))) + and d["mode"] in MODES + and type(d["insecure"]) is bool + and _stamp(d["added"]) + and (d["verified"] is None or _stamp(d["verified"])) + ) + if not ok: + raise ProfileError(REFUSAL) + + +def validate_document(doc, secrets: Iterable[str] = ()) -> None: + """Raises ProfileError(REFUSAL) unless `doc` is a version-1 printers document: valid profiles, unique names and + serials, and a default that is None or the name of an entry. Each name and IP is checked against every serial and + mask in the document, not only its own, so a document the writer accepts is accepted on every later load.""" + secrets = tuple(secrets) + if not isinstance(doc, dict) or set(doc) != _DOC_KEYS: + raise ProfileError(REFUSAL) + if type(doc["version"]) is not int or doc["version"] != PROFILES_VERSION or not isinstance(doc["printers"], list): + raise ProfileError(REFUSAL) + serials_in_doc = [d["serial"] for d in doc["printers"] if isinstance(d, dict) and isinstance(d.get("serial"), str)] + ids = tuple(v for s in serials_in_doc if s for v in (s, conf.mask(s)) if v != "…") + for d in doc["printers"]: + validate_profile(d, (*secrets, *ids)) + names = [d["name"] for d in doc["printers"]] + serials = [d["serial"] for d in doc["printers"]] + if len(set(names)) != len(names) or len({x.casefold() for x in serials}) != len(serials): + raise ProfileError(REFUSAL) + if doc["default"] is not None and (not isinstance(doc["default"], str) or doc["default"] not in names): + raise ProfileError(REFUSAL) + + +@dataclass(frozen=True) +class Profile: + """One printer: its serial, last known IP, mode, TLS flag, when it was added and when its last successful `login` + was (`verified`). The serial and the IP stay out of the repr.""" + + name: str + serial: str = field(repr=False) + ip: str | None = field(repr=False) + mode: str + insecure: bool + added: str + verified: str | None + + @classmethod + def from_dict(cls, d, secrets: Iterable[str] = ()) -> Profile: + """The profile in `d`, validated first (ProfileError on any refusal).""" + validate_profile(d, secrets) + return cls(**d) + + def to_dict(self) -> dict: + """The stored form: every schema key.""" + return { + "name": self.name, + "serial": self.serial, + "ip": self.ip, + "mode": self.mode, + "insecure": self.insecure, + "added": self.added, + "verified": self.verified, + } + + def public(self) -> dict: + """The stored form with the serial masked (the IP stays: `printer list` is the one place it shows).""" + return self.to_dict() | {"serial": conf.mask(self.serial)} + + +@dataclass(frozen=True) +class ProfileSet: + """Every printer profile and the name of the default one. Changes return a new set.""" + + default: str | None = None + printers: tuple[Profile, ...] = () + + def get(self, name: str) -> Profile | None: + """The profile named `name`, or None.""" + return next((p for p in self.printers if p.name == name), None) + + def by_serial(self, serial: str) -> Profile | None: + """The profile for `serial` (compared without regard to letter case), or None.""" + return next((p for p in self.printers if p.serial.casefold() == serial.casefold()), None) + + def selected(self, name: str | None = None) -> Profile | None: + """The profile named `name`, or the default profile when `name` is None; None when there is none.""" + if name is None: + return self.get(self.default) if self.default is not None else None + return self.get(name) + + def upsert(self, p: Profile, default: bool = False, secrets: Iterable[str] = ()) -> ProfileSet: + """The set with `p` added, or replacing the profile with the same serial (letter case ignored) whatever its name + (its `added` and its stored serial kept). + ProfileError(CLASH) when another serial already has the name, ProfileError(REFUSAL) when `p` does not validate + against `secrets`. The first profile becomes the default; `default=True` makes `p` the default.""" + validate_profile(p.to_dict(), secrets) + old = self.by_serial(p.serial) + clash = self.get(p.name) + if clash is not None and clash.serial.casefold() != p.serial.casefold(): + raise ProfileError(CLASH) + if old is None: + printers = (*self.printers, p) + else: + p = replace(p, added=old.added, serial=old.serial) # the stored case is canonical + printers = tuple(p if x.serial == p.serial else x for x in self.printers) + new_default = self.default + if old is not None and self.default == old.name: + new_default = p.name # a rename keeps the default on the same printer + if new_default is None or default: + new_default = p.name + return ProfileSet(default=new_default, printers=printers) + + def remove(self, name: str) -> ProfileSet: + """The set without the profile named `name`; when it was the default, the first remaining profile (or None) + becomes the default. ProfileError when no profile has that name.""" + if self.get(name) is None: + raise ProfileError(UNKNOWN) + printers = tuple(p for p in self.printers if p.name != name) + new_default = self.default + if self.default == name: + new_default = printers[0].name if printers else None + return ProfileSet(default=new_default, printers=printers) + + @classmethod + def from_dict(cls, doc, secrets: Iterable[str] = ()) -> ProfileSet: + """The set in a printers document, validated first against `secrets` (ProfileError on any refusal).""" + validate_document(doc, secrets) + return cls(default=doc["default"], printers=tuple(Profile(**d) for d in doc["printers"])) + + def to_dict(self) -> dict: + """The printers document: version, default and every profile in order.""" + return {"version": PROFILES_VERSION, "default": self.default, "printers": [p.to_dict() for p in self.printers]} diff --git a/src/shambu/vault.py b/src/shambu/vault.py new file mode 100644 index 0000000..87150ec --- /dev/null +++ b/src/shambu/vault.py @@ -0,0 +1,211 @@ +"""The LAN access code at rest: one 0600 JSON file, `credentials.json` in shambu's config dir (`conf.CREDENTIALS`). + +What it handles: the `Vault` Protocol (get, put, delete a code by serial), the shape checks a serial and a code must +pass before they are stored (`safe_serial`, `safe_code`), `FileVault` (the file), `MemoryVault` (the in-memory +implementation the tests use) and `default_vault()`. The file is `{"version": 1, "codes": {"": ""}}`, +written atomically with mode 0600 under a lock on `credentials.json.lock`. A file with any group or other permission +bit is refused, and so is a symlink, anything that is not a regular file and a file another user owns (`NOT_PRIVATE`). +Every code a vault reads or is given is registered with `conf.remember_secret`, so every outward text can scrub it: a +read registers every code in the file, and a put registers its code before it touches any file. + +What it does not do: printer profiles (profiles.py, conf.py), IP addresses, Bambu Studio's config (conf.py) or any +network access. A code never appears in a repr, an error text or a log line. + +Threat model: the file gets the same protection as Bambu Studio's own plain-text config against another process that +runs as the same user, with a tighter file mode, and it lives outside any repository. It does not protect the code +from that user's own processes or from anyone who can read that user's files. +""" + +from __future__ import annotations + +import json +import os +import re +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import Protocol, TypeVar + +from . import conf +from .errors import BambuError + +T = TypeVar("T") + +CREDENTIALS_VERSION = 1 +UNSAFE = "a printer serial must be 8 to 32 letters or digits, and a code 4 to 128 characters with no space, quote or backslash" +BAD_FILE = ( + 'the credentials file is not a shambu credentials document (want {"version": 1, "codes": {...}}): ' + "move it away and run shambu printer login again" +) +NOT_PRIVATE = "{path}: not a regular file owned by this user; remove it and run shambu printer login again" + +# At least four characters, no whitespace, quote or backslash, no leading '-'. +_SAFE_CODE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:@+=/-]{3,127}") +_SAFE_SERIAL = re.compile(r"[A-Za-z0-9]{8,32}") + + +class VaultError(BambuError): + """The credentials store refused or failed a read, a write or a delete, or a serial or code has an unsafe shape.""" + + +def safe_code(code) -> bool: + """True when `code` is 4 to 128 safe characters (a sanity rule for user input).""" + return isinstance(code, str) and _SAFE_CODE.fullmatch(code) is not None + + +def safe_serial(serial) -> bool: + """True when `serial` is 8 to 32 letters or digits.""" + return isinstance(serial, str) and _SAFE_SERIAL.fullmatch(serial) is not None + + +class Vault(Protocol): + """Where a printer's code is kept at rest, by serial.""" + + def get(self, serial: str) -> str | None: + """The code for `serial`, or None when there is none.""" + ... + + def put(self, serial: str, code: str) -> None: + """Store `code` for `serial`, replacing any code there.""" + ... + + def delete(self, serial: str) -> bool: + """Remove the code for `serial`; False when there was none.""" + ... + + +@dataclass +class MemoryVault: + """A `Vault` in a dict: the test fake and the second implementation of the Protocol. `calls` records + `(op, serial)` and never the code; when `error` is set, every call raises it after recording the call. Unsafe + serials and codes are refused before anything is recorded, as `FileVault` refuses them.""" + + items: dict[str, str] = field(default_factory=dict, repr=False) + calls: list[tuple[str, str]] = field(default_factory=list) + error: Exception | None = None + + def _call(self, op: str, serial: str) -> None: + if not safe_serial(serial): + raise VaultError(UNSAFE) + self.calls.append((op, serial)) + if self.error is not None: + raise self.error + + def get(self, serial: str) -> str | None: + """The stored code; every stored code is registered with `conf.remember_secret`, as `FileVault` registers every + code in its file. None when there is none.""" + self._call("get", serial) + for c in self.items.values(): + conf.remember_secret(c) + return self.items.get(serial) + + def put(self, serial: str, code: str) -> None: + """Store `code` for `serial` (registered with `conf.remember_secret` first); VaultError for an unsafe code.""" + conf.remember_secret(code) + if not safe_code(code): + raise VaultError(UNSAFE) + self._call("put", serial) + self.items[serial] = code + + def delete(self, serial: str) -> bool: + """Remove the code for `serial`; False when there was none.""" + self._call("delete", serial) + return self.items.pop(serial, None) is not None + + +@dataclass +class FileVault: + """A `Vault` in `credentials.json`. `path` None means `conf.CREDENTIALS`, read at each call. Serials and codes are + checked before any file is touched; every failure is a VaultError whose text never holds a code.""" + + path: Path | None = None + + def _path(self) -> Path: + return self.path if self.path is not None else conf.CREDENTIALS + + def _load(self, p: Path) -> dict[str, str]: + """The codes in the file; {} when it does not exist. VaultError for a symlink, anything that is not a regular + file owned by this user (`NOT_PRIVATE`), a file that is not private or not a version-1 credentials document. + The open (`conf.open_private`) never follows a link and never blocks (a FIFO at the path is refused, not waited on).""" + try: + fd = conf.open_private(p, NOT_PRIVATE.format(path=p)) + except FileNotFoundError: + return {} + except conf.DataFileError as e: + raise VaultError(str(e)) from None + with os.fdopen(fd, encoding="utf-8") as f: + try: + st = os.fstat(f.fileno()) + except OSError as e: + raise VaultError(f"{p}: cannot read it ({e.strerror})") from None + if st.st_mode & 0o077: + raise VaultError(f"{p} must be private: chmod 600 {p}") + try: + d = json.loads(f.read()) + except OSError as e: + raise VaultError(f"{p}: cannot read it ({e.strerror})") from None + except ValueError: + raise VaultError(BAD_FILE) from None + if ( + not isinstance(d, dict) + or set(d) != {"version", "codes"} + or type(d["version"]) is not int + or d["version"] != CREDENTIALS_VERSION + or not isinstance(d["codes"], dict) + or not all(safe_serial(k) and safe_code(v) for k, v in d["codes"].items()) + ): + raise VaultError(BAD_FILE) + for c in d["codes"].values(): # every code in the file, not only the one asked for + conf.remember_secret(c) + return dict(d["codes"]) + + def _save(self, p: Path, codes: dict[str, str]) -> None: + conf._atomic_write(p, {"version": CREDENTIALS_VERSION, "codes": codes}, ".credentials-", mode=0o600) + + def _change(self, p: Path, fn: Callable[[dict[str, str]], T]) -> T: + """`fn(codes)` under the lock on `credentials.json.lock`; a lock or write failure becomes a VaultError.""" + try: + return conf._locked(p, lambda: fn(self._load(p))) + except conf.DataFileError as e: + raise VaultError(str(e)) from None + + def get(self, serial: str) -> str | None: + """The code for `serial`, registered with `conf.remember_secret`; None when there is none.""" + if not safe_serial(serial): + raise VaultError(UNSAFE) + return self._load(self._path()).get(serial) or None # `_load` registered every code + + def put(self, serial: str, code: str) -> None: + """Store `code` for `serial`, replacing any code there. The code is registered with `conf.remember_secret` before + any file is touched.""" + conf.remember_secret(code) + if not safe_serial(serial) or not safe_code(code): + raise VaultError(UNSAFE) + p = self._path() + + def run(codes: dict[str, str]) -> None: + codes[serial] = code + self._save(p, codes) + + self._change(p, run) + + def delete(self, serial: str) -> bool: + """Remove the code for `serial`; False when there was none.""" + if not safe_serial(serial): + raise VaultError(UNSAFE) + p = self._path() + + def run(codes: dict[str, str]) -> bool: + if serial not in codes: + return False + del codes[serial] + self._save(p, codes) + return True + + return self._change(p, run) + + +def default_vault() -> Vault: + """The vault the commands use: the credentials file in the config dir. Callers call it lazily, at use; the tests' + `offline` fixture patches it.""" + return FileVault() diff --git a/tests/conftest.py b/tests/conftest.py index 1c6187b..3b1e485 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,33 +6,50 @@ import json import os import shutil +import types import zipfile from dataclasses import dataclass, field from pathlib import Path import pytest -from shambu import conf +from shambu import conf, printer, vault from shambu import presets as P from shambu.errors import PrinterError from shambu.facade import Bambu from shambu.studio import BINARY, CliCommand, MeshInfo, OrientCandidate, OrientResult, OrientTable, RunResult FIXTURES = Path(__file__).parent / "fixtures" +IDENTITY_CONTEXT = types.SimpleNamespace(name="stand-in CA context") # what a fake `cert_serial` receives as `ctx` +REAL_IDENTITY_CONTEXT = printer._identity_context # saved before `offline` patches it @pytest.fixture(autouse=True) def offline(monkeypatch, tmp_path_factory): """No test touches the LAN or this Mac's Bambu Studio config: the facade's printer read fails as an unreachable - printer does (the WARN fallback), and BambuStudio.conf is absent. A test that wants a report or a config - monkeypatches `Bambu.loaded_filaments` or `conf.CONF` again on top of this.""" + printer does (the WARN fallback), BambuStudio.conf is absent, Studio's support folder is an empty tmp dir, printers.json and credentials.json are missing files + in a config dir that does not exist yet (so its 0700 creation runs), `vault.default_vault()` returns one + `MemoryVault` per test, the identity checks of discovery get a stand-in CA context (`printer._identity_context`; no + test reads Studio's real CA bundle) and the runtime secret and identifier sets start and end empty. A test that wants a report or + a config monkeypatches `Bambu.loaded_filaments`, `conf.CONF` or `conf.PRINTERS` again on top of this; a test that + requests `offline` by name seeds the vault through `offline.vault`.""" def unreachable(self, *a, **k): raise PrinterError("no printer in tests") monkeypatch.setattr(Bambu, "loaded_filaments", unreachable) monkeypatch.setattr(conf, "CONF", tmp_path_factory.mktemp("conf") / "no-such-BambuStudio.conf") + monkeypatch.setattr(conf, "APPSUPPORT", tmp_path_factory.mktemp("appsupport")) # hms_table() reads nothing real monkeypatch.setattr(conf, "CALIBRATION", tmp_path_factory.mktemp("calibration") / "no-such-calibration.json") + config = tmp_path_factory.mktemp("config") / "shambu" # not created: the first write makes it 0700 + monkeypatch.setattr(conf, "PRINTERS", config / "printers.json") + monkeypatch.setattr(conf, "CREDENTIALS", config / "credentials.json") + fake = vault.MemoryVault() + monkeypatch.setattr(vault, "default_vault", lambda: fake) + monkeypatch.setattr(printer, "_identity_context", lambda: IDENTITY_CONTEXT) + conf.forget_secrets() + yield types.SimpleNamespace(vault=fake) + conf.forget_secrets() MACHINE = "Test X2D 0.4 nozzle" diff --git a/tests/test_cli.py b/tests/test_cli.py index 0ef2400..18d5827 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ """cli.main: subcommands, --json output, exit codes. Bambu() is replaced by a facade over fakes.""" import json +import re import subprocess import zipfile from dataclasses import dataclass, field @@ -1185,3 +1186,724 @@ def test_no_probe_flag_on_check_build_and_sweep(): p = cli.build_parser() for argv in (["check", "s.json", "--no-probe"], ["build", "s.json", "--no-probe"], ["sweep", "s.json", "--no-probe"]): assert p.parse_args(argv).no_probe is True + + +# ---------------------------------------------------------------------------- 0.5.0: login, list, forget, --printer, one-shot codes +P_SERIAL = "00X0TEST0000999" +P_OTHER = "01P0TEST0000123" +P_CODE = "SENTINEL-CODE-8812" +P_IP = "10.0.0.9" +P_NEW_IP = "10.0.0.20" +P_MASKED = "00X…999" +P_STAMP = "2026-10-01T08:00:00Z" + + +class LanReader: + """Stands in for MqttReader: records (ip, serial, insecure) and whether the code was the expected one, never the code.""" + + seen: list = [] + codes_ok: list = [] + waits: list = [] + refuse = False + + def __init__(self, ip, serial, access_code, wait=8.0, insecure=False): + LanReader.seen.append((ip, serial, insecure)) + LanReader.waits.append(wait) + LanReader.codes_ok.append(access_code == P_CODE) + self.ip = ip + + def read(self): + from shambu import printer + + if LanReader.refuse: + raise printer.LoginRefused(f"the printer at {self.ip} refused the MQTT login") + return {"print": {"gcode_state": "IDLE"}, "_tls_verified": True} + + +@pytest.fixture +def lan(monkeypatch): + from shambu import printer + + LanReader.seen, LanReader.codes_ok, LanReader.waits, LanReader.refuse = [], [], [], False + monkeypatch.setattr(printer, "MqttReader", LanReader) + monkeypatch.setattr(printer, "certificate_serial", lambda ip, deadline=None: P_SERIAL) + return LanReader + + +@pytest.fixture +def studio_conf(tmp_path, monkeypatch): + from shambu import conf + + p = tmp_path / "BambuStudio.conf" + p.write_text(json.dumps({"app": {"user_last_selected_machine": P_SERIAL}, "access_code": {P_SERIAL: P_CODE}})) + monkeypatch.setattr(conf, "CONF", p) + monkeypatch.setattr(conf, "APPSUPPORT", tmp_path) + return p + + +def code_stdin(monkeypatch, text=P_CODE + "\n"): + import io + + monkeypatch.setattr("sys.stdin", io.StringIO(text)) + + +def a_profile(name="x2d", serial=P_SERIAL, ip=P_IP, mode="lan"): + from shambu.profiles import Profile + + return Profile(name, serial, ip, mode, False, P_STAMP, P_STAMP) + + +def store_profiles(*profiles, default=None): + from shambu import conf + from shambu.profiles import ProfileSet + + conf.save_profiles(ProfileSet(default=default or profiles[0].name, printers=tuple(profiles))) + + +def stored(): + from shambu import conf + + return json.loads(conf.PRINTERS.read_text()) if conf.PRINTERS.is_file() else None + + +def test_login_from_studio_stores_the_profile_and_the_code(capsys, lan, studio_conf, offline): + code, out, err = run(capsys, "printer", "login", "--from-studio", "--ip", P_IP, "--mode", "lan") + assert code == 0 + assert out.startswith(f"profile printer: serial {P_MASKED}, code stored, mode lan, read at ") and out.rstrip().endswith("(default)") + assert re.search(r"read at \d\d:\d\d UTC \(default\)$", out.rstrip()) + assert offline.vault.items == {P_SERIAL: P_CODE} + (p,) = stored()["printers"] + assert (p["name"], p["serial"], p["ip"], p["mode"], p["insecure"]) == ("printer", P_SERIAL, P_IP, "lan", False) + assert p["verified"] and p["added"] and lan.codes_ok == [True] + assert P_CODE not in out + err and P_SERIAL not in out + err and P_IP not in out + err + + +def test_login_ip_takes_the_serial_from_the_certificate(capsys, lan, monkeypatch, offline): + code_stdin(monkeypatch) + code, out, err = run(capsys, "--json", "printer", "login", "--ip", P_IP, "--access-code-stdin", "--name", "x2d") + assert code == 0 + assert one_json(out) == { + "name": "x2d", + "serial_masked": P_MASKED, + "mode": "unknown", + "verified": stored()["printers"][0]["verified"], + "default": True, + "code_stored": True, + } + assert stored()["printers"][0]["serial"] == P_SERIAL and lan.seen == [(P_IP, P_SERIAL, False)] + assert offline.vault.items == {P_SERIAL: P_CODE} and P_CODE not in out + err + + +def test_login_ip_serial_mismatch_writes_nothing(capsys, lan, monkeypatch, offline): + code_stdin(monkeypatch) + code, out, err = run(capsys, "printer", "login", "--ip", P_IP, "--serial", P_OTHER, "--access-code-stdin") + assert code == 3 + assert "presents a certificate for printer" in err and P_MASKED in err and P_IP in err # the masks and the typed IP stay + assert P_SERIAL not in err and P_OTHER not in err + assert stored() is None and offline.vault.items == {} and lan.seen == [] + assert P_CODE not in out + err + + +def test_login_refresh_keeps_added_and_serial_and_moves_the_ip(capsys, lan, monkeypatch, offline): + store_profiles(a_profile()) + code_stdin(monkeypatch) + code, out, _ = run(capsys, "printer", "login", "--printer", "x2d", "--ip", P_NEW_IP, "--access-code-stdin") + assert code == 0 and out.startswith(f"profile x2d: serial {P_MASKED}, code stored, mode lan, ") + (p,) = stored()["printers"] + assert (p["serial"], p["ip"], p["added"], p["mode"]) == (P_SERIAL, P_NEW_IP, P_STAMP, "lan") + assert p["verified"] != P_STAMP and lan.seen == [(P_NEW_IP, P_SERIAL, False)] + + +def test_login_default_profile_when_nothing_is_given(capsys, lan, monkeypatch, offline): + store_profiles(a_profile()) + code_stdin(monkeypatch) + assert run(capsys, "printer", "login", "--access-code-stdin", "--mode", "cloud")[0] == 0 + assert stored()["printers"][0]["mode"] == "cloud" and lan.seen == [(P_IP, P_SERIAL, False)] + + +@pytest.mark.parametrize( + ("argv", "words"), + [ + (["--printer", "x2d", "--serial", P_OTHER], "--serial does not match profile x2d"), + ([], "--from-studio"), + (["--ip", P_IP, "--insecure"], "--insecure cannot read the printer's certificate"), + (["--from-studio", "--access-code-stdin"], "--from-studio takes the serial"), + (["--from-studio", "--serial", P_SERIAL], "--from-studio takes the serial"), + (["--printer", "nope"], "no printer profile named 'nope'"), + ], +) +def test_login_usage_errors_before_any_read(capsys, lan, monkeypatch, offline, argv, words): + if argv[:2] == ["--printer", "x2d"]: + store_profiles(a_profile()) + code_stdin(monkeypatch) + code, out, _ = run(capsys, "printer", "login", *argv) + assert code == 2 and words in out + assert lan.seen == [] and offline.vault.items == {} and P_OTHER not in out + + +def test_login_refused_writes_nothing(capsys, lan, monkeypatch, offline): + lan.refuse = True + code_stdin(monkeypatch) + code, out, err = run(capsys, "printer", "login", "--ip", P_IP, "--access-code-stdin") + assert code == 3 and "LoginRefused" in err and "refused the MQTT login" in err + assert stored() is None and offline.vault.items == {} and P_CODE not in out + err and P_IP in err # the IP the user typed + + +@pytest.mark.parametrize("previous", [None, "OLD-CODE-1234"]) +def test_login_profile_write_failure_restores_the_vault(capsys, lan, monkeypatch, offline, previous): + from shambu import conf + from shambu.conf import DataFileError + + if previous: + offline.vault.items[P_SERIAL] = previous + + def fails(ps, path=None): + raise DataFileError("printers.json: cannot write it (No space left on device)") + + monkeypatch.setattr(conf, "save_profiles", fails) + code_stdin(monkeypatch) + code, _out, err = run(capsys, "printer", "login", "--ip", P_IP, "--access-code-stdin") + assert code == 3 and "DataFileError" in err + assert offline.vault.items == ({P_SERIAL: previous} if previous else {}) + assert ("put", P_SERIAL) in offline.vault.calls + + +def test_login_access_code_file(capsys, lan, tmp_path, offline): + f = tmp_path / "code.txt" + f.write_text(P_CODE + "\nsecond line\n") + code, out, err = run(capsys, "printer", "login", "--ip", P_IP, "--access-code-file", str(f)) + assert code == 0 and offline.vault.items == {P_SERIAL: P_CODE} and lan.codes_ok == [True] + assert P_CODE not in out + err + code, out, _ = run(capsys, "printer", "login", "--ip", P_IP, "--access-code-file", str(tmp_path / "none.txt")) + assert code == 2 and "cannot read it" in out + + +@pytest.mark.parametrize(("text", "words"), [("\n", "empty"), ("has space\n", "no space, quote or backslash"), ("", "empty")]) +def test_login_bad_code_is_usage(capsys, lan, monkeypatch, text, words): + code_stdin(monkeypatch, text) + code, out, _ = run(capsys, "printer", "login", "--ip", P_IP, "--access-code-stdin") + assert code == 2 and words in out and lan.seen == [] + + +def test_login_without_a_terminal_or_a_code_flag_is_usage(capsys, lan, monkeypatch): + code_stdin(monkeypatch, P_CODE + "\n") # a pipe, not a terminal: never read without the flag + code, out, _ = run(capsys, "printer", "login", "--ip", P_IP) + assert code == 2 and "no access code: give --access-code-stdin or --access-code-file" in out and lan.seen == [] + + +def test_login_prompt_that_would_echo_is_usage(capsys, lan, monkeypatch): + import getpass + import io + import sys + import warnings + + class Tty(io.StringIO): + def isatty(self): + return True + + def echoing(prompt=""): + warnings.warn("Can not control echo on the terminal.", getpass.GetPassWarning, stacklevel=2) + return P_CODE + + monkeypatch.setattr("sys.stdin", Tty()) + monkeypatch.setattr(sys.stderr, "isatty", lambda: True, raising=False) + monkeypatch.setattr(getpass, "getpass", echoing) + code, out, err = run(capsys, "printer", "login", "--ip", P_IP) + assert code == 2 and "cannot hide the access code" in out and lan.seen == [] and P_CODE not in out + err + + +def test_login_prompt_on_a_terminal(capsys, lan, monkeypatch, offline): + import getpass + import io + import sys + + class Tty(io.StringIO): + def isatty(self): + return True + + prompts = [] + monkeypatch.setattr("sys.stdin", Tty()) + monkeypatch.setattr(sys.stderr, "isatty", lambda: True, raising=False) + monkeypatch.setattr(getpass, "getpass", lambda prompt="": prompts.append(prompt) or f" {P_CODE} ") + assert run(capsys, "printer", "login", "--ip", P_IP)[0] == 0 + assert prompts == ["LAN access code (not shown): "] and offline.vault.items == {P_SERIAL: P_CODE} + + +def test_list_text_and_json(capsys): + store_profiles(a_profile(), a_profile("p1s", P_OTHER, None, "cloud")) + code, out, err = run(capsys, "printer", "list") + assert code == 0 + assert out.splitlines() == [ + f"x2d {P_MASKED} {P_IP} lan verified 08:00 UTC (default)", + "p1s 01P…123 - cloud verified 08:00 UTC", + ] + code, out, _ = run(capsys, "--json", "printer", "list") + d = one_json(out) + assert code == 0 and d["default"] == "x2d" and d["config_dir"] + assert [(p["name"], p["serial"], p["ip"]) for p in d["printers"]] == [("x2d", P_MASKED, P_IP), ("p1s", "01P…123", None)] + assert P_SERIAL not in out + err and P_OTHER not in out + + +def test_list_without_profiles_is_exit_1(capsys): + code, out, _ = run(capsys, "printer", "list") + assert code == 1 and out.strip() == "no printer profiles: run shambu printer login" + code, out, _ = run(capsys, "--json", "printer", "list") + assert code == 1 and one_json(out)["printers"] == [] + + +def test_forget_removes_the_profile_and_the_code(capsys, offline): + store_profiles(a_profile(), a_profile("p1s", P_OTHER, None)) + offline.vault.items.update({P_SERIAL: P_CODE, P_OTHER: "OTHER-CODE-1"}) + code, out, err = run(capsys, "printer", "forget", "--printer", "x2d") + assert code == 0 and out.strip() == f"forgot profile x2d (serial {P_MASKED}); code removed" + assert offline.vault.items == {P_OTHER: "OTHER-CODE-1"} + assert [p["name"] for p in stored()["printers"]] == ["p1s"] and stored()["default"] == "p1s" + code, out, _ = run(capsys, "printer", "forget", "--printer", "p1s", "--keep-code") + assert code == 0 and out.strip().endswith("; code kept") and offline.vault.items == {P_OTHER: "OTHER-CODE-1"} + assert stored()["printers"] == [] and P_CODE not in out + err + + +def test_forget_needs_a_name_and_an_existing_profile(capsys): + from shambu import conf + + assert run(capsys, "printer", "forget")[0] == 2 + code, out, _ = run(capsys, "printer", "forget", "--printer", "nope") + assert code == 2 and "no printer profile named 'nope'" in out + assert not conf.PRINTERS.parent.exists() # nothing written: no config dir, no lock file + store_profiles(a_profile()) + code, out, _ = run(capsys, "printer", "forget", "--printer", "nope") + assert code == 2 and [p["name"] for p in stored()["printers"]] == ["x2d"] + + +def test_forget_keeps_a_code_another_profile_still_uses(monkeypatch, offline): + """The schema keeps serials unique, so two profiles never share one on disk; the guard is still checked.""" + from shambu import conf + from shambu.profiles import ProfileSet + + shared = ProfileSet("x2d", (a_profile(), a_profile("twin"))) + monkeypatch.setattr(conf, "locked_profiles", lambda fn, path=None: fn(shared)) + monkeypatch.setattr(conf, "save_profiles", lambda ps, path=None: None) + offline.vault.items[P_SERIAL] = P_CODE + r = Bambu().forget("x2d") + assert (r["code_removed"], r["note"]) == (False, None) and offline.vault.items == {P_SERIAL: P_CODE} + + +def test_forget_reports_a_vault_failure_after_the_removal(capsys, offline): + from shambu.vault import VaultError + + store_profiles(a_profile()) + offline.vault.items[P_SERIAL] = P_CODE + offline.vault.error = VaultError("credentials.json: cannot write it (Permission denied)") + code, out, _ = run(capsys, "printer", "forget", "--printer", "x2d") + assert code == 3 and "code: the profile is gone but the code could not be removed" in out and stored()["printers"] == [] + store_profiles(a_profile()) + code, out, _ = run(capsys, "--json", "printer", "forget", "--printer", "x2d") + d = one_json(out) + assert code == 3 and d["code_removed"] is False and d["note"].startswith("the profile is gone but the code could not be removed") + + +@pytest.mark.parametrize("argv", [["printer", "status"], ["doctor"], ["check", "SPEC"]]) +def test_unknown_printer_profile_is_usage_before_any_read(capsys, lan, monkeypatch, tmp_path, argv): + reads = [] + monkeypatch.setattr(Bambu, "loaded_filaments", lambda self, *a, **k: reads.append(1)) + argv = [str(write_spec(tmp_path)) if x == "SPEC" else x for x in argv] + code, out, _ = run(capsys, *argv, "--printer", "nope") + assert code == 2 and "no printer profile named 'nope'" in out + assert reads == [] and lan.seen == [] + + +def test_doctor_lines_without_a_profile_are_unchanged(capsys, monkeypatch): + """No profile: every doctor line is the line 0.4.0 printed (the profile feature switched off), compared one by one. + The timings line holds measured seconds and is compared by its label only.""" + from shambu import facade + + def lines(): + code, out, _ = run(capsys, "doctor", "--no-printer") + assert code == 0 + return [x.split(" ", 1)[0] if x.startswith("timings:") else x for x in out.splitlines()] + + with_feature = lines() + code, out, _ = run(capsys, "--json", "doctor", "--no-printer") + c = one_json(out)["config"] + assert code == 0 and (c["printer_profile"], c["profiles"]) == (None, 0) and c["config_dir"] + monkeypatch.setattr(cli, "profile_lines", lambda c: []) + monkeypatch.setattr(facade, "profile_section", lambda *a, **k: {}) + without = lines() + assert len(with_feature) == len(without) + for got, want in zip(with_feature, without, strict=True): + assert got == want + + +def test_doctor_shows_the_selected_profile(capsys): + store_profiles(a_profile()) + code, out, _ = run(capsys, "doctor", "--no-printer") + (line,) = [x for x in out.splitlines() if x.startswith("printer profile")] + assert line.startswith( + f"printer profile x2d (default): serial {P_MASKED}, mode lan, last login 08:00 UTC, TLS verified, code: none; 1 profile(s) in " + ) + assert P_IP not in out and P_SERIAL not in out + code, out, _ = run(capsys, "--json", "doctor", "--no-printer", "--printer", "x2d") + assert code == 0 + assert one_json(out)["config"]["printer_profile"] == { + "name": "x2d", + "default": True, + "serial": P_MASKED, + "mode": "lan", + "verified": P_STAMP, + "insecure": False, + "code": "none", + } + + +def test_main_scrubs_a_code_and_a_raw_serial_and_keeps_the_ip_and_the_mask(capsys, monkeypatch): + """As in 0.4.0, the error text keeps the IP and a masked serial; a code and a raw serial never appear.""" + from shambu.errors import PrinterError + + store_profiles(a_profile()) + + class Svc: + def status(self): + raise PrinterError(f"the printer at {P_IP} ({P_SERIAL}, {P_MASKED}) said {P_CODE}") + + monkeypatch.setattr(Bambu, "printer", lambda self, *a, **k: Svc()) + for argv in (["printer", "status"], ["--json", "printer", "status"]): + code_stdin(monkeypatch) + code, out, err = run(capsys, *argv, "--printer", "x2d", "--access-code-stdin") + text = one_json(out)["error"] if argv[0] == "--json" else err.strip() + assert code == 3 and text == f"PrinterError: the printer at {P_IP} (, {P_MASKED}) said " + assert P_CODE not in out + err and P_SERIAL not in out + err + + +def test_status_with_a_one_shot_code_and_a_profile(capsys, lan, monkeypatch, offline): + store_profiles(a_profile()) + offline.vault.items[P_SERIAL] = "STORED-CODE-1" + code_stdin(monkeypatch) + code, out, err = run(capsys, "--json", "printer", "status", "--printer", "x2d", "--access-code-stdin") + assert code == 0 and lan.codes_ok == [True] and lan.seen == [(P_IP, P_SERIAL, False)] # the flag wins over the stored code + assert P_CODE not in out + err and "STORED-CODE-1" not in out + err + + +def test_commands_without_studio_config_or_codes(capsys, monkeypatch): + from shambu import printer + + monkeypatch.setattr(printer.ArpProbe, "find", lambda self: [{"ip": P_IP, "method": "arp+8883"}]) + monkeypatch.setattr(printer.SubnetProbe, "find", lambda self: [{"ip": P_NEW_IP, "method": "subnet+8883"}]) + code, out, _ = run(capsys, "printer", "discover") + assert code == 0 and out.splitlines() == [f"{P_IP} (arp+8883)", f"{P_NEW_IP} (subnet+8883)"] + assert run(capsys, "doctor", "--no-printer")[0] == 0 + assert run(capsys, "printer", "list")[0] == 1 + + +# ---------------------------------------------------------------------------- slice D review fixes +def damaged_printers_file(): + from shambu import conf + + conf.PRINTERS.parent.mkdir(parents=True, exist_ok=True) + conf.PRINTERS.write_text("{bad", encoding="utf-8") + return conf.PRINTERS + + +@pytest.mark.parametrize("extra", [[], ["--printer", "x2d"]]) +def test_doctor_survives_a_damaged_printers_file(capsys, extra): + damaged_printers_file() + code, out, err = run(capsys, "doctor", *extra) + lines = out.splitlines() + assert code == 0 and "{bad" not in out + err + (line,) = [x for x in lines if x.startswith("printer profile")] + assert line.startswith("printer profile: ConfError: a printer profile must match the schema") + for head in ("shambu ", "Bambu Studio ", "profile sources: ", "GUI presets: ", "loaded on the printer", "recipes: ", "timings: "): + assert any(x.startswith(head) for x in lines), head + + +@pytest.mark.parametrize("argv", [["printer", "status", "--ip", "10.0.0.9"], ["check", "SPEC"]]) +def test_a_damaged_printers_file_is_exit_3_naming_the_file(capsys, lan, tmp_path, argv): + path = damaged_printers_file() + argv = [str(write_spec(tmp_path)) if x == "SPEC" else x for x in argv] + code, out, err = run(capsys, *argv) + assert code == 3 and f"ConfError: {path}: a printer profile must match the schema" in err and "fix or remove the file" in err + assert "{bad" not in out + err and lan.seen == [] + + +def test_login_printer_with_from_studio_is_usage(capsys, lan, studio_conf, offline): + store_profiles(a_profile()) + code, out, _ = run(capsys, "printer", "login", "--printer", "x2d", "--from-studio") + assert code == 2 and "--from-studio" in out and "--printer" in out + assert lan.seen == [] and offline.vault.items == {} + + +def test_both_code_flags_are_usage(capsys, lan, monkeypatch, tmp_path, offline): + f = tmp_path / "code.txt" + f.write_text(P_CODE + "\n") + for argv in (["printer", "login", "--ip", P_IP], ["printer", "status", "--ip", P_IP]): + code_stdin(monkeypatch) + code, out, _ = run(capsys, *argv, "--access-code-file", str(f), "--access-code-stdin") + assert code == 2 and "not both" in out + assert lan.seen == [] and offline.vault.items == {} + + +def test_list_keeps_the_mask_and_scrubs_a_code_in_free_text(capsys, monkeypatch): + """The schema refuses a code in a name; the scrub behind `list` still holds when one gets through.""" + from shambu import conf + from shambu.profiles import ProfileSet + + conf.remember_secret(P_CODE) + ps = ProfileSet("x2d", (a_profile(f"x2d-{P_CODE}"),)) + monkeypatch.setattr(conf, "load_profiles", lambda path=None: ps) + for argv in (["printer", "list"], ["--json", "printer", "list"]): + code, out, err = run(capsys, *argv) + if argv[0] == "--json": + (p,) = one_json(out)["printers"] + assert (p["name"], p["serial"]) == ("x2d-", P_MASKED) + else: + assert out.startswith(f"x2d- {P_MASKED} ") + assert code == 0 and P_CODE not in out + err and P_SERIAL not in out + err + + +def test_discover_keeps_the_mask_and_scrubs_a_code_in_free_text(capsys, monkeypatch): + from shambu import conf, printer + + conf.remember_secret(P_CODE) + hit = {"ip": P_IP, "method": "ssdp", "model": f"X2D {P_MASKED}", "connect": f"lan {P_CODE}"} + monkeypatch.setattr(printer.ArpProbe, "find", lambda self: []) + monkeypatch.setattr(printer.SubnetProbe, "find", lambda self: []) + monkeypatch.setattr(printer.SsdpListen, "find", lambda self: [hit]) + for argv in (["printer", "discover", "--ssdp", "1"], ["--json", "printer", "discover", "--ssdp", "1"]): + code, out, err = run(capsys, *argv) + if argv[0] == "--json": + assert one_json(out) == [{**hit, "connect": "lan "}] + else: + assert out.strip() == f"{P_IP} (ssdp, X2D {P_MASKED} lan )" + assert code == 0 and P_CODE not in out + err + + +def test_login_name_renames_and_keeps_the_default(capsys, lan, monkeypatch, offline): + store_profiles(a_profile(), a_profile("p1s", P_OTHER, None, "cloud")) + code_stdin(monkeypatch) + code, out, _ = run(capsys, "printer", "login", "--printer", "x2d", "--name", "studio-x2d", "--access-code-stdin") + assert code == 0 and out.startswith("profile studio-x2d: ") and out.rstrip().endswith("(default)") + d = stored() + assert d["default"] == "studio-x2d" and [p["name"] for p in d["printers"]] == ["studio-x2d", "p1s"] + assert d["printers"][0]["serial"] == P_SERIAL and d["printers"][0]["added"] == P_STAMP + + +def test_login_new_printer_insecure_with_serial(capsys, lan, monkeypatch, offline): + from shambu import printer + + def no_cert(*a, **k): + raise AssertionError("--insecure must not read the certificate") + + monkeypatch.setattr(printer, "certificate_serial", no_cert) + code_stdin(monkeypatch) + code, out, err = run(capsys, "printer", "login", "--ip", P_IP, "--serial", P_SERIAL, "--insecure", "--access-code-stdin") + assert code == 0 and lan.seen == [(P_IP, P_SERIAL, True)] + (p,) = stored()["printers"] + assert (p["serial"], p["ip"], p["insecure"]) == (P_SERIAL, P_IP, True) + assert offline.vault.items == {P_SERIAL: P_CODE} and P_CODE not in out + err + + +def test_login_stores_the_address_discovery_found(capsys, lan, monkeypatch, offline): + """A profile without an IP: the read's resolved address (`PrinterService.last_resolved`) goes into the profile. + The profile is --insecure, so discovery takes the hit without the certificate check.""" + import dataclasses + + from shambu import printer + + store_profiles(dataclasses.replace(a_profile(ip=None), insecure=True)) + monkeypatch.setattr(printer.ArpProbe, "find", lambda self: [{"ip": P_NEW_IP, "method": "arp+8883"}]) + monkeypatch.setattr(printer.SubnetProbe, "find", lambda self: []) + code_stdin(monkeypatch) + code, _, err = run(capsys, "printer", "login", "--printer", "x2d", "--insecure", "--access-code-stdin") + assert code == 0, err + assert stored()["printers"][0]["ip"] == P_NEW_IP and lan.seen == [(P_NEW_IP, P_SERIAL, True)] + + +# ---------------------------------------------------------------------------- slice D review fixes, round 2 +def test_login_refuses_a_profile_whose_serial_changed_during_the_prompt(capsys, lan, monkeypatch, offline): + """The CLI checks the profile's serial before the prompt; the facade reads the profile again under the lock and + refuses when its serial is no longer that pin: no reader is built and nothing is written.""" + import dataclasses + + from shambu import conf + + store_profiles(a_profile()) + before = stored() + real, calls = conf.select_profile, [] + + def flipping(name, ps=None, path=None): + p = real(name, ps, path) + calls.append(name) + return p if len(calls) == 1 or p is None else dataclasses.replace(p, serial=P_OTHER) + + monkeypatch.setattr(conf, "select_profile", flipping) + code_stdin(monkeypatch) + code, out, err = run(capsys, "printer", "login", "--printer", "x2d", "--access-code-stdin") + assert code == 3 and err.strip() == "ConfError: profile x2d changed while login ran; retry" + assert len(calls) == 2 and lan.seen == [] and stored() == before and offline.vault.items == {} + assert P_SERIAL not in out + err and P_OTHER not in out + err and P_MASKED not in out + err + + +@pytest.mark.parametrize("secret", ["true", "verified"]) +def test_login_json_stays_valid_when_the_code_is_a_json_word_or_a_key(capsys, lan, monkeypatch, offline, secret): + code_stdin(monkeypatch, secret + "\n") + code, out, err = run(capsys, "--json", "printer", "login", "--ip", P_IP, "--access-code-stdin", "--name", "x2d") + d = one_json(out) + assert code == 0, err + assert set(d) == {"name", "serial_masked", "mode", "verified", "default", "code_stored"} + assert d["code_stored"] is True and d["default"] is True and d["verified"] == stored()["printers"][0]["verified"] + + +def _doctor_with(monkeypatch, change): + real = Bambu.doctor + + def patched(self, *a, **k): + d = real(self, *a, **k) + change(d) + return d + + monkeypatch.setattr(Bambu, "doctor", patched) + + +def test_doctor_scrubs_a_one_shot_code_in_a_preset_name(capsys, monkeypatch): + _doctor_with(monkeypatch, lambda d: d["config"].update(gui_machine_preset=f"X2D {P_CODE}")) + code_stdin(monkeypatch) + _, out, err = run(capsys, "doctor", "--access-code-stdin") + assert "GUI presets: machine X2D , " in out and P_CODE not in out + err + code_stdin(monkeypatch) + _, out, err = run(capsys, "--json", "doctor", "--access-code-stdin") + assert one_json(out)["config"]["gui_machine_preset"] == "X2D " and P_CODE not in out + err + + +def test_doctor_scrubs_a_section_error(capsys, monkeypatch): + _doctor_with(monkeypatch, lambda d: d.update(machine={"error": f"PresetError: {P_CODE} not found"})) + code_stdin(monkeypatch) + _, out, err = run(capsys, "doctor", "--access-code-stdin") + assert "machine: PresetError: not found" in out.splitlines() and P_CODE not in out + err + code_stdin(monkeypatch) + _, out, err = run(capsys, "--json", "doctor", "--access-code-stdin") + assert one_json(out)["machine"] == {"error": "PresetError: not found"} and P_CODE not in out + err + + +@pytest.mark.parametrize("stored_code", [True, False]) +def test_doctor_says_whether_the_credentials_file_holds_a_code(capsys, offline, stored_code): + store_profiles(a_profile()) + if stored_code: + offline.vault.items[P_SERIAL] = P_CODE + want = "stored" if stored_code else "none" + code, out, err = run(capsys, "doctor", "--no-printer") + (line,) = [x for x in out.splitlines() if x.startswith("printer profile")] + assert f", TLS verified, code: {want}; 1 profile(s) in " in line + code, out, err = run(capsys, "--json", "doctor", "--no-printer") + assert code == 0 and one_json(out)["config"]["printer_profile"]["code"] == want and P_CODE not in out + err + + +def test_watch_scrubs_a_vault_code_in_a_missing_gcode3mf_path(capsys, tmp_path, offline): + store_profiles(a_profile()) + offline.vault.items[P_SERIAL] = P_CODE # the code is in credentials.json only: no Studio config, no flag + path = f"/nope/{P_CODE}.gcode.3mf" + log = str(tmp_path / "watch.log") + code, out, err = run(capsys, "printer", "watch", "--printer", "x2d", "--log", log, "--gcode3mf", path) + assert code == 2 and out.strip() == "--gcode3mf /nope/.gcode.3mf: no such file" and P_CODE not in out + err + code, out, err = run(capsys, "--json", "printer", "watch", "--printer", "x2d", "--log", log, "--gcode3mf", path) + assert code == 2 and one_json(out) == {"error": "--gcode3mf /nope/.gcode.3mf: no such file"} + assert P_CODE not in out + err + + +def test_login_checks_the_name_before_the_prompt_and_the_network(capsys, lan, monkeypatch, offline): + import sys + + from shambu import printer + + certs = [] + monkeypatch.setattr(printer, "certificate_serial", lambda *a, **k: certs.append(1) or P_SERIAL) + code_stdin(monkeypatch) + code, out, _ = run(capsys, "printer", "login", "--ip", P_IP, "--name", "bad name", "--access-code-stdin") + assert code == 2 and out.startswith("--name: a profile name is 1 to 32 letters") + assert sys.stdin.read() == P_CODE + "\n" and certs == [] and lan.seen == [] and stored() is None + + +@pytest.mark.parametrize("text", ["{bad", "[]", '{"app": []}', '{"app": {"user_last_selected_machine": 5}}', '{"access_code": []}']) +def test_login_from_studio_with_a_damaged_studio_config(capsys, lan, offline, text): + from shambu import conf + + conf.CONF.write_text(text, encoding="utf-8") + code, out, err = run(capsys, "--json", "printer", "login", "--from-studio") + assert code == 3 and one_json(out) == {"error": "ConfError: Bambu Studio's config could not be read or has an unexpected shape"} + code, out, err = run(capsys, "printer", "login", "--from-studio") + assert code == 3 and err.strip() == "ConfError: Bambu Studio's config could not be read or has an unexpected shape" + assert lan.seen == [] and stored() is None and offline.vault.items == {} + + +def test_login_honours_wait(capsys, lan, monkeypatch, offline): + store_profiles(a_profile()) + code_stdin(monkeypatch) + code, _, err = run(capsys, "printer", "login", "--printer", "x2d", "--wait", "3.5", "--access-code-stdin") + assert code == 0, err + assert lan.waits == [3.5] + + +def test_login_default_moves_the_default_on_a_refresh(capsys, lan, monkeypatch, offline): + store_profiles(a_profile(), a_profile("p1s", P_OTHER, P_NEW_IP, "cloud")) + code_stdin(monkeypatch) + code, out, err = run(capsys, "printer", "login", "--printer", "p1s", "--default", "--access-code-stdin") + assert code == 0, err + assert out.rstrip().endswith("(default)") and stored()["default"] == "p1s" + assert [p["name"] for p in stored()["printers"]] == ["x2d", "p1s"] and lan.seen == [(P_NEW_IP, P_OTHER, False)] + + +def test_login_refresh_without_insecure_turns_the_tls_check_back_on(capsys, lan, monkeypatch, offline): + import dataclasses + + store_profiles(dataclasses.replace(a_profile(), insecure=True)) + code_stdin(monkeypatch) + code, _, err = run(capsys, "printer", "login", "--printer", "x2d", "--access-code-stdin") + assert code == 0, err + assert lan.seen == [(P_IP, P_SERIAL, False)] and stored()["printers"][0]["insecure"] is False + code_stdin(monkeypatch) + assert run(capsys, "printer", "login", "--printer", "x2d", "--insecure", "--access-code-stdin")[0] == 0 + assert lan.seen[-1] == (P_IP, P_SERIAL, True) and stored()["printers"][0]["insecure"] is True + + +# ---------------------------------------------------------------------------- serial case on login +def test_login_refresh_with_a_case_only_different_serial_keeps_the_canonical_serial(capsys, lan, monkeypatch, offline): + """The pin check is case-insensitive; the profile's stored serial is the one read, stored and written.""" + store_profiles(a_profile()) + code_stdin(monkeypatch) + code, _, err = run(capsys, "printer", "login", "--printer", "x2d", "--serial", P_SERIAL.lower(), "--access-code-stdin") + assert code == 0, err + (p,) = stored()["printers"] + assert (p["name"], p["serial"], p["added"]) == ("x2d", P_SERIAL, P_STAMP) + assert lan.seen == [(P_IP, P_SERIAL, False)] and offline.vault.items == {P_SERIAL: P_CODE} + + +def test_login_rename_with_a_case_only_different_serial_keeps_one_profile(capsys, lan, monkeypatch, offline): + store_profiles(a_profile()) + code_stdin(monkeypatch) + argv = ("printer", "login", "--printer", "x2d", "--serial", P_SERIAL.lower(), "--name", "renamed", "--access-code-stdin") + code, _, err = run(capsys, *argv) + assert code == 0, err + d = stored() + (p,) = d["printers"] + assert (p["name"], p["serial"], p["added"]) == ("renamed", P_SERIAL, P_STAMP) and d["default"] == "renamed" + assert offline.vault.items == {P_SERIAL: P_CODE} + + +def test_login_new_printer_stores_the_certificate_case_of_the_serial(capsys, lan, monkeypatch, offline): + code_stdin(monkeypatch) + code, _, err = run(capsys, "printer", "login", "--ip", P_IP, "--serial", P_SERIAL.lower(), "--access-code-stdin") + assert code == 0, err + (p,) = stored()["printers"] + assert p["serial"] == P_SERIAL and lan.seen == [(P_IP, P_SERIAL, False)] + assert offline.vault.items == {P_SERIAL: P_CODE} + + +def test_login_by_ip_keeps_the_credential_key_of_an_existing_lower_case_profile(capsys, lan, monkeypatch, offline): + """A profile stored with a lower-case serial and an old code; `login --ip` resolves the upper-case certificate + serial: one profile, one credential key (the stored case), the new code under it, nothing under the other case.""" + store_profiles(a_profile(serial=P_SERIAL.lower())) + offline.vault.items[P_SERIAL.lower()] = "OLD-CODE-0001" + code_stdin(monkeypatch) + code, _, err = run(capsys, "printer", "login", "--ip", P_IP, "--access-code-stdin") + assert code == 0, err + (p,) = stored()["printers"] + assert p["serial"] == P_SERIAL.lower() + assert offline.vault.items == {P_SERIAL.lower(): P_CODE} diff --git a/tests/test_printer_conf.py b/tests/test_printer_conf.py index 0c2b9ed..acb708d 100644 --- a/tests/test_printer_conf.py +++ b/tests/test_printer_conf.py @@ -167,14 +167,40 @@ def test_mqtt_reader_repr_hides_code(): assert SENTINEL not in repr(printer.MqttReader("10.0.0.9", SERIAL, SENTINEL)) +@pytest.fixture +def printers_file(offline): + """A valid printers.json with one default profile `x2d` for SERIAL at 10.0.0.9, and its code in the vault.""" + from shambu.profiles import Profile, ProfileSet + + conf.save_profiles(ProfileSet("x2d", (Profile("x2d", SERIAL, "10.0.0.9", "lan", False, "2026-10-01T08:00:00Z", None),))) + offline.vault.items[SERIAL] = SENTINEL + return conf.PRINTERS + + +@pytest.mark.parametrize("source", ["studio", "profile", "stdin"]) @pytest.mark.parametrize("as_json", [True, False]) -def test_cli_printer_status_never_prints_code(conf_file, monkeypatch, capsys, resolver, as_json): +def test_cli_printer_status_never_prints_code(request, monkeypatch, capsys, resolver, as_json, source): + """The code from Studio's config, from the credentials file (no Studio config) and from --access-code-stdin.""" + import io + + argv = ["-v", "printer", "status"] + if source == "studio": + request.getfixturevalue("conf_file") + argv += ["--ip", "10.0.0.9"] + elif source == "profile": + request.getfixturevalue("printers_file") + argv += ["--printer", "x2d"] + else: + monkeypatch.setattr("sys.stdin", io.StringIO(SENTINEL + "\n")) + argv += ["--ip", "10.0.0.9", "--serial", SERIAL, "--access-code-stdin"] + FakeReader.seen = [] monkeypatch.setattr(printer, "MqttReader", FakeReader) monkeypatch.setattr(cli, "Bambu", lambda: Bambu(studio=FakeStudio(), resolver=resolver)) - code = cli.main((["--json"] if as_json else []) + ["-v", "printer", "status", "--ip", "10.0.0.9"]) + code = cli.main((["--json"] if as_json else []) + argv) out, err = capsys.readouterr() assert code == 0 - assert SENTINEL not in out + err + assert FakeReader.seen == [SENTINEL] + assert SENTINEL not in out + err and SERIAL not in out + err if as_json: assert json.loads(out)["state"] == "printing" @@ -508,6 +534,18 @@ def test_public_raw_scrubs_a_known_access_code_under_an_ordinary_key(conf_file): assert got["print"]["note"] == "" and got["print"]["gcode_state"] == "RUNNING" +def test_public_raw_scrubs_a_code_only_the_credentials_file_holds(offline): + """No Studio config: the code is in credentials.json only. The chain's lookup registers it, and public_raw drops it.""" + from shambu.profiles import Profile, ProfileSet + + assert not conf.CONF.exists() + conf.save_profiles(ProfileSet("x2d", (Profile("x2d", SERIAL, "10.0.0.9", "lan", False, "2026-10-01T08:00:00Z", None),))) + offline.vault.items[SERIAL] = SENTINEL + Bambu().printer().prepare_secrets() + got = printer.public_raw({"print": {"note": f"code {SENTINEL}", "gcode_state": "RUNNING"}}, "10.0.0.9", SERIAL) + assert got["print"] == {"note": "", "gcode_state": "RUNNING"} and SENTINEL not in json.dumps(got) + + def test_header_scrub_never_touches_numbers_and_catches_identifiers(): """A code of digits inside a float once corrupted the scrubbed JSON text; an IP or a masked serial in a file name passed the literal match.""" @@ -541,3 +579,28 @@ def test_watch_header_drops_an_ip_and_a_masked_serial_from_the_gcode_file_name(s first = log.read_text(encoding="utf-8").splitlines()[0] assert SENTINEL_IP not in first and MASKED not in first and "00X\\u2026999" not in first assert json.loads(first)["gcode3mf"] == "-.gcode.3mf" # the endpoint is a known secret here + + +def test_watch_header_scrubs_a_code_that_only_the_credentials_file_holds(printers_file, monkeypatch, capsys, tmp_path, resolver): + """No Studio config: the code comes only from the vault, and the G-code file name holds it. The header line is + written before the first poll, so the watch registers the chain's code first.""" + import zipfile + + sys.path.insert(0, str(Path(__file__).parent)) + from test_calibrate import gcode + + monkeypatch.setattr(printer, "MqttReader", StreamReader) + monkeypatch.setattr(printer.time, "sleep", lambda s: None) + monkeypatch.setattr(cli, "Bambu", lambda: Bambu(studio=FakeStudio(), resolver=resolver)) + g3 = tmp_path / f"{SENTINEL}-plate.gcode.3mf" + with zipfile.ZipFile(g3, "w") as z: + z.writestr("Metadata/project_settings.config", "{}") + z.writestr("Metadata/model_settings.config", "") + z.writestr("Metadata/plate_1.gcode", gcode("PETG")) + log = tmp_path / "w.jsonl" + StreamReader.script = [timed_report("FINISH", layer=3, remaining=0)] + assert cli.main(["--json", "printer", "watch", "--printer", "x2d", "--log", str(log), "--gcode3mf", str(g3)]) == 0 + out, err = capsys.readouterr() + first = log.read_text(encoding="utf-8").splitlines()[0] + assert SENTINEL not in first and json.loads(first)["gcode3mf"] == "-plate.gcode.3mf" + assert SENTINEL not in log.read_text(encoding="utf-8") + out + err diff --git a/tests/test_printer_tls.py b/tests/test_printer_tls.py index 2e44d71..164f64a 100644 --- a/tests/test_printer_tls.py +++ b/tests/test_printer_tls.py @@ -1,5 +1,7 @@ """printer.py TLS policy, report merging and watch(). Offline: a fake paho client, no sockets, no real printer.""" +import copy +import dataclasses import json import socket import ssl @@ -7,11 +9,14 @@ import sys import threading import time +import types import pytest -from shambu import conf, printer -from shambu.errors import PrinterError +from conftest import REAL_IDENTITY_CONTEXT +from shambu import conf, printer, profiles +from shambu.errors import ConfError, PrinterError +from shambu.vault import MemoryVault, VaultError SERIAL = "00X0TEST0000999" SENTINEL = "SENTINEL-CODE-8812" @@ -770,3 +775,1037 @@ def test_wait_for_start_polls_an_idle_printer_until_a_job_runs(): assert got == ["IDLE", "FINISH", "PREPARE", "RUNNING", "FINISH"] assert sleeps == [60, 60, 60, 60] assert [st.raw_state for st in _service([_timed("IDLE")]).watch(every_s=300, sleep=lambda s: None)] == ["IDLE"] # default: stop + + +# ---------------------------------------------------------------------------- 0.5.0: credential chain, staged discovery, typed results +PROFILE = profiles.Profile( + name="x2d", + serial=SERIAL, + ip="10.0.0.9", + mode="lan", + insecure=False, + added="2026-10-01T10:00:00Z", + verified="2026-10-20T09:15:00Z", +) +NEW_IP = "10.0.0.42" + + +class FakeSource: + """A `CredentialSource` with fixed answers; `calls` records which methods ran (never the code).""" + + def __init__(self, name, serial=None, code=None): + self.name, self._serial, self._code, self.calls = name, serial, code, [] + + def serial(self): + self.calls.append("serial") + return self._serial + + def code(self, serial): + self.calls.append("code") + return self._code + + +class _Pass: + """A discovery pass with fixed hits; `found` counts its find() calls across copies.""" + + def __init__(self, hits, name="fake", found=None): + self.hits, self.name, self.found = hits, name, found if found is not None else [0] + self.timings, self.shortfall = {}, None + + def with_deadline(self, seconds): + return _Pass(self.hits, self.name, self.found) + + def find(self): + self.found[0] += 1 + self.timings = {self.name: 0.0} + return list(self.hits) + + +class _Rec: + """A reader factory that records (ip, code) per read and plays a report or an error per IP.""" + + def __init__(self, by_ip=None, report=None): + self.by_ip, self.report, self.seen = by_ip or {}, report, [] + + def __call__(self, ip, serial, code, wait, insecure): + rec = self + + class R: + def read(self): + rec.seen.append((ip, code)) + x = rec.by_ip.get(ip, rec.report if rec.report is not None else full()) + if isinstance(x, Exception): + raise x + return copy.deepcopy(x) + + return R() + + +def _profile_svc(vault=None, **kw): + v = vault if vault is not None else MemoryVault(items={SERIAL: SENTINEL}) + kw.setdefault("sources", (printer.ProfileCredentials(PROFILE, v),)) + kw.setdefault("timeout_s", None) + kw.setdefault("ip", PROFILE.ip) # the facade passes the profile's IP as `ip` + return printer.PrinterService(profile=PROFILE, **kw) + + +def test_chain_order_one_shot_then_profile_then_studio(monkeypatch, tmp_path): + _service_conf(monkeypatch, tmp_path) # Studio holds SENTINEL for SERIAL + rec = _Rec() + stored = printer.ProfileCredentials(PROFILE, MemoryVault(items={SERIAL: "STORED-CODE-1"})) + one_shot = printer.OneShotCredentials("FLAG-CODE-2") + studio = printer.StudioCredentials() + + def used(*sources): + res = printer.PrinterService(ip="10.0.0.9", reader_factory=rec, timeout_s=None, sources=sources)._bounded( + printer.PrinterService(ip="10.0.0.9", reader_factory=rec, timeout_s=None, sources=sources)._resolve_and_read + ) + return rec.seen[-1][1], res.resolved.code_source, res.resolved.serial_source + + assert used(one_shot, stored, studio) == ("FLAG-CODE-2", "flag", "profile") # the one-shot names no serial + assert used(stored, studio) == ("STORED-CODE-1", "profile", "profile") + assert used(printer.ProfileCredentials(PROFILE, MemoryVault()), studio) == (SENTINEL, "studio", "profile") + assert used(studio) == (SENTINEL, "studio", "studio") + assert "FLAG-CODE-2" not in repr(one_shot) and "STORED-CODE-1" not in repr(stored) + + +def test_a_fake_source_satisfies_the_protocol_and_serial_comes_before_code(): + src = FakeSource("fake", serial=SERIAL, code=SENTINEL) + r = printer.PrinterService(ip="10.0.0.9", sources=(src,), timeout_s=None).resolve() + assert (r.serial, r.code, r.serial_source, r.code_source, r.ip_source) == (SERIAL, SENTINEL, "fake", "fake", "flag") + assert src.calls == ["serial", "code"] + assert SENTINEL not in repr(r) + + +def test_a_one_shot_code_with_no_serial_takes_the_serial_from_the_certificate(): + seen = [] + + def cert(ip, deadline, ctx=None): + seen.append((ip, deadline)) + return SERIAL + + svc = printer.PrinterService(ip="10.0.0.9", reader_factory=_Rec(), sources=(printer.OneShotCredentials(SENTINEL),), cert_serial=cert) + res = svc._bounded(svc._resolve_and_read) + assert (res.resolved.serial, res.resolved.serial_source, res.resolved.code_source) == (SERIAL, "certificate", "flag") + assert seen[0][0] == "10.0.0.9" and 0 < seen[0][1] <= 15.0 + assert list(svc.timings) == ["credentials", "mqtt"] # the certificate read is inside the credentials step + + +def test_no_serial_anywhere_is_a_setup_error(monkeypatch, tmp_path): + base = "no printer selected: run shambu printer login, or select it in Bambu Studio (Device tab)" + svc = printer.PrinterService(sources=(printer.OneShotCredentials(SENTINEL), printer.StudioCredentials()), timeout_s=None) + with pytest.raises(PrinterError) as e: # the offline fixture: BambuStudio.conf is absent + svc.status() + assert str(e.value) == base + "; BambuStudio.conf not found: has Bambu Studio run on this Mac?" + assert printer.error_category(e.value)[0] == "setup" + p = tmp_path / "BambuStudio.conf" + p.write_text(json.dumps({"app": {}, "access_code": {}})) # present, but no printer selected + monkeypatch.setattr(conf, "CONF", p) + with pytest.raises(PrinterError) as e: + svc.status() + assert str(e.value) == base + assert printer.error_category(e.value)[0] == "setup" + insecure = printer.PrinterService(ip="10.0.0.9", insecure=True, sources=(printer.OneShotCredentials(SENTINEL),), timeout_s=None) + with pytest.raises(PrinterError, match="no printer selected"): # no certificate read under --insecure + insecure.status() + + +def test_no_code_anywhere_is_a_conf_error(monkeypatch, tmp_path): + p = tmp_path / "BambuStudio.conf" + p.write_text(json.dumps({"app": {"user_last_selected_machine": SERIAL}, "access_code": {}})) + monkeypatch.setattr(conf, "CONF", p) + with pytest.raises(ConfError) as e: + printer.PrinterService(ip="10.0.0.9", timeout_s=None).status() + assert str(e.value) == ( + f"no access code for serial {conf.mask(SERIAL)}: run shambu printer login, or open the printer once in Bambu Studio's Device tab" + ) + assert printer.error_category(e.value)[0] == "setup" + + +def test_no_studio_config_with_an_ip_and_a_one_shot_code_reads(): + """`conf.CONF` is absent (the offline fixture): `status --ip X --serial S --access-code-file F` needs no Studio.""" + rec = _Rec() + svc = printer.PrinterService( + ip="10.0.0.9", + serial=SERIAL, + reader_factory=rec, + timeout_s=None, + sources=( + printer.OneShotCredentials(SENTINEL), + printer.StudioCredentials(), + ), + ) + assert svc.status().state == "printing" + assert rec.seen == [("10.0.0.9", SENTINEL)] and list(svc.timings) == ["credentials", "mqtt"] + assert printer.StudioCredentials().serial() is None and printer.StudioCredentials().code(SERIAL) is None + + +def test_a_profile_read_times_only_credentials_and_mqtt(): + v = MemoryVault(items={SERIAL: SENTINEL}) + svc = _profile_svc(v, reader_factory=_Rec()) + res = svc._bounded(svc._resolve_and_read) + assert list(svc.timings) == ["credentials", "mqtt"] + assert (res.resolved.ip, res.resolved.ip_source, res.resolved.serial_source, res.resolved.code_source) == ( + "10.0.0.9", + "profile", + "profile", + "profile", + ) + svc = _profile_svc(v, ip=None, reader_factory=_Rec()) # no `ip`: the profile's IP is the fallback + assert svc._bounded(svc._resolve_and_read).resolved.ip_source == "profile" + svc = _profile_svc(v, ip="10.0.0.77", reader_factory=_Rec()) # the caller's override is a flag + assert svc._bounded(svc._resolve_and_read).resolved.ip_source == "flag" + + +def test_a_profile_for_another_serial_gives_none(): + v = MemoryVault(items={SERIAL: SENTINEL}) + assert printer.ProfileCredentials(PROFILE, v).code("00X0OTHER000111") is None + assert v.calls == [] + + +def test_a_vault_failure_names_the_credentials_file(): + v = MemoryVault(items={SERIAL: SENTINEL}, error=VaultError("damaged")) + with pytest.raises(PrinterError) as e: + printer.ProfileCredentials(PROFILE, v).code(SERIAL) + assert str(e.value) == f"the credentials file could not give the access code for {conf.mask(SERIAL)}: damaged" + + +ROUTE = """ route to: default +destination: default + mask: default + gateway: 10.0.0.1 + interface: en0 + flags: +""" +IFCONFIG_24 = """en0: flags=8863 mtu 1500 +\tether 0:11:22:33:44:77 +\tinet6 fe80::1%en0 prefixlen 64 secured scopeid 0xb +\tinet 10.0.0.5 netmask 0xffffff00 broadcast 10.0.0.255 +\tstatus: active +""" +IFCONFIG_16 = IFCONFIG_24.replace("0xffffff00", "0xffff0000") + + +def test_subnet_hosts_from_route_and_ifconfig(): + hosts = printer.subnet_hosts(ROUTE, IFCONFIG_24) + assert len(hosts) == 253 and hosts[:5] == ["10.0.0.1", "10.0.0.2", "10.0.0.3", "10.0.0.4", "10.0.0.6"] # not its own + assert hosts[-1] == "10.0.0.254" + assert printer.subnet_hosts(ROUTE, IFCONFIG_16) == [] # wider than /24: no scan + assert printer.subnet_hosts("nothing here", IFCONFIG_24) == [] + assert printer.subnet_hosts(ROUTE, "en0: flags=0<>") == [] + assert len(printer.subnet_hosts(ROUTE, IFCONFIG_24.replace("0xffffff00", "0xfffffff0"))) == 13 # a /28 + + +def _runner(ifconfig=IFCONFIG_24, arp=ARP_AN, calls=None): + def run(argv, **kw): + if calls is not None: + calls.append((argv, kw.get("timeout"))) + out = {"route": ROUTE, "ifconfig": ifconfig, "arp": arp}[argv[0]] + return subprocess.CompletedProcess(argv, 0, out, "") + + return run + + +def test_subnet_probe_skips_arp_hosts_and_tags_its_method(): + calls, probed = [], [] + + def probe(ip, port, timeout): + probed.append(ip) + return ip in ("10.0.0.9", NEW_IP) + + sp = printer.SubnetProbe(runner=_runner(calls=calls), probe=probe) + assert sp.find() == [{"ip": NEW_IP, "method": "subnet+8883"}] # 10.0.0.9 is in the ARP table: the ARP pass covers it + assert "10.0.0.9" not in probed and "10.0.0.1" not in probed and len(probed) == 251 + assert [c[0] for c in calls] == [["route", "-n", "get", "default"], ["ifconfig", "en0"], ["arp", "-an"]] + assert {c[1] for c in calls} == {2.0} + assert list(sp.timings) == ["subnet"] and sp.shortfall is None + + +def test_subnet_probe_wide_mask_or_runner_failure_gives_a_shortfall(caplog): + wide = printer.SubnetProbe(runner=_runner(ifconfig=IFCONFIG_16), probe=lambda *a: True) + assert wide.find() == [] and "wider than /24" in wide.shortfall + + def broken(argv, **kw): + raise subprocess.TimeoutExpired(argv, kw["timeout"]) + + with caplog.at_level("WARNING", logger="shambu.printer"): + b = printer.SubnetProbe(runner=broken, probe=lambda *a: True) + assert b.find() == [] and "TimeoutExpired" in b.shortfall + assert any("no subnet scan" in r.getMessage() for r in caplog.records) + + +OTHER = "00X0OTHER000111" + + +def _certs(table, seen=None): + def cert(ip, deadline, ctx=None): + if seen is not None: + seen.append((ip, deadline)) + x = table.get(ip, OTHER) + if isinstance(x, Exception): + raise x + return x + + return cert + + +def test_a_foreign_hit_is_skipped_and_the_subnet_pass_runs(monkeypatch, tmp_path): + _service_conf(monkeypatch, tmp_path) + probe = lambda ip, port, t: ip in ("10.0.0.9", NEW_IP) # noqa: E731 - 10.0.0.9 is another 8883 host + rec, seen = _Rec(), [] + svc = printer.PrinterService( + serial=SERIAL, + discovery=(printer.ArpProbe(runner=_runner(), probe=probe), printer.SubnetProbe(runner=_runner(), probe=probe)), + reader_factory=rec, + cert_serial=_certs({NEW_IP: SERIAL.lower()}, seen), # any case + ) + res = svc._bounded(svc._resolve_and_read) + assert (res.resolved.ip, res.resolved.ip_source) == (NEW_IP, "subnet+8883") + assert rec.seen == [(NEW_IP, SENTINEL)] # no credential to the foreign host + assert [ip for ip, _ in seen] == ["10.0.0.9", NEW_IP] and all(0 < d <= 15.0 for _, d in seen) + assert list(svc.timings) == ["credentials", "arp", "probe", "subnet", "identity", "mqtt"] + + +def test_a_profile_without_an_ip_checks_each_hit(): + hits = [{"ip": "10.0.0.50", "method": "arp+8883"}, {"ip": NEW_IP, "method": "arp+8883"}] + rec = _Rec() + prof = dataclasses.replace(PROFILE, ip=None) + svc = printer.PrinterService( + profile=prof, + sources=(printer.ProfileCredentials(prof, MemoryVault(items={SERIAL: SENTINEL})),), + reader_factory=rec, + discovery=(_Pass(hits),), + cert_serial=_certs({NEW_IP: SERIAL}), + timeout_s=None, + ) + res = svc._bounded(svc._resolve_and_read) + assert (res.resolved.ip, res.resolved.serial_source) == (NEW_IP, "profile") and rec.seen == [(NEW_IP, SENTINEL)] + + +def test_a_check_that_raises_skips_the_hit(caplog): + hits = [{"ip": "10.0.0.50", "method": "arp+8883"}, {"ip": NEW_IP, "method": "arp+8883"}] + cert = _certs({"10.0.0.50": PrinterError("TLS verification failed for the host at 10.0.0.50: x"), NEW_IP: SERIAL}) + svc = printer.PrinterService( + serial=SERIAL, sources=(printer.OneShotCredentials(SENTINEL),), discovery=(_Pass(hits),), reader_factory=_Rec(), cert_serial=cert + ) + with caplog.at_level("INFO", logger="shambu.printer"): + assert svc._bounded(svc._resolve_and_read).resolved.ip == NEW_IP + assert any("discovery hit skipped: TLS verification failed" in r.getMessage() for r in caplog.records) + + +def test_insecure_takes_the_first_hit_without_a_check(): + def no_check(*a): + raise AssertionError("no certificate check under --insecure") + + hits = [{"ip": "10.0.0.50", "method": "arp+8883"}, {"ip": NEW_IP, "method": "arp+8883"}] + svc = printer.PrinterService( + serial=SERIAL, + insecure=True, + sources=(printer.OneShotCredentials(SENTINEL),), + discovery=(_Pass(hits),), + reader_factory=_Rec(report={**full(), "_topic": TOPIC}), + cert_serial=no_check, + ) + res = svc._bounded(svc._resolve_and_read) + assert res.resolved.ip == "10.0.0.50" and "identity" not in svc.timings + svc.insecure, svc.check_candidates = False, False # the check can be turned off + assert svc._bounded(svc._resolve_and_read).resolved.ip == "10.0.0.50" + + +def test_a_studio_serial_keeps_the_first_hit_rule(monkeypatch, tmp_path): + """The no-profile path is 0.4.0's: no certificate check, the same steps.""" + _service_conf(monkeypatch, tmp_path) + + def no_check(*a): + raise AssertionError("no certificate check on the Studio path") + + svc = printer.PrinterService( + discovery=(_Pass([{"ip": "10.0.0.9", "method": "arp+8883"}]),), reader_factory=_Rec(), cert_serial=no_check + ) + assert svc.status().ip == "10.0.0.9" and list(svc.timings) == ["credentials", "fake", "mqtt"] + + +def test_slow_identity_checks_time_out_in_the_identity_step(monkeypatch): + monkeypatch.setattr(printer, "CONNECT_S", 0.0) # no MQTT reserve: a check that ignores its deadline runs past the timeout + monkeypatch.setattr(printer, "HANDSHAKE_S", 0.0) + rec = _Rec() + + def slow(ip, deadline, ctx=None): + time.sleep(0.4) + return OTHER + + hits = [{"ip": f"10.0.0.{i}", "method": "arp+8883"} for i in range(50, 60)] + svc = printer.PrinterService( + serial=SERIAL, + sources=(printer.OneShotCredentials(SENTINEL),), + discovery=(_Pass(hits),), + reader_factory=rec, + cert_serial=slow, + timeout_s=0.3, + ) + with pytest.raises(PrinterError) as e: + svc.status() + assert str(e.value) == "timed out after 0.3 s in identity" + time.sleep(0.6) + assert rec.seen == [] # the code never left + + +def test_the_identity_checks_stop_at_the_mqtt_reserve(monkeypatch): + seen, found = [], [0] + reserve = printer.CONNECT_S + printer.HANDSHAKE_S + hits = [{"ip": f"10.0.0.{i}", "method": "arp+8883"} for i in range(50, 53)] + svc = printer.PrinterService( + serial=SERIAL, + sources=(printer.OneShotCredentials(SENTINEL),), + discovery=(_Pass(hits), _Pass([{"ip": NEW_IP, "method": "subnet+8883"}], found=found)), + timeout_s=15.0, + ) + svc.cert_serial = _certs({}, seen) + left = iter([reserve + 1.0, reserve + 1.0, reserve, reserve - 1.0]) # pass start, then one value per hit + monkeypatch.setattr(svc, "_left", lambda a: next(left, reserve - 1.0)) + with pytest.raises(PrinterError) as e: + svc.resolve() + assert seen == [("10.0.0.50", 1.0)] # the check gets only what is left above the reserve + assert found == [0] # no later pass once the time for checks is spent + assert ( + f"(1 host(s) checked are not printer {conf.mask(SERIAL)}; 2 candidate(s) not checked before the timeout; " + "1 discovery pass(es) not run before the timeout)" + ) in str(e.value) + assert "none is printer" not in str(e.value) + + +def test_a_spent_deadline_starts_no_later_discovery_pass(monkeypatch): + first, second = [0], [0] + reserve = printer.CONNECT_S + printer.HANDSHAKE_S + svc = printer.PrinterService( + serial=SERIAL, + sources=(printer.OneShotCredentials(SENTINEL),), + discovery=(_Pass([], found=first), _Pass([{"ip": NEW_IP, "method": "subnet+8883"}], found=second)), + cert_serial=_certs({NEW_IP: SERIAL}), + timeout_s=15.0, + ) + left = iter([15.0, reserve]) # the first pass starts with time left; the second would start inside the reserve + monkeypatch.setattr(svc, "_left", lambda a: next(left)) + with pytest.raises(PrinterError) as e: + svc.resolve() + assert first == [1] and second == [0] + assert "(1 discovery pass(es) not run before the timeout)" in str(e.value) + + +def test_slow_identity_checks_keep_the_mqtt_reserve(): + """A short timeout and slow checks: the checks stop while the MQTT reserve is still left, and the error counts the + hosts not checked instead of a timeout in the identity step.""" + seen = [] + + def slow(ip, deadline, ctx=None): + seen.append(ip) + time.sleep(0.25) + return OTHER + + hits = [{"ip": f"10.0.0.{i}", "method": "arp+8883"} for i in range(50, 60)] + rec = _Rec() + svc = printer.PrinterService( + serial=SERIAL, + sources=(printer.OneShotCredentials(SENTINEL),), + discovery=(_Pass(hits),), + reader_factory=rec, + cert_serial=slow, + timeout_s=printer.CONNECT_S + printer.HANDSHAKE_S + 0.6, + ) + with pytest.raises(PrinterError) as e: + svc.status() + n = len(seen) + assert 1 <= n <= 4 and rec.seen == [] + assert f"{len(hits) - n} candidate(s) not checked before the timeout" in str(e.value) + assert not str(e.value).startswith("timed out") + + +def test_a_missing_ca_bundle_surfaces_before_any_identity_check(monkeypatch, tmp_path): + monkeypatch.setattr(printer, "_identity_context", REAL_IDENTITY_CONTEXT) + monkeypatch.setattr(printer, "CA_FILE", tmp_path / "missing.cer") + seen, found = [], [0] + svc = printer.PrinterService( + serial=SERIAL, + sources=(printer.OneShotCredentials(SENTINEL),), + discovery=(_Pass([{"ip": NEW_IP, "method": "arp+8883"}], found=found),), + cert_serial=_certs({NEW_IP: SERIAL}, seen), + timeout_s=None, + ) + with pytest.raises(PrinterError) as e: + svc.resolve() + assert str(e.value).startswith("printer CA bundle not found at ") and "no host" not in str(e.value) + assert seen == [] and found == [0] # the hit was never probed for identity + + +def test_discovery_builds_the_identity_context_once_and_passes_it(monkeypatch): + built, ctxs = [], [] + monkeypatch.setattr(printer, "_identity_context", lambda: built.append(1) or "CTX") + + def cert(ip, deadline, ctx=None): + ctxs.append(ctx) + return SERIAL if ip == NEW_IP else OTHER + + hits = [{"ip": "10.0.0.50", "method": "arp+8883"}, {"ip": "10.0.0.51", "method": "arp+8883"}, {"ip": NEW_IP, "method": "arp+8883"}] + svc = printer.PrinterService( + serial=SERIAL, sources=(printer.OneShotCredentials(SENTINEL),), discovery=(_Pass(hits),), cert_serial=cert, timeout_s=None + ) + assert svc.resolve().ip == NEW_IP + assert built == [1] and ctxs == ["CTX", "CTX", "CTX"] + + +def test_certificate_serial_uses_a_prebuilt_context(cert_env, monkeypatch): + def no_build(): + raise AssertionError("a prebuilt context is not rebuilt") + + ctx = REAL_IDENTITY_CONTEXT() # cert_env's CA bundle "loads" + monkeypatch.setattr(printer, "_identity_context", no_build) + assert printer.certificate_serial("10.0.0.9", 1.0, ctx) == SERIAL + + +def test_an_arp_hit_ends_discovery_before_the_subnet_pass(monkeypatch, tmp_path): + _service_conf(monkeypatch, tmp_path) + probe = lambda ip, port, t: ip == "10.0.0.9" # noqa: E731 + svc = printer.PrinterService( + discovery=(printer.ArpProbe(runner=_runner(), probe=probe), printer.SubnetProbe(runner=_runner(), probe=probe)), + reader_factory=_Rec(), + timeout_s=None, + ) + assert svc.status().ip == "10.0.0.9" + assert list(svc.timings) == ["credentials", "arp", "probe", "mqtt"] + + +def test_no_accepted_hit_keeps_the_no_host_text(monkeypatch, tmp_path): + _service_conf(monkeypatch, tmp_path) + svc = printer.PrinterService( + serial=SERIAL, discovery=(_Pass([{"ip": "10.0.0.9", "method": "arp+8883"}]),), cert_serial=_certs({}), timeout_s=None + ) + with pytest.raises(PrinterError) as e: + svc.status() + assert str(e.value).startswith("no host on this LAN answers on port 8883 (1 host(s) answered but none is printer ") + assert printer.error_category(e.value)[0] == "discovery" + plain = printer.PrinterService(discovery=(_Pass([]),), timeout_s=None) + with pytest.raises(PrinterError) as e: + plain.status() + assert str(e.value) == "no host on this LAN answers on port 8883. Is the printer on and on this network? Pass --ip." + + +# ---------------------------------------------------------------------------- certificate_serial +class _TlsSock: + def __init__(self, cert): + self.cert, self.closed = cert, False + + def getpeercert(self): + return self.cert + + def close(self): + self.closed = True + + +@pytest.fixture +def cert_env(monkeypatch, tmp_path): + """A CA bundle that 'loads', a fake TCP connect and a fake wrap_socket. `env.cert` is what the peer presents, + `env.wrap_exc` / `env.connect_exc` make that layer fail.""" + ca = tmp_path / "printer.cer" + ca.write_text("x") + monkeypatch.setattr(printer, "CA_FILE", ca) + monkeypatch.setattr(ssl.SSLContext, "load_verify_locations", lambda self, *a, **k: None) + monkeypatch.setattr(printer, "_identity_context", REAL_IDENTITY_CONTEXT) # the real context over the fake bundle + env = types.SimpleNamespace(cert={"subject": ((("commonName", SERIAL),),)}, wrap_exc=None, connect_exc=None, seen={}) + + class Sock: + def settimeout(self, t): + env.seen["handshake"] = t + + def close(self): + env.seen["closed"] = True + + def connect(addr, timeout=None): + env.seen.update(addr=addr, connect=timeout) + if env.connect_exc: + raise env.connect_exc + return Sock() + + def wrap(self, sock, **kw): + env.seen.update(wrap_kw=kw, check_hostname=self.check_hostname, verify_mode=self.verify_mode) + if env.wrap_exc: + raise env.wrap_exc + return _TlsSock(env.cert) + + monkeypatch.setattr(printer.socket, "create_connection", connect) + monkeypatch.setattr(ssl.SSLContext, "wrap_socket", wrap) + return env + + +def test_certificate_serial_reads_the_cn_without_sni(cert_env): + assert printer.certificate_serial("10.0.0.9", deadline=1.5) == SERIAL + s = cert_env.seen + assert s["addr"] == ("10.0.0.9", 8883) and s["connect"] == 1.5 and 1.4 < s["handshake"] <= 1.5 + assert s["wrap_kw"] == {"server_hostname": None} # no SNI + assert (s["check_hostname"], s["verify_mode"]) == (False, ssl.CERT_REQUIRED) and s["closed"] + printer.certificate_serial("10.0.0.9") + assert (cert_env.seen["connect"], cert_env.seen["handshake"]) == (printer.CONNECT_S, printer.HANDSHAKE_S) + + +def test_certificate_serial_refuses_a_cn_that_is_not_a_serial(cert_env): + cert_env.cert = {"subject": ((("commonName", "not a printer"),),)} # "nonsense" is 8 letters: serial-shaped + with pytest.raises(PrinterError, match=r"^the certificate at 10\.0\.0\.9 names no printer$"): + printer.certificate_serial("10.0.0.9") + cert_env.cert = {} + with pytest.raises(PrinterError, match="names no printer"): + printer.certificate_serial("10.0.0.9") + + +def test_certificate_serial_error_texts(cert_env, monkeypatch, tmp_path): + cert_env.wrap_exc = ssl.SSLCertVerificationError("certificate verify failed") + with pytest.raises(PrinterError, match=r"^TLS verification failed for the host at 10\.0\.0\.9: "): + printer.certificate_serial("10.0.0.9") + cert_env.wrap_exc, cert_env.connect_exc = None, ConnectionRefusedError("refused") + with pytest.raises(PrinterError, match=r"^cannot connect to 10\.0\.0\.9:8883: refused"): + printer.certificate_serial("10.0.0.9") + monkeypatch.setattr(printer, "CA_FILE", tmp_path / "missing.cer") + with pytest.raises(PrinterError, match="CA bundle not found"): + printer.certificate_serial("10.0.0.9") + + +def test_certificate_serial_handshake_gets_what_the_connect_left(cert_env, monkeypatch): + real = printer.socket.create_connection + + def slow_connect(addr, timeout=None): + time.sleep(0.3) + return real(addr, timeout) + + monkeypatch.setattr(printer.socket, "create_connection", slow_connect) + printer.certificate_serial("10.0.0.9", deadline=1.0) + assert cert_env.seen["connect"] == 1.0 and cert_env.seen["handshake"] <= 0.71 + + +def test_certificate_serial_handshake_timeout_text(cert_env): + cert_env.wrap_exc = TimeoutError("_ssl.c:999: The handshake operation timed out") + with pytest.raises(PrinterError) as e: + printer.certificate_serial("10.0.0.9", deadline=1.0) + assert str(e.value) == "TLS handshake with 10.0.0.9:8883 timed out" and cert_env.seen["closed"] + + +# ---------------------------------------------------------------------------- refused login +def test_a_refused_login_names_the_source_and_the_hint(fake_paho): + fake_paho(rc=5) + svc = printer.PrinterService(ip="10.0.0.9", serial=SERIAL, sources=(printer.OneShotCredentials(SENTINEL),), timeout_s=None) + with pytest.raises(printer.LoginRefused) as e: + svc.status() + text = str(e.value) + assert "refused the MQTT login" in text and "(source: a one-shot flag)\n" in text + assert "shambu printer login --ip 10.0.0.9" in text and "--from-studio --printer --ip" not in text + assert "In LAN-only mode:" in text and "In cloud mode:" in text and "can regenerate it" in text + assert SENTINEL not in text + repr(e.value) + assert printer.error_category(e.value) == ( + "login", + "the printer refused the access code (run shambu printer login, or check the code in Studio)", + ) + + +@pytest.mark.parametrize( + ("mode", "want", "absent"), + [("lan", "--printer x2d", "--from-studio"), ("cloud", "then run: shambu printer login --from-studio", "touchscreen")], +) +def test_a_refused_profile_login_gives_the_mode_hint(fake_paho, mode, want, absent): + fake_paho(rc=5) + prof = dataclasses.replace(PROFILE, mode=mode) + svc = printer.PrinterService( + ip="10.0.0.9", profile=prof, sources=(printer.ProfileCredentials(prof, MemoryVault(items={SERIAL: SENTINEL})),), timeout_s=None + ) + with pytest.raises(printer.LoginRefused) as e: + svc.status() + text = str(e.value) + assert "(source: the credentials file, profile x2d)\n" in text and want in text and absent not in text + if mode == "cloud": # the CLI refuses --from-studio with --printer (usage, exit 2): the hint names neither + assert text.endswith("then run: shambu printer login --from-studio") and "--printer x2d" not in text + + +def test_error_category_messages_are_pinned(): + msgs = {cat: msg for cat, _, msg in printer.ERROR_CATEGORIES} + assert msgs["login"] == "the printer refused the access code (run shambu printer login, or check the code in Studio)" + assert msgs["setup"] == "shambu is not set up for the printer (shambu printer login, or Bambu Studio's Device tab)" + assert printer.error_category(ConfError("x")) == ("setup", msgs["setup"]) + assert [c for c, _, _ in printer.ERROR_CATEGORIES] == ["timeout", "tls", "login", "discovery", "identity", "report", "connect", "setup"] + + +# ---------------------------------------------------------------------------- typed read result and the profile IP +@pytest.mark.parametrize("insecure", [False, True]) +def test_a_forged_address_key_in_the_report_has_no_effect(insecure): + forged = {**full(), "_relocated_ip": "10.0.0.66", "relocation": {"new_ip": "10.0.0.66"}, "_topic": TOPIC, "_tls_verified": True} + svc = _profile_svc(reader_factory=_Rec(report=forged), insecure=insecure) + res = svc._bounded(svc._resolve_and_read) + assert (res.resolved.ip, res.resolved.ip_source) == ("10.0.0.9", "profile") and set(vars(res)) == {"report", "resolved"} + + +def test_the_profile_address_that_cannot_connect_names_the_login_fix(): + found = [0] + rec = _Rec(by_ip={"10.0.0.9": PrinterError("cannot connect to 10.0.0.9:8883: [Errno 65] No route to host")}) + svc = _profile_svc(reader_factory=rec, discovery=(_Pass([{"ip": NEW_IP, "method": "arp+8883"}], found=found),)) + with pytest.raises(PrinterError) as e: + svc.status() + assert str(e.value) == ( + "cannot connect to 10.0.0.9:8883: [Errno 65] No route to host The profile x2d expected this address " + "(last login 2026-10-20 09:15 UTC); " + "run: shambu printer login --printer x2d --ip , or shambu printer discover" + ) + assert printer.error_category(e.value)[0] == "connect" and found == [0] # no rediscovery, nothing written + svc.profile = dataclasses.replace(PROFILE, verified=None) + with pytest.raises(PrinterError, match=r"\(last login never\)"): + svc.status() + svc = _profile_svc(ip="10.0.0.77", reader_factory=_Rec(by_ip={"10.0.0.77": PrinterError("cannot connect to 10.0.0.77:8883: x")})) + with pytest.raises(PrinterError) as e: # the caller's --ip is not the profile's + svc.status() + assert "The profile" not in str(e.value) + + +def test_prepare_secrets_registers_a_stored_code_without_network(): + conf.forget_secrets() + v = MemoryVault(items={SERIAL: SENTINEL}) + + def no_network(*a): + raise AssertionError("prepare_secrets must not read the printer") + + svc = _profile_svc(v, reader_factory=no_network, cert_serial=no_network, discovery=()) + svc.prepare_secrets() + assert SENTINEL in conf.sensitive_values() and v.calls == [("get", SERIAL)] + conf.forget_secrets() + printer.PrinterService(sources=(printer.ProfileCredentials(PROFILE, MemoryVault()),), cert_serial=no_network).prepare_secrets() + printer.PrinterService(sources=(), cert_serial=no_network).prepare_secrets() # no serial: nothing, no error + assert SENTINEL not in conf.sensitive_values() + with pytest.raises(PrinterError, match="credentials file"): + _profile_svc(MemoryVault(error=VaultError("locked"))).prepare_secrets() + + +# ---------------------------------------------------------------------------- a broken Studio config is no source +@pytest.mark.parametrize( + "content", + [ + "{not json", + b"\xff\xfe not utf-8", + json.dumps([SERIAL]), + json.dumps({"app": [SERIAL], "access_code": [SENTINEL]}), + json.dumps({"app": {"user_last_selected_machine": 7}, "access_code": {SERIAL: 1234}}), + ], +) +def test_a_broken_studio_config_gives_nothing_and_the_certificate_names_the_serial(monkeypatch, tmp_path, content): + p = tmp_path / "BambuStudio.conf" + p.write_bytes(content if isinstance(content, bytes) else content.encode()) + monkeypatch.setattr(conf, "CONF", p) + studio = printer.StudioCredentials() + assert studio.serial() is None and studio.code(SERIAL) is None + rec = _Rec() + svc = printer.PrinterService( # `--ip 10.0.0.9 --access-code-file F` + ip="10.0.0.9", + reader_factory=rec, + timeout_s=None, + sources=(printer.OneShotCredentials(SENTINEL), studio), + cert_serial=_certs({"10.0.0.9": SERIAL}), + ) + res = svc._bounded(svc._resolve_and_read) + assert (res.resolved.serial, res.resolved.serial_source, res.resolved.code_source) == (SERIAL, "certificate", "flag") + assert rec.seen == [("10.0.0.9", SENTINEL)] + + +def test_an_unreadable_studio_config_gives_nothing(): + def unreadable(): + raise PermissionError(13, "Permission denied") + + studio = printer.StudioCredentials(load=unreadable) + assert studio.serial() is None and studio.code(SERIAL) is None + + +def test_subnet_probe_lets_an_unexpected_runner_error_through(caplog): + def broken(argv, **kw): + raise OSError(2, "No such file or directory") + + with caplog.at_level("WARNING", logger="shambu.printer"): + b = printer.SubnetProbe(runner=broken, probe=lambda *a: True) + assert b.find() == [] and "FileNotFoundError" in b.shortfall + + def buggy(argv, **kw): + raise KeyError("a bug, not a runner failure") + + with pytest.raises(KeyError): + printer.SubnetProbe(runner=buggy, probe=lambda *a: True).find() + + +def test_last_resolved_holds_what_the_read_used_without_the_code(): + svc = _profile_svc(reader_factory=_Rec(), timeout_s=5.0) + assert svc.last_resolved is None + svc.status() + r = svc.last_resolved + assert r is not None and (r.ip, r.ip_source, r.code_source) == ("10.0.0.9", "profile", "profile") + assert SENTINEL not in repr(svc) and SENTINEL not in repr(r) + svc.timeout_s = None # the caller's-thread path sets it too + svc.last_resolved = None + svc.status() + assert svc.last_resolved is not None and svc.last_resolved.ip == "10.0.0.9" + + +def test_last_resolved_stays_empty_for_an_abandoned_read(monkeypatch, tmp_path): + _service_conf(monkeypatch, tmp_path) + svc = printer.PrinterService(ip="10.0.0.9", reader_factory=_Hang, timeout_s=0.2) + with pytest.raises(PrinterError, match="timed out"): + svc.status() + time.sleep(1.6) # the worker finishes its read after the caller gave up + assert svc.last_resolved is None + + +# ---------------------------------------------------------------------------- reprs and scrubbed texts +def test_a_read_result_repr_holds_no_report_serial_ip_or_code(): + report = full() + report["print"]["subtask_name"] = f"job {SENTINEL}" + res = printer.ReadResult(report, printer.Resolved("10.0.0.9", SERIAL, SENTINEL, "flag", "flag", "flag")) + text = repr(res) + assert SENTINEL not in text and SERIAL not in text and "10.0.0.9" not in text and "subtask_name" not in text + + +def test_a_profile_name_that_holds_a_code_read_later_is_redacted_in_the_refusal(fake_paho): + fake_paho(rc=5) + named = "zq-code-7731" # also the stored code: registered only when the vault read runs + prof = dataclasses.replace(PROFILE, name=named) + assert named not in conf.sensitive_values() + svc = printer.PrinterService( + ip="10.0.0.9", profile=prof, sources=(printer.ProfileCredentials(prof, MemoryVault(items={SERIAL: named})),), timeout_s=None + ) + with pytest.raises(printer.LoginRefused) as e: + svc.status() + text = str(e.value) + assert named not in text and "profile )" in text and "--printer " in text + + +def test_the_profile_address_hint_scrubs_a_code_in_the_profile_name(): + named = "zq-code-7731" + prof = dataclasses.replace(PROFILE, name=named) + rec = _Rec(by_ip={"10.0.0.9": PrinterError("cannot connect to 10.0.0.9:8883: refused")}) + svc = printer.PrinterService( + ip="10.0.0.9", profile=prof, sources=(printer.ProfileCredentials(prof, MemoryVault(items={SERIAL: named})),), timeout_s=None + ) + svc.reader_factory = rec + with pytest.raises(PrinterError) as e: + svc.status() + assert named not in str(e.value) and "The profile expected this address" in str(e.value) + + +def test_the_identity_skip_log_is_scrubbed(caplog): + conf.remember_secret(SENTINEL) + hits = [{"ip": "10.0.0.50", "method": "arp+8883"}, {"ip": NEW_IP, "method": "arp+8883"}] + cert = _certs({"10.0.0.50": PrinterError(f"cannot connect to 10.0.0.50:8883: {SENTINEL}"), NEW_IP: SERIAL}) + svc = printer.PrinterService( + serial=SERIAL, sources=(printer.OneShotCredentials(SENTINEL),), discovery=(_Pass(hits),), cert_serial=cert, timeout_s=None + ) + with caplog.at_level("INFO", logger="shambu.printer"): + assert svc.resolve().ip == NEW_IP + logged = " ".join(r.getMessage() for r in caplog.records) + assert "discovery hit skipped: cannot connect to :8883: " in logged + assert SENTINEL not in logged and "10.0.0.50" not in logged + + +def test_scrub_ignores_an_empty_value(): + assert printer.scrub("abc", ["", "b"]) == "ac" + assert conf.scrub_text("abc", ["", "b"]) == "ac" + + +# ---------------------------------------------------------------------------- the default-route network +ROUTE_GW_SECOND = ROUTE.replace("gateway: 10.0.0.1", "gateway: 10.0.7.1") +IFCONFIG_ALIAS = IFCONFIG_24 + "\tinet 10.0.7.20 netmask 0xffffff00 broadcast 10.0.7.255\n" +IFCONFIG_TWO_ON_ONE = IFCONFIG_24 + "\tinet 10.0.0.6 netmask 0xffffff00 broadcast 10.0.0.255\n" +IFCONFIG_UTUN = "utun4: flags=8051 mtu 1380\n\tinet 10.0.0.5 --> 10.0.0.1 netmask 0xffffff00\n" + + +def test_subnet_hosts_takes_the_address_whose_network_holds_the_gateway(): + hosts = printer.subnet_hosts(ROUTE_GW_SECOND, IFCONFIG_ALIAS) + assert len(hosts) == 253 and hosts[0] == "10.0.7.1" and "10.0.7.20" not in hosts + assert not any(h.startswith("10.0.0.") for h in hosts) + assert printer.subnet_hosts(ROUTE_GW_SECOND, IFCONFIG_24) == [] # no address's network holds the gateway + + +def test_subnet_hosts_leaves_out_every_local_address(): + hosts = printer.subnet_hosts(ROUTE, IFCONFIG_TWO_ON_ONE) + assert len(hosts) == 252 and "10.0.0.5" not in hosts and "10.0.0.6" not in hosts + + +def test_subnet_hosts_refuses_point_to_point_odd_masks_and_a_missing_gateway(): + assert printer.subnet_hosts(ROUTE, IFCONFIG_UTUN) == [] + assert printer.subnet_hosts(ROUTE, IFCONFIG_24.replace("0xffffff00", "0xffff00ff")) == [] # not contiguous + assert printer.subnet_hosts(ROUTE.replace(" gateway: 10.0.0.1\n", ""), IFCONFIG_24) == [] + + +# ---------------------------------------------------------------------------- deadlines and runner failures +def test_subnet_probe_bounds_each_command_by_what_is_left(): + calls: list = [] + sp = printer.SubnetProbe(runner=_runner(calls=calls), probe=lambda *a: False).with_deadline(0.5) + sp.find() + assert [c[0][0] for c in calls] == ["route", "ifconfig", "arp"] + assert all(0 < c[1] <= 0.5 for c in calls) + calls.clear() + spent = printer.SubnetProbe(runner=_runner(calls=calls), probe=lambda *a: True).with_deadline(0.0) + assert spent.find() == [] and calls == [] and "TimeoutExpired" in spent.shortfall + + +def test_subnet_probe_a_nonzero_exit_or_an_arp_failure_means_no_scan(): + def route_fails(argv, **kw): + if argv[0] == "route": + return subprocess.CompletedProcess(argv, 1, "", "route: writing to routing socket: not in table") + return _runner()(argv, **kw) + + probed: list = [] + sp = printer.SubnetProbe(runner=route_fails, probe=lambda ip, *a: probed.append(ip) or True) + assert sp.find() == [] and "could not be read (OSError)" in sp.shortfall and probed == [] + + def arp_hangs(argv, **kw): + if argv[0] == "arp": + raise subprocess.TimeoutExpired(argv, kw["timeout"]) + return _runner()(argv, **kw) + + sp = printer.SubnetProbe(runner=arp_hangs, probe=lambda ip, *a: probed.append(ip) or True) + assert sp.find() == [] and "TimeoutExpired" in sp.shortfall and probed == [] + + +def test_certificate_serial_with_no_time_left_does_not_connect(cert_env, monkeypatch): + with pytest.raises(PrinterError) as e: + printer.certificate_serial("10.0.0.9", deadline=0.0) + assert str(e.value) == "timed out: no time left for the TLS handshake with 10.0.0.9:8883" and "addr" not in cert_env.seen + assert printer.error_category(e.value)[0] == "timeout" + real = printer.socket.create_connection + + def slow_connect(addr, timeout=None): + time.sleep(0.25) + return real(addr, timeout) + + monkeypatch.setattr(printer.socket, "create_connection", slow_connect) + with pytest.raises(PrinterError, match="no time left for the TLS handshake"): + printer.certificate_serial("10.0.0.9", deadline=0.2) + assert cert_env.seen["closed"] and "wrap_kw" not in cert_env.seen + + +# ---------------------------------------------------------------------------- core round 3 +def test_a_vault_error_text_is_scrubbed_before_it_reaches_the_printer_error(): + conf.remember_secret(SENTINEL) + v = MemoryVault(items={SERIAL: SENTINEL}, error=VaultError(f"damaged near {SENTINEL} in the file")) + with pytest.raises(PrinterError) as e: + printer.ProfileCredentials(PROFILE, v).code(SERIAL) + assert SENTINEL not in str(e.value) and "damaged near in the file" in str(e.value) + + +def test_subnet_hosts_takes_the_longest_prefix_whatever_the_line_order(): + wide = "\tinet 10.0.0.5 netmask 0xffff0000 broadcast 10.0.255.255\n" + narrow = "\tinet 10.0.0.6 netmask 0xffffff00 broadcast 10.0.0.255\n" + head = "en0: flags=8863 mtu 1500\n" + a = printer.subnet_hosts(ROUTE, head + wide + narrow) + b = printer.subnet_hosts(ROUTE, head + narrow + wide) + assert a == b and len(a) == 252 # the /24, without both local addresses + assert "10.0.0.5" not in a and "10.0.0.6" not in a and a[-1] == "10.0.0.254" + + +def test_arp_probe_bounds_arp_by_the_deadline(): + calls = [] + + def runner(argv, **kw): + calls.append(kw.get("timeout")) + return subprocess.CompletedProcess(argv, 0, ARP_AN, "") + + probe = printer.ArpProbe(runner=runner, probe=lambda ip, port, t: ip == "10.0.0.9") + assert probe.with_deadline(0.5).find() == [{"ip": "10.0.0.9", "method": "arp+8883"}] + assert calls == [0.5] + spent = probe.with_deadline(0.0) + assert spent.find() == [] and calls == [0.5] # no time left: arp -an does not run + assert spent.shortfall == "no time left for arp -an, so no ARP discovery" + + +def test_a_first_pass_inside_the_reserve_still_runs_bounded_by_what_is_left(monkeypatch): + deadlines = [] + + class Rec(_Pass): + def with_deadline(self, seconds): + deadlines.append(seconds) + return super().with_deadline(seconds) + + svc = printer.PrinterService( + serial=SERIAL, + sources=(printer.OneShotCredentials(SENTINEL),), + discovery=(Rec([]), Rec([])), + cert_serial=_certs({}), + timeout_s=15.0, + ) + monkeypatch.setattr(svc, "_left", lambda a: 0.3) # a short timeout: inside the MQTT reserve from the start + with pytest.raises(PrinterError) as e: + svc.resolve() + assert deadlines == [0.3] # the first pass ran, bounded by what is left; the second did not start + assert "(1 discovery pass(es) not run before the timeout)" in str(e.value) + + +def test_verified_discovery_on_a_large_lan_keeps_time_for_the_identity_check(): + """Codex's reproduction: 1000 ARP hosts, only the printer answers at once and every other host takes its probe + timeout, so the sweep fills its whole window. With 15 s, the sweep must leave the identity check its allowance.""" + others = [f"10.0.{i // 250 + 1}.{i % 250 + 1}" for i in range(1000)] + table = "".join(f"? ({ip}) at 0:1:2:3:4:5 on en0 ifscope [ethernet]\n" for ip in [NEW_IP, *others]) + + def runner(argv, **kw): + return subprocess.CompletedProcess(argv, 0, table, "") + + def probe(ip, port, timeout): + if ip == NEW_IP: + return True + time.sleep(timeout) + return False + + seen = [] + svc = printer.PrinterService( + serial=SERIAL, + sources=(printer.OneShotCredentials(SENTINEL),), + discovery=(printer.ArpProbe(runner=runner, probe=probe),), + reader_factory=_Rec(), + cert_serial=_certs({NEW_IP: SERIAL}, seen), + timeout_s=15.0, + ) + res = svc._bounded(svc._resolve_and_read) + assert res.resolved.ip == NEW_IP and "identity" in svc.timings + assert [ip for ip, _ in seen] == [NEW_IP] + assert 0 < seen[0][1] <= printer.CONNECT_S + printer.HANDSHAKE_S + assert svc.timings["probe"] <= 15.0 - (printer.CONNECT_S + printer.HANDSHAKE_S) - printer.IDENTITY_S + 0.5 + + +def test_last_resolved_is_cleared_by_an_overlapping_read_that_fails(): + made = [] + b_started = threading.Event() + + class Reader: + def __init__(self, *a): + made.append(self) + self.n = len(made) + + def read(self): + if self.n == 1: # A: succeeds once B is running + b_started.wait(2.0) + return full() + if self.n == 2: # B: fails after A returned + b_started.set() + time.sleep(0.3) + raise PrinterError("the printer closed the connection") + return full() + + svc = _profile_svc(reader_factory=Reader, timeout_s=5.0) + box: dict = {} + + def run(key): + try: + box[key] = svc.status() + except PrinterError as e: + box[key] = e + + a = threading.Thread(target=run, args=("a",)) + a.start() + deadline = time.monotonic() + 2.0 + while not made and time.monotonic() < deadline: + time.sleep(0.01) + b = threading.Thread(target=run, args=("b",)) + b.start() + a.join(3.0) + assert not isinstance(box["a"], PrinterError) and svc.last_resolved is not None # A set it on completion + b.join(3.0) + assert isinstance(box["b"], PrinterError) + assert svc.last_resolved is None # B finished last, with an error + svc.status() + assert svc.last_resolved is not None and svc.last_resolved.ip == "10.0.0.9" diff --git a/tests/test_profiles.py b/tests/test_profiles.py new file mode 100644 index 0000000..901fc6c --- /dev/null +++ b/tests/test_profiles.py @@ -0,0 +1,561 @@ +"""profiles.py: the printer-profile schema, the validators and the Profile/ProfileSet types; conf.py: printers.json I/O.""" + +from __future__ import annotations + +import json +import os +import threading + +import pytest + +from shambu import conf +from shambu.errors import ConfError +from shambu.profiles import ( + CLASH, + PROFILES_VERSION, + REFUSAL, + UNKNOWN, + Profile, + ProfileError, + ProfileSet, + validate_document, + validate_profile, +) + +SERIAL = "00X0TEST0000999" +SERIAL2 = "00X0TEST0000888" +SENTINEL = "SENTINEL-CODE-8812" +FULLWIDTH_YEAR = "".join(chr(0xFF10 + int(c)) for c in "2026") # \d matches these; [0-9] does not + + +def entry(**over) -> dict: + d = { + "name": "desk", + "serial": SERIAL, + "ip": "10.0.0.9", + "mode": "lan", + "insecure": False, + "added": "2026-10-27T09:00:00Z", + "verified": None, + } + return d | over + + +def doc(*printers: dict, default: str | None = "desk", version=PROFILES_VERSION) -> dict: + return {"version": version, "default": default, "printers": list(printers)} + + +def test_profile_round_trip(): + d = entry(verified="2026-10-27T09:05:00Z", ip="fe80::1", mode="unknown") + p = Profile.from_dict(d) + assert p.to_dict() == d + assert p.public()["serial"] == conf.mask(SERIAL) and p.public()["ip"] == "fe80::1" + + +def test_profile_set_round_trip(): + d = doc(entry(), entry(name="garage", serial=SERIAL2, ip=None, mode="cloud", insecure=True)) + ps = ProfileSet.from_dict(d) + assert ps.to_dict() == d + assert ps.get("garage").serial == SERIAL2 and ps.by_serial(SERIAL).name == "desk" + assert ps.get("nope") is None and ps.by_serial(SERIAL2[:-1] + "7") is None + + +def test_an_empty_document_is_valid(): + assert ProfileSet.from_dict(doc(default=None)) == ProfileSet() + assert ProfileSet().to_dict() == doc(default=None) + + +@pytest.mark.parametrize( + ("change", "refused"), + [ + ({"extra": 1}, "extra"), + ({"name": "10.0.0.40"}, "10.0.0.40"), + ({"name": "192.0.2.40"}, "192.0.2.40"), + ({"name": "x2d.local"}, "x2d.local"), + ({"name": "00X…999"}, "00X"), + ({"name": "00X...999"}, "00X"), + ({"name": "-desk"}, "-desk"), + ({"name": "a" * 33}, "a" * 33), + ({"name": "desk room"}, "desk room"), + ({"name": 7}, "7"), + ({"serial": "00X0"}, "00X0"), + ({"serial": "00X0TEST-000999"}, "00X0TEST-000999"), + ({"ip": "10.0.0.9:8883"}, "10.0.0.9"), + ({"ip": "printer.local"}, "printer.local"), + ({"ip": 167772169}, "167772169"), + ({"ip": "fe80::1%SENTINEL-CODE-8812"}, SENTINEL), # no IPv6 scope id + ({"ip": "fe80::1%en0"}, "en0"), + ({"name": SERIAL}, SERIAL), + ({"name": f"x{SERIAL.lower()}"}, SERIAL.lower()), + ({"name": "desk-" + conf.mask(SERIAL)}, "desk-"), + ({"added": FULLWIDTH_YEAR + "-10-27T09:00:00Z"}, FULLWIDTH_YEAR), # full-width digits + ({"added": "2026-13-27T09:00:00Z"}, "2026-13"), # month 13 + ({"verified": "2026-02-30T09:00:00Z"}, "2026-02-30"), + ({"mode": "wifi"}, "wifi"), + ({"mode": None}, "never-in-text"), + ({"insecure": 1}, "never-in-text"), + ({"insecure": "false"}, "never-in-text"), + ({"added": "2026-10-27 09:00:00"}, "2026-10-27"), + ({"added": None}, "never-in-text"), + ({"verified": "yesterday"}, "yesterday"), + ], +) +def test_validate_profile_refuses(change, refused): + with pytest.raises(ProfileError) as e: + validate_profile(entry(**change)) + assert str(e.value) == REFUSAL and refused not in str(e.value) + + +def test_validate_profile_refuses_a_missing_key_and_a_non_dict(): + d = entry() + del d["verified"] + for bad in (d, None, [entry()], "desk"): + with pytest.raises(ProfileError, match=f"^{REFUSAL}$"): + validate_profile(bad) + + +def test_a_known_secret_in_a_name_or_ip_is_refused(): + d = entry(name="desk-abcd12") + validate_profile(d) # no secret given: only the shape is checked + for secrets in (("abcd12",), ("ABCD12",)): # any case + with pytest.raises(ProfileError) as e: + validate_profile(d, secrets=secrets) + assert str(e.value) == REFUSAL and "abcd12" not in str(e.value) + with pytest.raises(ProfileError): + validate_profile(entry(ip="10.0.0.99"), secrets=("0.99",)) + validate_profile(entry(), secrets=("", SENTINEL)) # an empty secret matches nothing + + +def test_validate_document_and_upsert_pass_the_secrets_through(): + ok = doc(entry(name="desk-abcd12"), default=None) + validate_document(ok) + with pytest.raises(ProfileError): + validate_document(ok, secrets=("abcd12",)) + with pytest.raises(ProfileError): + ProfileSet.from_dict(ok, secrets=("abcd12",)) + with pytest.raises(ProfileError, match=f"^{REFUSAL}$"): + ProfileSet().upsert(Profile.from_dict(entry(name="desk-abcd12")), secrets=("abcd12",)) + + +def test_repr_holds_neither_the_serial_nor_the_ip(): + p = Profile.from_dict(entry()) + ps = ProfileSet().upsert(p) + for text in (repr(p), repr(ps)): + assert SERIAL not in text and "10.0.0.9" not in text and "desk" in text + + +@pytest.mark.parametrize( + "bad", + [ + doc(entry(), version=2), + doc(entry(), version=True), + doc(entry(), entry(serial=SERIAL2)), # duplicate names + doc(entry(), entry(name="garage")), # duplicate serials + doc(entry(), default="garage"), # the default names no entry + doc(entry(), default=7), + doc(entry(name="x2d.local"), default=None), + {"version": 1, "default": None}, + doc(default=None) | {"extra": []}, + {"version": 1, "default": None, "printers": {}}, + [entry()], + ], +) +def test_validate_document_refuses(bad): + with pytest.raises(ProfileError) as e: + validate_document(bad) + assert str(e.value) == REFUSAL and SERIAL not in str(e.value) and "x2d.local" not in str(e.value) + with pytest.raises(ProfileError): + ProfileSet.from_dict(bad) + + +def test_the_first_profile_becomes_the_default_and_selected_none_returns_it(): + ps = ProfileSet().upsert(Profile.from_dict(entry())) + assert ps.default == "desk" and ps.selected(None).name == "desk" + ps = ps.upsert(Profile.from_dict(entry(name="garage", serial=SERIAL2))) + assert ps.default == "desk" and ps.selected(None).name == "desk" and ps.selected("garage").serial == SERIAL2 + assert ps.selected("nope") is None and ProfileSet().selected(None) is None + assert ps.upsert(Profile.from_dict(entry(name="garage", serial=SERIAL2)), default=True).default == "garage" + + +def test_upsert_by_serial_keeps_added(): + ps = ProfileSet().upsert(Profile.from_dict(entry())) + again = Profile.from_dict(entry(name="office", ip="10.0.0.10", added="2026-10-28T10:00:00Z", verified="2026-10-28T10:00:00Z")) + ps2 = ps.upsert(again) + (p,) = ps2.printers + assert (p.name, p.ip, p.added, p.verified) == ("office", "10.0.0.10", "2026-10-27T09:00:00Z", "2026-10-28T10:00:00Z") + assert ps2.default == "office" # the renamed printer stays the default + assert ps.printers[0].name == "desk" # the old set is unchanged + + +def test_upsert_refuses_a_name_another_serial_has(): + ps = ProfileSet().upsert(Profile.from_dict(entry())) + with pytest.raises(ProfileError) as e: + ps.upsert(Profile.from_dict(entry(serial=SERIAL2))) + assert str(e.value) == CLASH and SERIAL2 not in str(e.value) + + +def test_upsert_refuses_a_profile_that_does_not_validate(): + with pytest.raises(ProfileError, match=f"^{REFUSAL}$"): + ProfileSet().upsert(Profile(**entry(name="x2d.local"))) + + +def test_remove_moves_the_default(): + ps = ProfileSet().upsert(Profile.from_dict(entry())).upsert(Profile.from_dict(entry(name="garage", serial=SERIAL2))) + after = ps.remove("desk") + assert after.default == "garage" and [p.name for p in after.printers] == ["garage"] + assert ps.remove("garage").default == "desk" # removing another profile keeps the default + assert after.remove("garage") == ProfileSet() + with pytest.raises(ProfileError) as e: + ps.remove("nope") + assert str(e.value) == UNKNOWN and "nope" not in str(e.value) + + +# ---------------------------------------------------------------------------- printers.json (conf.py) +def two() -> ProfileSet: + return ProfileSet.from_dict(doc(entry(), entry(name="garage", serial=SERIAL2, ip=None))) + + +def test_load_of_a_missing_file_is_an_empty_set(tmp_path): + assert conf.load_profiles() == ProfileSet(None, ()) + assert conf.load_profiles(tmp_path / "printers.json") == ProfileSet() + + +def test_save_then_load_round_trips_and_leaves_only_the_file(tmp_path): + p = tmp_path / "shambu" / "printers.json" + assert conf.save_profiles(two(), p) == p + assert conf.load_profiles(p) == two() + assert json.loads(p.read_text())["version"] == 1 + assert [x.name for x in p.parent.iterdir()] == ["printers.json"] # no temp file left + assert p.stat().st_mode & 0o777 == 0o600 + + +def test_save_uses_the_default_path(): + assert conf.save_profiles(two()) == conf.PRINTERS + assert conf.load_profiles() == two() + + +@pytest.mark.parametrize( + "text", + [ + "{not json " + SERIAL, + json.dumps(doc(entry(), version=2)), + json.dumps([entry()]), + json.dumps(doc({k: v for k, v in entry().items() if k != "verified"})), + json.dumps(doc(entry(name="10.0.0.40"), default=None)), + ], +) +def test_a_damaged_file_is_refused_without_its_content(tmp_path, text): + p = tmp_path / "printers.json" + p.write_text(text) + with pytest.raises(ConfError) as e: + conf.load_profiles(p) + assert str(e.value) == REFUSAL + assert SERIAL not in str(e.value) and "10.0.0" not in str(e.value) and e.value.__cause__ is None + + +def test_save_refuses_an_invalid_set_and_writes_nothing(tmp_path): + p = tmp_path / "printers.json" + bad = ProfileSet(default="desk", printers=(Profile(**entry(name="x2d.local")),)) + with pytest.raises(ConfError) as e: + conf.save_profiles(bad, p) + assert str(e.value) == REFUSAL and "x2d.local" not in str(e.value) + assert list(tmp_path.iterdir()) == [] + + +def test_a_write_failure_is_a_data_file_error(tmp_path, monkeypatch): + def boom(*a): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(conf.os, "replace", boom) + with pytest.raises(conf.DataFileError, match="No space left"): + conf.save_profiles(two(), tmp_path / "printers.json") + assert list(tmp_path.iterdir()) == [] + + +def test_concurrent_updates_both_land(tmp_path): + """Thread A holds the lock inside its change; thread B's update waits for it and then sees A's profile.""" + p = tmp_path / "printers.json" + inside, release = threading.Event(), threading.Event() + + def slow(ps): + inside.set() + release.wait(5) + return ps.upsert(Profile.from_dict(entry())) + + a = threading.Thread(target=conf.update_profiles, args=(slow, p)) + a.start() + assert inside.wait(5) + b = threading.Thread( + target=conf.update_profiles, args=(lambda ps: ps.upsert(Profile.from_dict(entry(name="garage", serial=SERIAL2))), p) + ) + b.start() + b.join(0.3) + assert b.is_alive() # B waits on the lock + release.set() + a.join(5) + b.join(5) + assert sorted(x.name for x in conf.load_profiles(p).printers) == ["desk", "garage"] + + +def test_update_returns_the_saved_set(): + ps = conf.update_profiles(lambda ps: ps.upsert(Profile.from_dict(entry()))) + assert ps == conf.load_profiles() and ps.default == "desk" + + +def test_locked_profiles_serialises_two_commits_and_lets_fn_save(tmp_path): + p = tmp_path / "printers.json" + inside, release = threading.Event(), threading.Event() + seen: list[tuple[str, ...]] = [] + + def commit(new: dict, wait: bool): + def fn(ps): + seen.append(tuple(x.name for x in ps.printers)) + if wait: + inside.set() + release.wait(5) + conf.save_profiles(ps.upsert(Profile.from_dict(new)), p) # the save inside the lock + return new["name"] + + return fn + + out: list[str] = [] + a = threading.Thread(target=lambda: out.append(conf.locked_profiles(commit(entry(), True), p))) + a.start() + assert inside.wait(5) + b = threading.Thread(target=lambda: out.append(conf.locked_profiles(commit(entry(name="garage", serial=SERIAL2), False), p))) + b.start() + b.join(0.3) + assert b.is_alive() and seen == [()] # B has not loaded yet + release.set() + a.join(5) + b.join(5) + assert out == ["desk", "garage"] and seen == [(), ("desk",)] # B saw A's commit + assert [x.name for x in conf.load_profiles(p).printers] == ["desk", "garage"] + + +def test_select_profile_default_by_name_and_unknown(tmp_path): + ps = two() + assert conf.select_profile(None, ps).name == "desk" + assert conf.select_profile("garage", ps).serial == SERIAL2 + assert conf.select_profile(None, ProfileSet()) is None + with pytest.raises(ConfError) as e: + conf.select_profile("nope", ps) + assert str(e.value) == "no printer profile named 'nope': shambu printer list" + p = tmp_path / "printers.json" + conf.save_profiles(ps, p) + assert conf.select_profile("garage", path=p).serial == SERIAL2 + assert conf.select_profile(None) is None # no file at conf.PRINTERS + + +def test_load_registers_every_profile_serial_and_its_mask(tmp_path, monkeypatch): + conf.save_profiles(two()) + conf.forget_secrets() + conf.remember_secret(SENTINEL) + assert SERIAL not in conf.sensitive_values() + conf.load_profiles() + vals = conf.sensitive_values() # no Studio config + for s in (SERIAL, SERIAL2, conf.mask(SERIAL), conf.mask(SERIAL2), SENTINEL): + assert s in vals + studio = tmp_path / "BambuStudio.conf" + studio.write_text( + json.dumps({"app": {"user_last_selected_machine": "00X0STUDIO00777"}, "access_code": {"00X0STUDIO00777": "abcd1234"}}) + ) + monkeypatch.setattr(conf, "CONF", studio) + vals = conf.sensitive_values() + for s in (SERIAL, SERIAL2, conf.mask(SERIAL), "00X0STUDIO00777", conf.mask("00X0STUDIO00777"), "abcd1234", SENTINEL): + assert s in vals + assert SERIAL not in conf.secret_values() and conf.mask(SERIAL) not in conf.secret_values() # codes only + conf.forget_secrets() + assert SERIAL not in conf.sensitive_values() + + +def test_sensitive_values_do_not_read_the_profile_file(monkeypatch): + def boom(p): + raise AssertionError("sensitive_values must not read printers.json") + + monkeypatch.setattr(conf, "_read_profiles", boom) + conf.remember_secret(SENTINEL) + assert conf.sensitive_values() == [SENTINEL] + + +def test_a_hand_edited_name_holding_a_runtime_secret_refuses_to_load(tmp_path): + p = tmp_path / "printers.json" + p.write_text(json.dumps(doc(entry(name="desk-xy12"), default=None))) + assert conf.load_profiles(p).printers[0].name == "desk-xy12" + conf.remember_secret("xy12") + with pytest.raises(ConfError) as e: + conf.load_profiles(p) + assert str(e.value) == REFUSAL and "xy12" not in str(e.value) + with pytest.raises(ConfError, match=f"^{REFUSAL}$"): + conf.save_profiles(ProfileSet.from_dict(doc(entry(name="desk-xy12"), default=None)), tmp_path / "other.json") + + +@pytest.mark.parametrize("studio", [{"access_code": ["broken"]}, ["a", "list", "root"], {"access_code": {"S": 5}}, {"app": "x"}]) +def test_a_malformed_studio_config_never_breaks_scrubbing(tmp_path, monkeypatch, studio): + p = tmp_path / "BambuStudio.conf" + p.write_text(json.dumps(studio)) + monkeypatch.setattr(conf, "CONF", p) + conf.remember_secret(SENTINEL) + assert conf.sensitive_values() == [SENTINEL] == conf.secret_values() + assert conf.scrub_text(f"x {SENTINEL}") == "x " + + +def test_access_code_registers_the_code(): + assert not conf.CONF.exists() # the offline fixture + assert conf.access_code(SERIAL, {"access_code": {SERIAL: "abcd1234"}}) == "abcd1234" + assert conf.scrub_text("code abcd1234 here") == "code here" + + +# ---------------------------------------------------------------------------- the config dir and the helpers +def test_config_dir_rule(monkeypatch, tmp_path): + monkeypatch.setenv("SHAMBU_CONFIG_DIR", str(tmp_path / "own")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + assert conf.config_dir() == tmp_path / "own" + monkeypatch.setenv("SHAMBU_CONFIG_DIR", "") + assert conf.config_dir() == tmp_path / "xdg" / "shambu" + monkeypatch.delenv("SHAMBU_CONFIG_DIR") + monkeypatch.setenv("XDG_CONFIG_HOME", "") + monkeypatch.setenv("HOME", str(tmp_path / "home")) + assert conf.config_dir() == tmp_path / "home" / ".config" / "shambu" + + +def test_a_relative_config_dir_value_counts_as_unset(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("SHAMBU_CONFIG_DIR", "relative/own") + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + assert conf.config_dir() == tmp_path / "xdg" / "shambu" # falls through to the next rule + monkeypatch.setenv("XDG_CONFIG_HOME", "./xdg") + assert conf.config_dir() == tmp_path / "home" / ".config" / "shambu" + monkeypatch.setenv("SHAMBU_CONFIG_DIR", "~/own") # a ~ value expands to an absolute path + assert conf.config_dir() == tmp_path / "home" / "own" + monkeypatch.delenv("SHAMBU_CONFIG_DIR") + monkeypatch.setenv("XDG_CONFIG_HOME", "~/xdg") + assert conf.config_dir() == tmp_path / "home" / "xdg" / "shambu" + + +def test_save_profiles_refuses_a_symlinked_file(tmp_path): + target = tmp_path / "target.json" + empty = json.dumps({"version": 1, "default": None, "printers": []}) + target.write_text(empty) + link = tmp_path / "config" / "printers.json" + link.parent.mkdir() + link.symlink_to(target) + with pytest.raises(conf.DataFileError) as e: + conf.save_profiles(ProfileSet(None, ()), link) + assert str(e.value) == f"{link}: not a regular file; remove it and retry" + assert link.is_symlink() and target.read_text() == empty and [x.name for x in link.parent.iterdir()] == ["printers.json"] + with pytest.raises(conf.DataFileError, match="regular file"): # the same through update_profiles (refused on its load) + conf.update_profiles(lambda ps: ps, link) + assert link.is_symlink() and target.read_text() == empty + + +def test_atomic_write_sets_the_mode_and_creates_a_private_dir(tmp_path): + p = tmp_path / "new" / "printers.json" + conf._atomic_write(p, {"version": 1}, ".printers-") + assert p.stat().st_mode & 0o777 == 0o600 and p.parent.stat().st_mode & 0o777 == 0o700 + q = tmp_path / "calibration.json" + conf._atomic_write(q, {"version": 1}, ".calibration-", mode=0o640) + assert q.stat().st_mode & 0o777 == 0o640 + tmp_path.chmod(0o755) + conf._atomic_write(tmp_path / "x.json", {}, ".x-") + assert tmp_path.stat().st_mode & 0o777 == 0o755 # a dir that exists keeps its mode + + +def test_atomic_write_mkstemp_failure_is_a_data_file_error(tmp_path, monkeypatch): + def boom(**kw): + raise OSError(13, "Permission denied") + + monkeypatch.setattr(conf.tempfile, "mkstemp", boom) + with pytest.raises(conf.DataFileError, match="cannot write there"): + conf._atomic_write(tmp_path / "printers.json", {"version": 1}, ".printers-") + assert list(tmp_path.iterdir()) == [] + + +def test_locked_cannot_create_the_lock_file(tmp_path): + parent = tmp_path / "a-file" + parent.write_text("") + with pytest.raises(conf.DataFileError, match="cannot create its lock file"): + conf._locked(parent / "printers.json", lambda: 1) + + +def test_locked_flock_failure_is_a_data_file_error(tmp_path, monkeypatch): + def boom(fd, op): + raise OSError(35, "Resource temporarily unavailable") + + monkeypatch.setattr(conf.fcntl, "flock", boom) + ran = [] + with pytest.raises(conf.DataFileError, match="cannot lock it"): + conf._locked(tmp_path / "printers.json", lambda: ran.append(1)) + assert ran == [] + + +def test_the_missing_code_text_names_both_ways_to_fix_it(): + with pytest.raises(ConfError) as e: + conf.access_code(SERIAL, {"access_code": {}}) + assert str(e.value) == ( + f"no access code for serial {conf.mask(SERIAL)}: run shambu printer login, or open the printer once in Bambu Studio's Device tab" + ) + + +def test_a_name_holding_another_entrys_serial_is_refused(): + """A name is checked against every serial in the document, so the writer and every later load agree.""" + validate_profile(entry(name=f"n{SERIAL2.lower()}")) # alone, the entry passes + with pytest.raises(ProfileError): + validate_document(doc(entry(name=f"n{SERIAL2.lower()}"), entry(name="b", serial=SERIAL2), default=None)) + validate_document(doc(entry(), entry(name="b", serial=SERIAL2))) + + +# ---------------------------------------------------------------------------- private reads (core round 3) +def test_a_symlinked_printers_file_is_refused_on_load(tmp_path): + target = tmp_path / "target.json" + target.write_text(json.dumps(doc(entry()))) + link = tmp_path / "printers.json" + link.symlink_to(target) + with pytest.raises(conf.DataFileError) as e: + conf.load_profiles(link) + assert str(e.value) == f"{link}: not a private regular file; remove it and retry" + assert SERIAL not in str(e.value) + + +def test_a_fifo_at_the_printers_path_is_refused_without_blocking(tmp_path): + fifo = tmp_path / "printers.json" + os.mkfifo(fifo, 0o600) + done = threading.Event() + box: dict = {} + + def run(): + try: + conf.load_profiles(fifo) + except conf.DataFileError as e: + box["error"] = str(e) + finally: + done.set() + + threading.Thread(target=run, daemon=True).start() + assert done.wait(2.0), "a FIFO at the path blocked the load" + assert box["error"] == f"{fifo}: not a private regular file; remove it and retry" + + +def test_a_directory_or_another_users_printers_file_is_refused(tmp_path, monkeypatch): + d = tmp_path / "dir" / "printers.json" + d.mkdir(parents=True) + with pytest.raises(conf.DataFileError, match="not a private regular file"): + conf.load_profiles(d) + p = tmp_path / "printers.json" + conf.save_profiles(ProfileSet(None, ()), p) + assert conf.load_profiles(p).printers == () # this user's regular file loads + monkeypatch.setattr(conf.os, "getuid", lambda: os.stat(p).st_uid + 1) + with pytest.raises(conf.DataFileError, match="not a private regular file"): + conf.load_profiles(p) + + +def test_serials_match_without_regard_to_case(): + """A second login that types the serial in another case refreshes the one profile and keeps the stored case.""" + ps = ProfileSet().upsert(Profile.from_dict(entry(name="x2d"))) + lower = Profile.from_dict(entry(name="other", serial=SERIAL.lower(), ip="10.0.0.20")) + out = ps.upsert(lower) + assert len(out.printers) == 1 + assert out.printers[0].serial == SERIAL and out.printers[0].name == "other" and out.printers[0].ip == "10.0.0.20" + assert ps.by_serial(SERIAL.lower()) is not None + with pytest.raises(ProfileError, match=REFUSAL): + validate_document(doc(entry(name="x2d"), entry(name="dup", serial=SERIAL.lower()), default="x2d")) diff --git a/tests/test_vault.py b/tests/test_vault.py new file mode 100644 index 0000000..3052c5e --- /dev/null +++ b/tests/test_vault.py @@ -0,0 +1,476 @@ +"""vault.py: the shape checks, FileVault (the 0600 credentials file in the config dir), MemoryVault (the test fake and +second Vault implementation) and the runtime secret set in conf.py that every vault read feeds.""" + +from __future__ import annotations + +import json +import os +import stat +import threading + +import pytest + +from shambu import conf, vault +from shambu.vault import BAD_FILE, FileVault, MemoryVault, VaultError, safe_code, safe_serial + +SERIAL = "00X0TEST0000999" +SERIAL2 = "00X0TEST0000888" +CODE = "SENTINEL-CODE-8812" +REAL_DEFAULT_VAULT = vault.default_vault # read at import, before the offline fixture patches it + + +@pytest.mark.parametrize("code", [CODE, "12345678", "abcd", "a1._:@+=/-z", "a" * 128]) +def test_safe_code_accepts(code): + assert safe_code(code) + + +@pytest.mark.parametrize("code", ["", "abc", "-abc1234", "ab cd1234", 'ab"cd1234', "ab\\cd1234", "ab'cd1234", "a" * 129, None, 12345678]) +def test_safe_code_refuses(code): + assert not safe_code(code) + + +@pytest.mark.parametrize( + ("serial", "ok"), + [(SERIAL, True), ("A" * 32, True), ("A" * 7, False), ("A" * 33, False), ("00X0-TEST999", False), ("", False), (None, False)], +) +def test_safe_serial(serial, ok): + assert safe_serial(serial) is ok + + +def test_memory_vault_get_put_delete(): + v = MemoryVault() + assert v.get(SERIAL) is None + v.put(SERIAL, CODE) + assert v.get(SERIAL) == CODE + v.put(SERIAL, "12345678") # put replaces + assert v.get(SERIAL) == "12345678" + assert v.delete(SERIAL) is True + assert v.delete(SERIAL) is False + assert v.get(SERIAL) is None + assert v.calls == [ + ("get", SERIAL), + ("put", SERIAL), + ("get", SERIAL), + ("put", SERIAL), + ("get", SERIAL), + ("delete", SERIAL), + ("delete", SERIAL), + ("get", SERIAL), + ] + assert CODE not in repr(v) and CODE not in json.dumps(v.calls) + + +def test_memory_vault_refuses_unsafe_values_before_recording(): + v = MemoryVault() + for call in (lambda: v.put(SERIAL, "ab cd1234"), lambda: v.put("bad serial", CODE), lambda: v.get("short"), lambda: v.delete("")): + with pytest.raises(VaultError) as e: + call() + assert "ab cd1234" not in str(e.value) and CODE not in str(e.value) + assert v.calls == [] and v.items == {} + + +def test_memory_vault_error_propagates(): + v = MemoryVault(items={SERIAL: CODE}, error=VaultError("file locked")) + with pytest.raises(VaultError, match="file locked"): + v.get(SERIAL) + assert CODE not in conf.secret_values() # a failed get registers nothing + for call in (lambda: v.put(SERIAL, CODE), lambda: v.delete(SERIAL)): + with pytest.raises(VaultError, match="file locked"): + call() + assert v.calls == [("get", SERIAL), ("put", SERIAL), ("delete", SERIAL)] + assert v.items == {SERIAL: CODE} + assert CODE in conf.secret_values() # a put registers its code before anything else, even when it then fails + + +def test_memory_vault_get_registers_the_code(): + assert CODE not in conf.sensitive_values() and CODE not in conf.secret_values() + MemoryVault(items={SERIAL: CODE}).get(SERIAL) + assert CODE in conf.sensitive_values() and CODE in conf.secret_values() + assert conf.scrub_text(f"code {CODE} here") == "code here" + conf.forget_secrets() + assert CODE not in conf.sensitive_values() and CODE not in conf.secret_values() + + +def test_default_vault_in_tests_is_the_shared_fake(offline): + assert vault.default_vault() is offline.vault is vault.default_vault() + offline.vault.put(SERIAL, CODE) + assert vault.default_vault().get(SERIAL) == CODE + + +def test_default_vault_is_the_credentials_file(): + v = REAL_DEFAULT_VAULT() + assert isinstance(v, FileVault) and v.path is None and v._path() == conf.CREDENTIALS + + +# ---------------------------------------------------------------------------- FileVault +def _mode(p) -> int: + return stat.S_IMODE(p.stat().st_mode) + + +def test_file_vault_round_trip_creates_a_private_file_in_a_private_dir(tmp_path): + p = tmp_path / "config" / "shambu" / "credentials.json" + v = FileVault(p) + assert v.get(SERIAL) is None and not p.exists() + v.put(SERIAL, CODE) + assert v.get(SERIAL) == CODE + assert json.loads(p.read_text()) == {"version": 1, "codes": {SERIAL: CODE}} + assert _mode(p) == 0o600 and _mode(p.parent) == 0o700 + assert sorted(x.name for x in p.parent.iterdir()) == ["credentials.json", "credentials.json.lock"] # no temp file + v.put(SERIAL, "12345678") # put replaces + assert v.get(SERIAL) == "12345678" + assert CODE not in repr(v) + + +def test_file_vault_holds_two_serials_and_delete_says_whether_there_was_one(tmp_path): + v = FileVault(tmp_path / "credentials.json") + assert v.delete(SERIAL) is False + v.put(SERIAL, CODE) + v.put(SERIAL2, "87654321") + assert (v.get(SERIAL), v.get(SERIAL2)) == (CODE, "87654321") + assert v.delete(SERIAL) is True + assert v.delete(SERIAL) is False + assert (v.get(SERIAL), v.get(SERIAL2)) == (None, "87654321") + + +def test_file_vault_refuses_a_file_others_can_read(tmp_path): + p = tmp_path / "credentials.json" + p.write_text(json.dumps({"version": 1, "codes": {SERIAL: CODE}})) + p.chmod(0o644) + with pytest.raises(VaultError) as e: + FileVault(p).get(SERIAL) + assert str(e.value) == f"{p} must be private: chmod 600 {p}" + assert CODE not in str(e.value) and CODE not in conf.secret_values() + + +@pytest.mark.parametrize( + "text", + [ + "{not json " + CODE, + json.dumps([CODE]), + json.dumps({"version": 2, "codes": {SERIAL: CODE}}), + json.dumps({"version": True, "codes": {SERIAL: CODE}}), + json.dumps({"version": 1, "codes": [CODE]}), + json.dumps({"version": 1, "codes": {SERIAL: CODE}, "extra": 1}), + json.dumps({"version": 1, "codes": {SERIAL: 12345678}}), + json.dumps({"version": 1, "codes": {"bad serial": CODE}}), + ], +) +def test_file_vault_refuses_a_damaged_file_with_a_fixed_text(tmp_path, text): + p = tmp_path / "credentials.json" + p.write_text(text) + p.chmod(0o600) + with pytest.raises(VaultError) as e: + FileVault(p).get(SERIAL) + assert str(e.value) == BAD_FILE and CODE not in str(e.value) and e.value.__cause__ is None + + +@pytest.mark.parametrize( + ("serial", "code"), [(SERIAL, "ab cd1234"), (SERIAL, "-abc1234"), (SERIAL, "abc"), ("bad serial", CODE), ("short", CODE)] +) +def test_file_vault_put_refuses_unsafe_values_before_any_write(tmp_path, serial, code): + p = tmp_path / "config" / "credentials.json" + with pytest.raises(VaultError) as e: + FileVault(p).put(serial, code) + assert str(e.value) == vault.UNSAFE and code not in str(e.value) and serial not in str(e.value) + assert not p.parent.exists() + + +@pytest.mark.parametrize("op", ["get", "delete"]) +def test_file_vault_get_and_delete_refuse_an_unsafe_serial(tmp_path, op): + with pytest.raises(VaultError, match="serial"): + getattr(FileVault(tmp_path / "credentials.json"), op)("bad serial; rm") + assert list(tmp_path.iterdir()) == [] + + +def test_file_vault_get_registers_the_code(tmp_path): + p = tmp_path / "credentials.json" + FileVault(p).put(SERIAL, CODE) + conf.forget_secrets() + assert CODE not in conf.secret_values() + assert FileVault(p).get(SERIAL) == CODE + assert CODE in conf.secret_values() and conf.scrub_text(f"code {CODE}") == "code " + + +def test_file_vault_default_path_follows_the_patched_constant(): + v = FileVault() + v.put(SERIAL, CODE) + assert conf.CREDENTIALS.is_file() and _mode(conf.CREDENTIALS) == 0o600 and _mode(conf.CREDENTIALS.parent) == 0o700 + assert v.get(SERIAL) == CODE + + +def test_file_vault_write_failure_is_a_vault_error_without_the_code(tmp_path, monkeypatch): + def boom(*a): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(conf.os, "replace", boom) + with pytest.raises(VaultError, match="No space left") as e: + FileVault(tmp_path / "credentials.json").put(SERIAL, CODE) + assert CODE not in str(e.value) + assert [x.name for x in tmp_path.iterdir()] == ["credentials.json.lock"] + + +def test_two_concurrent_puts_both_land(tmp_path, monkeypatch): + """Thread A holds the lock inside its load; thread B's put waits for it and then sees A's code.""" + p = tmp_path / "credentials.json" + inside, release = threading.Event(), threading.Event() + real = FileVault._load + + def slow(self, path): + if threading.current_thread().name == "a": + inside.set() + release.wait(5) + return real(self, path) + + monkeypatch.setattr(FileVault, "_load", slow) + a = threading.Thread(target=FileVault(p).put, args=(SERIAL, CODE), name="a") + a.start() + assert inside.wait(5) + b = threading.Thread(target=FileVault(p).put, args=(SERIAL2, "87654321"), name="b") + b.start() + b.join(0.3) + assert b.is_alive() # B waits on the lock + release.set() + a.join(5) + b.join(5) + assert json.loads(p.read_text())["codes"] == {SERIAL: CODE, SERIAL2: "87654321"} + + +# ---------------------------------------------------------------------------- the runtime secret set in conf.py +def test_remember_secret_ignores_empty_and_keeps_every_length(): + conf.remember_secret("") + conf.remember_secret("abc") # a runtime secret of any length is scrubbed + conf.remember_secret("abcd") + conf.remember_secret(CODE) + assert conf.sensitive_values() == [CODE, "abcd", "abc"] == conf.secret_values() + assert conf.scrub_text("x abc y") == "x y" + + +def test_sensitive_values_hold_runtime_secrets_with_no_studio_config(): + assert not conf.CONF.exists() # the offline fixture + assert conf.sensitive_values() == [] == conf.secret_values() + conf.remember_secret(CODE) + assert conf.sensitive_values() == [CODE] == conf.secret_values() + + +def test_secret_values_hold_codes_only(tmp_path, monkeypatch): + p = tmp_path / "BambuStudio.conf" + p.write_text(json.dumps({"app": {"user_last_selected_machine": SERIAL}, "access_code": {SERIAL: "12345678", SERIAL2: "87654321"}})) + monkeypatch.setattr(conf, "CONF", p) + conf.remember_secret(CODE) + secrets = conf.secret_values() + assert secrets[0] == CODE and sorted(secrets[1:]) == ["12345678", "87654321"] + assert SERIAL not in secrets and conf.mask(SERIAL) not in secrets + sensitive = conf.sensitive_values() + assert {CODE, "12345678", SERIAL, SERIAL2, conf.mask(SERIAL)} <= set(sensitive) + assert [len(v) for v in sensitive] == sorted((len(v) for v in sensitive), reverse=True) + + +# ---------------------------------------------------------------------------- the shared write helper +def test_atomic_write_writes_the_document(tmp_path): + p = tmp_path / "shambu" / "printers.json" + conf._atomic_write(p, {"version": 1, "default": None, "printers": []}, ".printers-") + assert json.loads(p.read_text()) == {"version": 1, "default": None, "printers": []} + assert [x.name for x in p.parent.iterdir()] == ["printers.json"] + + +def test_atomic_write_leaves_no_temp_file_when_replace_fails(tmp_path, monkeypatch): + def boom(*a): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(conf.os, "replace", boom) + with pytest.raises(conf.DataFileError, match="No space left"): + conf._atomic_write(tmp_path / "printers.json", {"version": 1}, ".printers-") + assert [x.name for x in tmp_path.iterdir()] == [] + + +def test_locked_returns_the_result_and_leaves_the_lock_file(tmp_path): + p = tmp_path / "printers.json" + assert conf._locked(p, lambda: 42) == 42 + assert [x.name for x in tmp_path.iterdir()] == ["printers.json.lock"] + + +# ---------------------------------------------------------------------------- symlinks, FIFOs and directories +def _seeded(path): + path.write_text(json.dumps({"version": 1, "codes": {SERIAL: CODE}})) + path.chmod(0o600) + return path + + +def test_a_symlinked_credentials_file_is_refused_on_get_and_put(tmp_path): + target = _seeded(tmp_path / "elsewhere.json") + before = target.read_text() + link = tmp_path / "config" / "credentials.json" + link.parent.mkdir() + link.symlink_to(target) + v = FileVault(link) + want = vault.NOT_PRIVATE.format(path=link) + with pytest.raises(VaultError) as e: + v.get(SERIAL) + assert str(e.value) == want and CODE not in str(e.value) + with pytest.raises(VaultError) as e: + v.put(SERIAL2, "OTHER-CODE-1234") + assert str(e.value) == want + assert link.is_symlink() and link.readlink() == target and target.read_text() == before # both untouched + assert sorted(x.name for x in link.parent.iterdir()) == ["credentials.json", "credentials.json.lock"] + + +def test_a_fifo_at_the_credentials_path_is_refused_without_blocking(tmp_path): + fifo = tmp_path / "credentials.json" + os.mkfifo(fifo, 0o600) + v = FileVault(fifo) + done = threading.Event() + box: dict = {} + + def run(): + try: + v.get(SERIAL) + except VaultError as e: + box["error"] = str(e) + finally: + done.set() + + threading.Thread(target=run, daemon=True).start() + assert done.wait(2.0), "a FIFO at the path blocked the read" + assert box["error"] == vault.NOT_PRIVATE.format(path=fifo) + with pytest.raises(VaultError, match="not a regular file"): + v.put(SERIAL, CODE) + assert stat.S_ISFIFO(os.lstat(fifo).st_mode) + + +def test_a_directory_at_the_credentials_path_is_refused(tmp_path): + d = tmp_path / "credentials.json" + d.mkdir(mode=0o700) + with pytest.raises(VaultError) as e: + FileVault(d).get(SERIAL) + assert str(e.value) == vault.NOT_PRIVATE.format(path=d) + with pytest.raises(VaultError, match="not a regular file"): + FileVault(d).put(SERIAL, CODE) + assert d.is_dir() + + +def test_a_file_another_user_owns_is_refused(tmp_path, monkeypatch): + p = _seeded(tmp_path / "credentials.json") + monkeypatch.setattr(vault.os, "getuid", lambda: os.stat(p).st_uid + 1) + with pytest.raises(VaultError) as e: + FileVault(p).get(SERIAL) + assert str(e.value) == vault.NOT_PRIVATE.format(path=p) + + +def test_atomic_write_never_replaces_a_symlink(tmp_path): + target = tmp_path / "target.json" + target.write_text("{}") + link = tmp_path / "printers.json" + link.symlink_to(target) + with pytest.raises(conf.DataFileError) as e: + conf._atomic_write(link, {"version": 1}, ".printers-") + assert str(e.value) == f"{link}: not a regular file; remove it and retry" + assert link.is_symlink() and target.read_text() == "{}" + assert sorted(x.name for x in tmp_path.iterdir()) == ["printers.json", "target.json"] + + +# ---------------------------------------------------------------------------- descriptor, fsync and lock order +def _open_fds() -> int: + return len(os.listdir("/dev/fd")) + + +def test_atomic_write_closes_its_descriptor_when_fchmod_fails(tmp_path, monkeypatch): + def boom(*a): + raise OSError(1, "Operation not permitted") + + conf._atomic_write(tmp_path / "warm.json", {"version": 1}, ".warm-") # first-use imports and caches out of the count + before = _open_fds() + monkeypatch.setattr(conf.os, "fchmod", boom) + with pytest.raises(conf.DataFileError, match="Operation not permitted"): + conf._atomic_write(tmp_path / "printers.json", {"version": 1}, ".printers-") + assert _open_fds() == before + assert sorted(x.name for x in tmp_path.iterdir()) == ["warm.json"] + + +def test_atomic_write_flushes_and_fsyncs_before_the_replace(tmp_path, monkeypatch): + order: list[str] = [] + real_fsync, real_replace = os.fsync, os.replace + monkeypatch.setattr(conf.os, "fsync", lambda fd: (order.append("fsync"), real_fsync(fd))[1]) + monkeypatch.setattr(conf.os, "replace", lambda a, b: (order.append("replace"), real_replace(a, b))[1]) + conf._atomic_write(tmp_path / "printers.json", {"version": 1}, ".printers-") + assert order == ["fsync", "replace"] + assert json.loads((tmp_path / "printers.json").read_text()) == {"version": 1} + + +def test_a_put_inside_locked_profiles_and_a_lone_put_both_land(tmp_path): + """The lock order a login commit uses: the profiles lock, then the credentials lock inside it. A second thread's + lone put (the credentials lock only) waits its turn and lands too; nothing deadlocks.""" + creds = tmp_path / "credentials.json" + printers = tmp_path / "printers.json" + v = FileVault(creds) + errors: list[VaultError] = [] + + def lone(): + try: + FileVault(creds).put(SERIAL2, "OTHER-CODE-1234") + except VaultError as e: + errors.append(e) + + other = threading.Thread(target=lone) + + def commit(ps): + other.start() + v.put(SERIAL, CODE) + return "done" + + assert conf.locked_profiles(commit, printers) == "done" + other.join(5.0) + assert not other.is_alive() and errors == [] + assert FileVault(creds).get(SERIAL) == CODE and FileVault(creds).get(SERIAL2) == "OTHER-CODE-1234" + + +# ---------------------------------------------------------------------------- every code is registered +def test_a_file_vault_read_registers_every_code_in_the_file(tmp_path): + p = tmp_path / "credentials.json" + p.write_text(json.dumps({"version": 1, "codes": {SERIAL: CODE, SERIAL2: "OTHER-CODE-1234"}})) + p.chmod(0o600) + assert FileVault(p).get(SERIAL) == CODE + assert {CODE, "OTHER-CODE-1234"} <= set(conf.secret_values()) + + +def test_a_put_registers_its_code_before_any_file_operation(tmp_path, monkeypatch): + def no_lock(p, fn): + assert CODE in conf.secret_values(), "the code is registered before the lock file is touched" + raise conf.DataFileError("stop here") + + monkeypatch.setattr(conf, "_locked", no_lock) + with pytest.raises(VaultError, match="stop here"): + FileVault(tmp_path / "credentials.json").put(SERIAL, CODE) + assert list(tmp_path.iterdir()) == [] + + +def test_a_memory_vault_get_registers_every_stored_code(): + MemoryVault(items={SERIAL: CODE, SERIAL2: "OTHER-CODE-1234"}).get(SERIAL) + assert {CODE, "OTHER-CODE-1234"} <= set(conf.secret_values()) + + +def test_remember_secret_ignores_a_value_that_is_not_text(): + conf.remember_secret(12345678) # type: ignore[arg-type] # a numeric Studio value + conf.remember_secret(None) # type: ignore[arg-type] + assert conf.secret_values() == [] + + +def test_the_vault_read_goes_through_the_shared_private_open(tmp_path, monkeypatch): + p = _seeded(tmp_path / "credentials.json") + seen = [] + real = conf.open_private + + def spy(path, refusal=None): + seen.append((path, refusal)) + return real(path, refusal) + + monkeypatch.setattr(conf, "open_private", spy) + assert FileVault(p).get(SERIAL) == CODE + assert seen == [(p, vault.NOT_PRIVATE.format(path=p))] + + def refuse(path, refusal=None): + raise conf.DataFileError(refusal) + + monkeypatch.setattr(conf, "open_private", refuse) + with pytest.raises(VaultError) as e: # conf's DataFileError reaches the caller as a VaultError, same text + FileVault(p).get(SERIAL) + assert str(e.value) == vault.NOT_PRIVATE.format(path=p)