Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,10 +248,12 @@ same `Transport` interface, selected with `--transport {pyez,rustez}`:
lab-verified on. Validated against PyEZ by a canonical-XML parity test and
the parametrized lab suite.

**Locking differs between backends.** PyEZ takes an exclusive config lock at
`connect()`, so `check` and the source fetch lock each device while reading.
rustez only locks at the first `load()`, so `check` on rustez never locks.
Tracked in [#8](https://github.com/mechubsec/srxsync/issues/8).
**Locking is aligned across backends.** Both PyEZ and rustez take an
exclusive config lock at `connect()` and hold it until `close()`, so `check`
and the source fetch lock each device while reading, on either backend. This
closes the window rustez previously left open between `connect()` and the
first `load()`, where another session could mutate the candidate
config underneath an in-flight push. See [#8](https://github.com/mechubsec/srxsync/issues/8).

## License

Expand Down
13 changes: 4 additions & 9 deletions srxsync/transport/rustez.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ def connect(
ssh_private_key_file=ssh_key,
)
self._dev.open(gather_facts=False)
self._cfg = Config(self._dev)
self._cfg.lock()
self._locked = True
except _RUSTEZ_ERRORS as exc:
raise TransportError(f"connect failed for {host}: {_reason(exc)}") from exc

Expand Down Expand Up @@ -95,16 +98,8 @@ def fetch(self, paths: list[str]) -> etree._Element:
# ------------------------------------------------------------------

def load(self, xml: etree._Element, mode: Literal["replace", "merge"]) -> None:
if self._dev is None:
if self._dev is None or self._cfg is None:
raise TransportError("not connected")
if self._cfg is None:
self._cfg = Config(self._dev)
if not self._locked:
try:
self._cfg.lock()
except _RUSTEZ_ERRORS as exc:
raise TransportError(f"lock failed on {self._host}: {_reason(exc)}") from exc
self._locked = True
try:
self._cfg.load(etree.tostring(xml).decode(), format="xml", action=mode)
except _RUSTEZ_ERRORS as exc:
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/test_transport_pyez.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,28 @@ def test_reason_never_echoes_device_rpc_error_body():
assert reason == "RpcError"


def test_connect_locks_before_any_read(monkeypatch):
"""The candidate lock must be held for the whole session, including a
read-only `check` (connect + fetch, no load). If connect() returned
before locking, a concurrent session could mutate the candidate between
our connect() and our first read, and we'd never know."""
calls: list[str] = []

fake_device = MagicMock()
fake_device.open.side_effect = lambda: calls.append("open")
monkeypatch.setattr("srxsync.transport.pyez.Device", lambda **kw: fake_device)

fake_config = MagicMock()
fake_config.lock.side_effect = lambda: calls.append("lock")
monkeypatch.setattr("srxsync.transport.pyez.Config", lambda dev, mode: fake_config)

t = PyEZTransport()
t.connect("198.51.100.1", "svc", password="FAKEPASS")

assert calls == ["open", "lock"]
fake_config.lock.assert_called_once()


def test_connect_never_echoes_device_lock_error_body(monkeypatch):
"""A LockError raised by Config.lock() during connect() is an RpcError,
not a ConnectError. Before this fix, connect() only wrapped ConnectError,
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/test_transport_rustez.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,48 @@ def test_unlisted_subclass_during_commit_is_wrapped(rustez_module: types.ModuleT
assert SECRET_TEXT not in str(info.value)


def test_connect_locks_immediately_like_pyez(
rustez_module: types.ModuleType, monkeypatch: pytest.MonkeyPatch
) -> None:
"""rustez used to defer the candidate lock to the first load(), leaving
a window after connect() (e.g. during a read-only `check`) where another
session could mutate the candidate underneath us. The lock must now be
taken in connect(), same as PyEZTransport, so `check` and the push path
lock at the same point on both backends."""
calls: list[str] = []

fake_dev = types.SimpleNamespace(open=lambda **kw: calls.append("open"))
monkeypatch.setattr(rustez_module, "Device", lambda **kw: fake_dev)

fake_cfg = types.SimpleNamespace(lock=lambda: calls.append("lock"))
monkeypatch.setattr(rustez_module, "Config", lambda dev: fake_cfg)

tx = rustez_module.RustezTransport()
tx.connect("192.0.2.1", "u", "p")

assert calls == ["open", "lock"]
assert tx._locked is True
assert tx._cfg is fake_cfg


def test_load_does_not_relock(rustez_module: types.ModuleType) -> None:
"""load() must use the lock already taken in connect() rather than
locking again — locking is not idempotent on the wire, so a second
lock() call from an already-locked session would fail against a real
device."""
tx = rustez_module.RustezTransport()
calls: list[str] = []
tx._dev = types.SimpleNamespace()
tx._cfg = types.SimpleNamespace(
lock=lambda: calls.append("lock"), load=lambda *a, **kw: calls.append("load")
)
tx._locked = True

tx.load(etree.Element("configuration"), "merge")

assert calls == ["load"]


def test_load_and_fetch_errors_are_sanitized(rustez_module: types.ModuleType) -> None:
tx = rustez_module.RustezTransport()
tx._dev = types.SimpleNamespace(
Expand Down
Loading