diff --git a/.gitignore b/.gitignore index 06f8d9e..97a074f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ state/ # NAS emulator (tests/docker/) — keep the layout, ignore generated state tests/docker/nas-data/* !tests/docker/nas-data/.gitkeep +# rsync-over-ssh keypair generated by entrypoint on first boot (never commit) +tests/docker/keys/* +!tests/docker/keys/.gitkeep # Local secrets / env (never commit) .env diff --git a/README.md b/README.md index 8eea354..c018d06 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,10 @@ container weekly and on every merge to `main`. ## NAS sync setup ExLab-Wizard syncs runs to your NAS via [rclone](https://rclone.org/) named -remotes. The app never stores NAS passwords — you configure the remote once +remotes (default) or via `rsync` over ssh for cluster nodes where IT blocks +SMB/SFTP. The app never stores NAS passwords or ssh key passphrases. + +**rclone transport (default — lab acquisition PCs):** configure the remote once with `rclone config` and wire the remote name into `config.yaml`: ```yaml @@ -136,6 +139,10 @@ nas: Step-by-step instructions (SFTP and SMB walkthroughs, performance tuning, tray-service caveats): **[`docs/setup/rclone-remote-setup.md`](docs/setup/rclone-remote-setup.md)**. +**rsync-over-ssh transport (cluster nodes where IT blocks rclone):** set +`nas.transport: rsync_ssh` and `nas.remote: user@host`. Keypair provisioning +and `known_hosts` pre-loading walkthrough: **[`docs/setup/rsync-ssh-setup.md`](docs/setup/rsync-ssh-setup.md)**. + Settings → **NAS Remote** → **Test connection** verifies the remote is reachable before you start syncing. diff --git a/docs/design_specs/design_spec_sections/04_Backend_Architecture.md b/docs/design_specs/design_spec_sections/04_Backend_Architecture.md index 413cab4..cc4f6f4 100644 --- a/docs/design_specs/design_spec_sections/04_Backend_Architecture.md +++ b/docs/design_specs/design_spec_sections/04_Backend_Architecture.md @@ -130,6 +130,10 @@ exlab_wizard/ generator.py # merge field layers, render YAML+Markdown, write README.md + readme_fields.json sync/ nas_client.py # NASSync interface (see §7.1) + transports/ + __init__.py # NasTransportDriver Protocol (push/check/lsjson_manifest/about) + build_nas_driver factory + rclone.py # RcloneDriver — default transport; thin rclone subprocess wrapper + rsync_ssh.py # RsyncSshDriver — rsync-over-ssh transport for cluster nodes (2026-06-10) lims/ client.py # LIMSClient (read-only in v1; see §7.2). Uses httpx for REST + cookie session. schemas.py # LIMSProject, LIMSUser as msgspec.Struct types @@ -178,6 +182,8 @@ tests/ The `ui/` package depends on `controller/` and the API schema modules but never the reverse: no backend module imports from `ui/`. This is the testability boundary — backend can be exercised without a browser. +**NAS transport abstraction (2026-06-10).** `sync/transports/__init__.py` defines `NasTransportDriver`, a structural Protocol with four async methods (`push`, `check`, `lsjson_manifest`, `about`) consumed by `NASSyncClient` and the tray probe. `RcloneDriver` and `RsyncSshDriver` both implement the protocol. Every construction site routes through the `build_nas_driver(nas: NasConfig, perf: RclonePerf) -> NasTransportDriver` factory; the factory branches on `nas.transport`. Stage-mode equipment always gets a `RcloneDriver` regardless of `nas.transport` because its targets are rclone named-remote strings, not `user@host` ssh targets (see §7.1 and `CLAUDE.md`). + ### 4.3.1 The `constants/` package A small set of values appears in many specifications and must stay synchronized across the codebase: schema version numbers, file names of cache files, regex patterns, keyring service identifier, enum string values. The `constants/` package is the single source of truth for these. Rules: diff --git a/docs/design_specs/design_spec_sections/07_Sync_and_Database_Integration.md b/docs/design_specs/design_spec_sections/07_Sync_and_Database_Integration.md index 03578e2..06fa3e8 100644 --- a/docs/design_specs/design_spec_sections/07_Sync_and_Database_Integration.md +++ b/docs/design_specs/design_spec_sections/07_Sync_and_Database_Integration.md @@ -60,9 +60,30 @@ QUEUED → RUNNING → AWAITING_VERIFY → VERIFIED → CLEANUP_ELIGIBLE → CLE Job rows persist across restarts. On startup, NASSync requeues any `RUNNING` or `AWAITING_VERIFY` jobs (treating them as `QUEUED` and `VERIFIED → AWAITING_VERIFY` respectively, since transport may have completed but verification didn't run). -### 7.1.3 Transport driver (`RcloneDriver`) - -The sole transport for v1 is `RcloneDriver` (`exlab_wizard.sync.transports.rclone`), +### 7.1.3 Transport drivers + +The transport used by each instance is selected by `nas.transport` in +`config.yaml` (default: `rclone`). The factory `build_nas_driver(nas, perf)` +in `exlab_wizard.sync.transports` returns the appropriate driver. + +| Transport | Selected by | Binary | Auth | Verify authority | +|-----------|------------|--------|------|-----------------| +| `rclone` (default) | `nas.transport: rclone` (or absent) | `rclone` | Named remote in `rclone.conf` | `rclone check --download` — pulls file bytes to the client and hashes locally | +| `rsync_ssh` | `nas.transport: rsync_ssh` | `rsync` | ssh key (`BatchMode=yes`) | `rsync -rni --checksum` dry-run — computes checksums inside the rsync protocol on the remote side | + +**Verify authority differs by transport.** `RcloneDriver.check` downloads the +remote bytes to the client to hash them locally, meaning the hash computation +runs on the client and the network carries the full file payload. `RsyncSshDriver.check` +invokes a dry-run with `--checksum` inside the rsync protocol, delegating the +hash computation to the remote rsync process; only the itemize-change output +(a handful of flag bytes per file) crosses the wire. Both signal +"content differs" correctly, but in rsync mode the trust is placed on the +remote-side rsync binary rather than a local hash. This is an accepted +trust-posture change (2026-06-10 spec-review). + +#### `RcloneDriver` + +`RcloneDriver` (`exlab_wizard.sync.transports.rclone`) is the default transport, a thin wrapper around the `rclone` binary. The driver exposes four operations: | Method | Command | Purpose | diff --git a/docs/design_specs/design_spec_sections/09_Configuration_File.md b/docs/design_specs/design_spec_sections/09_Configuration_File.md index f051d7b..6e43974 100644 --- a/docs/design_specs/design_spec_sections/09_Configuration_File.md +++ b/docs/design_specs/design_spec_sections/09_Configuration_File.md @@ -75,13 +75,22 @@ equipment: sync_mode: "nas" # Top-level NAS sync configuration. A single named rclone remote covers all -# nas-mode equipment. Credentials live entirely in rclone.conf. +# nas-mode equipment. Credentials live entirely in rclone.conf (rclone mode) +# or in the operator's ~/.ssh/ (rsync_ssh mode). nas: - remote: "lab-nas" # remote name from rclone.conf (set up with rclone config) + remote: "lab-nas" # rclone mode: remote name from rclone.conf (set up with rclone config) + # rsync_ssh mode: "user@host" target prefix base_root: "lab" # path on the remote under which equipment folders live # run target: lab-nas:/lab// - rclone_config_path: "" # optional: pin --config (blank = rclone default discovery) - mtime_tolerance_s: 2 # reconcile tolerance (s); absorbs SFTP/SMB modtime rounding + rclone_config_path: "" # rclone mode only: optional pin --config (blank = default discovery) + mtime_tolerance_s: 2 # reconcile tolerance (s); absorbs SFTP/SMB/rsync modtime rounding + # rsync-over-ssh transport (2026-06-10). Cluster nodes where IT blocks + # rclone/SMB/SFTP can use rsync --server over ssh instead. Lab PCs keep + # the default rclone transport. See docs/setup/rsync-ssh-setup.md. + transport: "rclone" # "rclone" (default) | "rsync_ssh" + ssh_port: 22 # rsync_ssh mode: ssh port on the NAS (default 22) + ssh_identity_file: "" # rsync_ssh mode: path to the ed25519 private key + # (blank = ssh default key discovery; BatchMode=yes always set) perf: transfers: 4 # rclone --transfers; also the RAM dial on constrained machines checkers: 8 # rclone --checkers diff --git a/docs/setup/rclone-remote-setup.md b/docs/setup/rclone-remote-setup.md index f43297e..9b8cf4a 100644 --- a/docs/setup/rclone-remote-setup.md +++ b/docs/setup/rclone-remote-setup.md @@ -1,9 +1,13 @@ # Setting up your rclone remote(s) -ExLab-Wizard uses [rclone](https://rclone.org/) as its NAS transport. Rather than -storing NAS credentials inside the app, you configure a named remote once with -`rclone config` and then tell the app which remote name to use. The app never -sees a password — all connection details live in `rclone.conf`. +ExLab-Wizard uses [rclone](https://rclone.org/) as its **default** NAS transport. +Rather than storing NAS credentials inside the app, you configure a named remote +once with `rclone config` and then tell the app which remote name to use. The app +never sees a password — all connection details live in `rclone.conf`. + +> **Cluster nodes where SMB/SFTP is blocked by IT:** use the `rsync_ssh` +> transport instead. See [`rsync-ssh-setup.md`](rsync-ssh-setup.md) for the +> key provisioning and `config.yaml` walkthrough. --- diff --git a/docs/setup/rsync-ssh-setup.md b/docs/setup/rsync-ssh-setup.md new file mode 100644 index 0000000..736288a --- /dev/null +++ b/docs/setup/rsync-ssh-setup.md @@ -0,0 +1,173 @@ +# Setting up rsync-over-ssh NAS transport + +ExLab-Wizard's `rsync_ssh` transport is for cluster nodes where IT blocks +rclone (SMB/SFTP is disabled on the Synology) but allows rsync-over-ssh. Lab +acquisition PCs keep the default `rclone` transport unchanged; only cluster +instances need this walkthrough. + +> **Also see:** [`rclone-remote-setup.md`](rclone-remote-setup.md) for the +> rclone/SMB setup used on lab acquisition PCs. + +--- + +## How it works + +Instead of a named rclone remote, the `rsync_ssh` transport drives `rsync -e +ssh` directly. `nas.remote` becomes `user@host` (the Synology service account), +and the app composes the full target as `user@host:///`. +All four sync operations (push, manifest listing, content verify, connection +probe) use the rsync protocol channel only — no interactive ssh login, no +SFTP subsystem, no remote command execution. IT allowlists exactly +`rsync --server` over ssh; that is all this transport needs. + +Credentials are ssh keys only (`BatchMode=yes`). No password is ever stored or +prompted. Key exchange happens before authentication, so the host-key can be +pre-provisioned without any login permission. + +--- + +## Prerequisites + +- **rsync** on the cluster node (`rsync --version` should report 3.x). +- **rsync** on the Synology NAS (installed via DSM Package Center or the + Synology rsync service; verify with `rsync --version` output recorded in + your runbook — see the "Recording rsync version" step below). +- **OpenSSH client** (`ssh`, `ssh-keygen`, `ssh-keyscan`) on the cluster node. + +--- + +## Step 1 — Generate a dedicated keypair + +Generate a key specifically for ExLab-Wizard NAS sync. Using a dedicated key +lets you revoke or rotate it without affecting your personal ssh access. + +```bash +ssh-keygen -t ed25519 -f ~/.ssh/id_exlab -N "" +``` + +- `-N ""` sets an empty passphrase so the sync worker can use the key + non-interactively (`BatchMode=yes` requires no passphrase prompt). +- The private key is `~/.ssh/id_exlab`; the public key is `~/.ssh/id_exlab.pub`. + +Restrict permissions: + +```bash +chmod 600 ~/.ssh/id_exlab +``` + +--- + +## Step 2 — Install the public key on the Synology service account + +Copy the public key to the person who manages your Synology NAS (lab IT or +the NAS admin). They need to append the contents of `~/.ssh/id_exlab.pub` to +the `~/.ssh/authorized_keys` file of the NAS service account +(`svc-sync@nas01.lab.example` in the example below). + +If you have temporary password ssh access to the NAS service account: + +```bash +ssh-copy-id -i ~/.ssh/id_exlab.pub -p svc-sync@nas01.lab.example +``` + +Or share the one-line `~/.ssh/id_exlab.pub` content with your NAS admin for +manual installation. + +--- + +## Step 3 — Pre-provision the NAS host key + +`BatchMode=yes` means ssh will fail immediately if the host key is unknown +rather than prompting you interactively. Pre-provision the key before the +first sync run. + +> **No login permission required.** The host-key exchange (Step 3) happens +> at the TCP/cryptographic layer, before authentication. You do not need +> shell access to the NAS to run `ssh-keyscan`. + +```bash +ssh-keyscan -p nas01.lab.example >> ~/.ssh/known_hosts +``` + +Verify the key fingerprint out-of-band against DSM or your NAS admin to +guard against a man-in-the-middle substitution: + +```bash +# Compare this fingerprint against what DSM shows under +# Control Panel → Terminal & SNMP → SSH key fingerprints. +ssh-keygen -lf <(ssh-keyscan -p nas01.lab.example 2>/dev/null) +``` + +--- + +## Step 4 — Record the NAS rsync version + +Record the Synology's rsync version in your runbook. Differences in rsync +protocol versions between client and server can occasionally cause format +quirks in listing output. Run from the cluster node after keys are installed: + +```bash +ssh -p -i ~/.ssh/id_exlab svc-sync@nas01.lab.example rsync --version +# Record the first line (e.g. "rsync version 3.2.3 ...") in your lab runbook. +``` + +--- + +## Step 5 — Add the `rsync_ssh` block to `config.yaml` + +```yaml +nas: + transport: "rsync_ssh" + remote: "svc-sync@nas01.lab.example" # user@host — doubles as the target prefix + base_root: "/volume1/lab" # absolute path on the NAS + ssh_port: 22 # optional, default 22 + ssh_identity_file: "~/.ssh/id_exlab" # optional; blank = ssh default key discovery + mtime_tolerance_s: 2 # modtime tolerance for reconcile (seconds) + bandwidth: + upload_mbps: null # null = unlimited +``` + +The full target path for a run is: + +``` +svc-sync@nas01.lab.example:/volume1/lab// +``` + +--- + +## Step 6 — Test the connection + +Open **Settings → NAS Remote → Test connection** in ExLab-Wizard. + +> **Degraded probe by design:** the rsync transport cannot query free-space +> without remote command execution (which IT blocks). Test-connection reports +> reachable + auth ok or a classified failure — no free-space info is shown. +> This is expected and not a configuration error. + +--- + +## Failure modes (BatchMode=yes) + +When ssh runs in `BatchMode=yes` it fails immediately instead of prompting. +The error is classified by the driver and surfaced in the Settings panel and +the log. + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| `Permission denied (publickey)` | Key not installed on the NAS service account, or wrong identity file | Verify `ssh_identity_file` path; re-run `ssh-copy-id` | +| `Host key verification failed` | Host key not in `~/.ssh/known_hosts` | Re-run `ssh-keyscan` (Step 3); verify fingerprint | +| `Connection refused` / timeout | Network or IT policy blocking the port | Check firewall rules; confirm with IT that the NAS ssh port is reachable from this cluster node | +| `Too many authentication failures` | SSH agent offering too many keys before the correct one | Set `ssh_identity_file` explicitly in config to skip key negotiation | + +--- + +## Notes on `perf` and rclone-only fields + +`nas.perf.transfers` and `nas.perf.checkers` are rclone parallelism dials. +They are **ignored** (not rejected) by the `rsync_ssh` transport — rsync is +single-stream per invocation. Set them to rclone defaults for forward +compatibility; they will be used again if you ever switch the instance back +to rclone. + +`nas.rclone_config_path` is also ignored by `rsync_ssh` — there is no +rclone involved. diff --git a/docs/superpowers/plans/2026-06-10-rsync-ssh-nas-transport.md b/docs/superpowers/plans/2026-06-10-rsync-ssh-nas-transport.md new file mode 100644 index 0000000..7daced4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-rsync-ssh-nas-transport.md @@ -0,0 +1,2081 @@ +# rsync-over-ssh NAS Transport Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an `rsync_ssh` NAS transport (selected per-instance by `nas.transport`) so cluster machines — where IT blocks SMB and rclone-SFTP but allows rsync-over-ssh — can push, reconcile, verify, and probe against the Synology NAS. + +**Architecture:** A new `RsyncSshDriver` mirrors `RcloneDriver`'s thin-subprocess-wrapper shape behind a shared `NasTransportDriver` protocol and a `build_nas_driver` factory. Targets reuse the existing `:` string (`user@host:/path` is valid rsync syntax), so `NASSyncClient`'s state machine, reconcile loop, and cleanup gate are untouched. All ops use rsync protocol primitives only (no remote command exec): push = `rsync -rt`, manifest = `--list-only` parse, verify = `--checksum` dry-run itemize, probe = non-recursive `--list-only`. + +**Spec:** `docs/superpowers/specs/2026-06-10-rsync-ssh-nas-transport-design.md` (all OQs resolved 2026-06-10). + +**Tech Stack:** Python 3.12, Pydantic v2, pytest (`asyncio_mode = "auto"`, so `async def` tests need no marker). Test runner: `uv run --extra test pytest`. Lint: `uvx ruff check`. **Run sync test suites one at a time** (they flake under CPU load). + +**Out of scope (deliberate):** renaming `SetupState.INCOMPLETE_NO_NAS_REMOTE` / `SetupNextAction.CONFIGURE_RCLONE_REMOTE` (kept; only UI copy changes); the `error_kind=None` → HASH_MISMATCH retry-routing debt; `ui/pages/wizard_equipment.py` copy polish; staging-hop rsync support. + +--- + +## File map + +| File | Change | +| --- | --- | +| `src/exlab_wizard/constants/enums.py` | new `SyncTransport` StrEnum | +| `src/exlab_wizard/constants/__init__.py` | export `SyncTransport` | +| `src/exlab_wizard/config/models.py` | `NasConfig.transport/ssh_port/ssh_identity_file` + validator | +| `src/exlab_wizard/sync/manifest.py` | new `parse_rsync_listing()` | +| `src/exlab_wizard/sync/transports/__init__.py` | `NasTransportDriver` protocol, `build_nas_driver` factory | +| `src/exlab_wizard/sync/transports/rclone.py` | new `lsjson_manifest()` shim | +| `src/exlab_wizard/sync/transports/rsync_ssh.py` | **new** `RsyncSshDriver` | +| `src/exlab_wizard/sync/nas_client.py` | factory routing + stage-mode bypass + `lsjson_manifest` | +| `src/exlab_wizard/sync/verifier.py` | protocol typing, drop no-arg default | +| `src/exlab_wizard/tray/dependencies.py` | transport-aware gate hydration + probe | +| `src/exlab_wizard/paths.py` | `_missing_nas_fields` transport branch | +| `src/exlab_wizard/ui/pages/settings.py` | transport-aware NAS-Remote section | +| `src/exlab_wizard/ui/pages/main.py` | transport-neutral banner subline | +| `tests/fixtures/stub_rsync.py` | **new** stub rsync binary | +| `tests/unit/...` | per-task test files (see tasks) | +| `tests/docker/*` | rsync-over-ssh leg + characterization tests | +| `docs/setup/`, `design_specs/design_spec_sections/{04,07,09}_*.md`, `README.md` | docs | + +--- + +### Task 1: `SyncTransport` enum + +**Files:** +- Modify: `src/exlab_wizard/constants/enums.py` (after `SyncMode`, ~line 176) +- Modify: `src/exlab_wizard/constants/__init__.py` (import list ~line 38, `__all__` ~line 264) +- Test: `tests/unit/constants/test_enum_literal_alignment.py`, `tests/unit/constants/test_enums.py` + +- [ ] **Step 1: Write the failing test** + +Add to the `@pytest.mark.parametrize` list in `tests/unit/constants/test_enum_literal_alignment.py` (alongside the `SetupNextAction` entry): + +```python + (enums.SyncTransport, frozenset({"rclone", "rsync_ssh"})), +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run --extra test pytest tests/unit/constants/test_enum_literal_alignment.py -v` +Expected: FAIL with `AttributeError: module 'exlab_wizard.constants.enums' has no attribute 'SyncTransport'` + +- [ ] **Step 3: Implement the enum** + +In `src/exlab_wizard/constants/enums.py`, directly after the `SyncMode` class: + +```python +class SyncTransport(StrEnum): + """Which binary the NAS sync subsystem shells out to. + + rsync-over-ssh NAS transport design (2026-06-10). Selected per + instance by ``nas.transport``: ``rclone`` (default) drives the named + remote in the operator's rclone.conf; ``rsync_ssh`` drives + ``rsync -e ssh`` against ``nas.remote`` as a ``user@host`` target. + Stage-mode equipment always uses rclone regardless of this value. + """ + + RCLONE = "rclone" + RSYNC_SSH = "rsync_ssh" +``` + +In `src/exlab_wizard/constants/__init__.py`: add `SyncTransport,` to the `from exlab_wizard.constants.enums import (...)` block (alphabetical, next to `SyncMode`) and `"SyncTransport",` to `__all__` (next to `"SyncMode"`). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run --extra test pytest tests/unit/constants/ -v` +Expected: PASS (alignment test plus the existing `test_enums.py` suite; if `test_enums.py` asserts a closed member list of the module, add `SyncTransport` there as well — check its failure output). + +- [ ] **Step 5: Commit** + +```bash +git add src/exlab_wizard/constants/ tests/unit/constants/ +git commit -m "feat(constants): add SyncTransport enum for NAS transport selection" +``` + +--- + +### Task 2: `NasConfig` transport fields + validator + +**Files:** +- Modify: `src/exlab_wizard/config/models.py` (`NasConfig`, ~line 273; import block ~line 36) +- Test: `tests/unit/config/test_models.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/config/test_models.py`: + +```python +class TestNasTransport: + def test_transport_defaults_to_rclone(self) -> None: + nas = NasConfig() + assert nas.transport == SyncTransport.RCLONE + assert nas.ssh_port == 22 + assert nas.ssh_identity_file == "" + + def test_rsync_ssh_accepts_user_at_host(self) -> None: + nas = NasConfig( + transport="rsync_ssh", + remote="svc-sync@nas01.lab.example", + base_root="/volume1/lab", + ssh_identity_file="~/.ssh/id_exlab", + ) + assert nas.transport == SyncTransport.RSYNC_SSH + assert nas.remote == "svc-sync@nas01.lab.example" + + def test_rsync_ssh_rejects_empty_remote(self) -> None: + with pytest.raises(ValidationError, match="requires nas.remote"): + NasConfig(transport="rsync_ssh", remote="") + + @pytest.mark.parametrize("bad", ["nas01", "@nas01", "svc-sync@"]) + def test_rsync_ssh_rejects_non_user_at_host(self, bad: str) -> None: + with pytest.raises(ValidationError, match="user@host"): + NasConfig(transport="rsync_ssh", remote=bad) + + def test_rclone_mode_remote_shape_unrestricted(self) -> None: + assert NasConfig(remote="nas01").remote == "nas01" + + def test_transport_serializes_as_string(self) -> None: + dumped = NasConfig( + transport="rsync_ssh", remote="u@h" + ).model_dump() + assert dumped["transport"] == "rsync_ssh" +``` + +Imports needed at top of the test module (add to existing import blocks): `from pydantic import ValidationError` and `from exlab_wizard.constants import SyncTransport` (the module already imports `NasConfig` and `pytest`). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run --extra test pytest tests/unit/config/test_models.py -k TestNasTransport -v` +Expected: FAIL with `ValidationError` ("Extra inputs are not permitted" — `extra="forbid"` rejects the unknown `transport` key). + +- [ ] **Step 3: Implement the fields** + +In `src/exlab_wizard/config/models.py`: add `SyncTransport,` to the existing `from exlab_wizard.constants import (...)` block. Then inside `NasConfig`, after the `rclone_config_path: str = ""` field: + +```python + # rsync-over-ssh NAS transport (2026-06-10 design). ``rsync_ssh`` drives + # ``rsync -e ssh`` with ``remote`` as a ``user@host`` target prefix; + # ``rclone_config_path`` and ``perf`` are rclone-only and ignored in + # rsync mode. Stage-mode equipment always uses rclone regardless. + transport: SyncTransport = SyncTransport.RCLONE + ssh_port: int = Field(default=22, ge=1, le=65535) + ssh_identity_file: str = "" +``` + +And after the existing fields, the serializer + validator (mirroring `EquipmentConfig._serialize_sync_mode`): + +```python + @field_serializer("transport") + def _serialize_transport(self, value: SyncTransport) -> str: + return value.value + + @model_validator(mode="after") + def _validate_rsync_ssh(self) -> NasConfig: + if self.transport != SyncTransport.RSYNC_SSH: + return self + if not self.remote: + msg = "nas.transport 'rsync_ssh' requires nas.remote ('user@host')" + raise ValueError(msg) + user, sep, host = self.remote.partition("@") + if not (sep and user and host): + msg = ( + f"nas.remote {self.remote!r} must be 'user@host' when " + "nas.transport is 'rsync_ssh'" + ) + raise ValueError(msg) + return self +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run --extra test pytest tests/unit/config/test_models.py -v` +Expected: PASS (whole module — confirms no regression in existing `NasConfig` tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/exlab_wizard/config/models.py tests/unit/config/test_models.py +git commit -m "feat(config): nas.transport selector + ssh fields on NasConfig" +``` + +--- + +### Task 3: `parse_rsync_listing` manifest parser + +**Files:** +- Modify: `src/exlab_wizard/sync/manifest.py` +- Test: `tests/unit/sync/test_manifest.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/unit/sync/test_manifest.py`: + +```python +from datetime import datetime + +from exlab_wizard.sync.manifest import parse_rsync_listing + + +def _stamp(epoch: float) -> str: + """Format ``epoch`` the way the rsync client formats listing mtimes + (local timezone, 1 s resolution).""" + return datetime.fromtimestamp(epoch).strftime("%Y/%m/%d %H:%M:%S") + + +class TestParseRsyncListing: + def test_plain_file(self) -> None: + raw = f"-rw-r--r-- 2048 {_stamp(1_750_000_000)} data/reading_001.csv\n" + manifest = parse_rsync_listing(raw) + assert manifest.has("data/reading_001.csv") + assert manifest.entries["data/reading_001.csv"].size == 2048 + + def test_filename_with_spaces_survives(self) -> None: + raw = f"-rw-r--r-- 1234 {_stamp(1_750_000_000)} image data 001.tif\n" + manifest = parse_rsync_listing(raw) + assert manifest.has("image data 001.tif") + + def test_comma_grouped_size(self) -> None: + raw = f"-rw-r--r-- 1,234,567 {_stamp(1_750_000_000)} big.bin\n" + assert parse_rsync_listing(raw).entries["big.bin"].size == 1234567 + + def test_mtime_roundtrips_through_local_timezone(self) -> None: + epoch = 1_750_000_000.0 + raw = f"-rw-r--r-- 100 {_stamp(epoch)} f.txt\n" + manifest = parse_rsync_listing(raw) + assert manifest.matches("f.txt", 100, epoch, tolerance_s=2) + assert not manifest.matches("f.txt", 100, epoch + 3600, tolerance_s=2) + + def test_directories_and_dot_dropped(self) -> None: + stamp = _stamp(1_750_000_000) + raw = ( + f"drwxr-xr-x 4096 {stamp} .\n" + f"drwxr-xr-x 4096 {stamp} data\n" + f"-rw-r--r-- 10 {stamp} data/x.csv\n" + ) + manifest = parse_rsync_listing(raw) + assert set(manifest.entries) == {"data/x.csv"} + + def test_octal_escapes_unescaped(self) -> None: + raw = f"-rw-r--r-- 10 {_stamp(1_750_000_000)} weird\\#012name.txt\n" + assert parse_rsync_listing(raw).has("weird\nname.txt") + + def test_garbage_lines_ignored(self) -> None: + assert parse_rsync_listing("sending incremental file list\n\n") .entries == {} + + def test_empty_input(self) -> None: + assert parse_rsync_listing("").entries == {} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run --extra test pytest tests/unit/sync/test_manifest.py -k TestParseRsyncListing -v` +Expected: FAIL with `ImportError: cannot import name 'parse_rsync_listing'` + +- [ ] **Step 3: Implement the parser** + +In `src/exlab_wizard/sync/manifest.py`: extend the imports (`from datetime import UTC, datetime`), add `"parse_rsync_listing"` to `__all__`, and append: + +```python +# ``rsync --list-only`` line: perms, size (possibly digit-grouped), the +# fixed-format timestamp, then EVERYTHING after the single separating +# space is the path — filenames containing spaces appear literally +# (spec-review blocker, 2026-06-10), so the line must be anchored on the +# timestamp, never whitespace-split. +_RSYNC_LIST_RE = re.compile( + r"^(?P\S+)\s+(?P[\d,.]+)\s+" + r"(?P\d{4}/\d{2}/\d{2})\s+(?P