messagefoundry 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. messagefoundry/__init__.py +108 -0
  2. messagefoundry/__main__.py +1155 -0
  3. messagefoundry/api/__init__.py +27 -0
  4. messagefoundry/api/app.py +1581 -0
  5. messagefoundry/api/approvals.py +184 -0
  6. messagefoundry/api/auth_models.py +211 -0
  7. messagefoundry/api/auth_routes.py +655 -0
  8. messagefoundry/api/field_authz.py +96 -0
  9. messagefoundry/api/models.py +374 -0
  10. messagefoundry/api/security.py +247 -0
  11. messagefoundry/api/tls.py +47 -0
  12. messagefoundry/auth/__init__.py +39 -0
  13. messagefoundry/auth/data/common_passwords.NOTICE +13 -0
  14. messagefoundry/auth/data/common_passwords.txt +10000 -0
  15. messagefoundry/auth/identity.py +71 -0
  16. messagefoundry/auth/ldap.py +264 -0
  17. messagefoundry/auth/notifications.py +68 -0
  18. messagefoundry/auth/passwords.py +53 -0
  19. messagefoundry/auth/permissions.py +120 -0
  20. messagefoundry/auth/policy.py +153 -0
  21. messagefoundry/auth/ratelimit.py +55 -0
  22. messagefoundry/auth/service.py +1323 -0
  23. messagefoundry/auth/tokens.py +26 -0
  24. messagefoundry/auth/totp.py +174 -0
  25. messagefoundry/checks.py +174 -0
  26. messagefoundry/config/__init__.py +30 -0
  27. messagefoundry/config/active_environment.py +80 -0
  28. messagefoundry/config/ai_policy.py +140 -0
  29. messagefoundry/config/code_sets.py +260 -0
  30. messagefoundry/config/connections_edit.py +200 -0
  31. messagefoundry/config/connections_file.py +287 -0
  32. messagefoundry/config/db_lookup.py +117 -0
  33. messagefoundry/config/environments.py +116 -0
  34. messagefoundry/config/ingest_time.py +83 -0
  35. messagefoundry/config/models.py +240 -0
  36. messagefoundry/config/reference.py +158 -0
  37. messagefoundry/config/response.py +83 -0
  38. messagefoundry/config/run_context.py +153 -0
  39. messagefoundry/config/settings.py +1311 -0
  40. messagefoundry/config/state.py +99 -0
  41. messagefoundry/config/tls_policy.py +110 -0
  42. messagefoundry/config/wiring.py +1918 -0
  43. messagefoundry/console/__init__.py +20 -0
  44. messagefoundry/console/__main__.py +274 -0
  45. messagefoundry/console/_async.py +107 -0
  46. messagefoundry/console/change_password.py +111 -0
  47. messagefoundry/console/client.py +552 -0
  48. messagefoundry/console/connections.py +324 -0
  49. messagefoundry/console/login.py +107 -0
  50. messagefoundry/console/mfa.py +205 -0
  51. messagefoundry/console/reauth.py +94 -0
  52. messagefoundry/console/search.py +57 -0
  53. messagefoundry/console/service_control.py +137 -0
  54. messagefoundry/console/sessions.py +122 -0
  55. messagefoundry/console/shell.py +410 -0
  56. messagefoundry/console/status.py +377 -0
  57. messagefoundry/console/users_page.py +282 -0
  58. messagefoundry/console/widgets.py +553 -0
  59. messagefoundry/generators/README.md +27 -0
  60. messagefoundry/generators/__init__.py +15 -0
  61. messagefoundry/generators/_core.py +589 -0
  62. messagefoundry/generators/_hl7data.py +428 -0
  63. messagefoundry/generators/adt.py +286 -0
  64. messagefoundry/generators/all_types.py +24 -0
  65. messagefoundry/generators/bar.py +28 -0
  66. messagefoundry/generators/dft.py +20 -0
  67. messagefoundry/generators/mdm.py +39 -0
  68. messagefoundry/generators/mfn.py +46 -0
  69. messagefoundry/generators/oml.py +32 -0
  70. messagefoundry/generators/orl.py +30 -0
  71. messagefoundry/generators/orm.py +23 -0
  72. messagefoundry/generators/oru.py +21 -0
  73. messagefoundry/generators/ras.py +20 -0
  74. messagefoundry/generators/rde.py +54 -0
  75. messagefoundry/generators/siu.py +64 -0
  76. messagefoundry/generators/vxu.py +20 -0
  77. messagefoundry/hl7schema.py +75 -0
  78. messagefoundry/last_resort.py +55 -0
  79. messagefoundry/logging_setup.py +332 -0
  80. messagefoundry/parsing/__init__.py +64 -0
  81. messagefoundry/parsing/consistency.py +166 -0
  82. messagefoundry/parsing/groups.py +228 -0
  83. messagefoundry/parsing/message.py +453 -0
  84. messagefoundry/parsing/peek.py +237 -0
  85. messagefoundry/parsing/split.py +120 -0
  86. messagefoundry/parsing/summary.py +46 -0
  87. messagefoundry/parsing/tree.py +128 -0
  88. messagefoundry/parsing/validate.py +95 -0
  89. messagefoundry/parsing/x12/__init__.py +46 -0
  90. messagefoundry/parsing/x12/delimiters.py +140 -0
  91. messagefoundry/parsing/x12/errors.py +30 -0
  92. messagefoundry/parsing/x12/interchange.py +232 -0
  93. messagefoundry/parsing/x12/message.py +200 -0
  94. messagefoundry/parsing/x12/peek.py +207 -0
  95. messagefoundry/pipeline/__init__.py +21 -0
  96. messagefoundry/pipeline/alert_sinks.py +486 -0
  97. messagefoundry/pipeline/alerts.py +100 -0
  98. messagefoundry/pipeline/cert_expiry.py +219 -0
  99. messagefoundry/pipeline/cluster.py +955 -0
  100. messagefoundry/pipeline/cluster_sqlserver.py +444 -0
  101. messagefoundry/pipeline/config_convergence.py +137 -0
  102. messagefoundry/pipeline/dryrun.py +450 -0
  103. messagefoundry/pipeline/engine.py +756 -0
  104. messagefoundry/pipeline/leader_tasks.py +158 -0
  105. messagefoundry/pipeline/reference_sync.py +369 -0
  106. messagefoundry/pipeline/retention.py +289 -0
  107. messagefoundry/pipeline/security_notify.py +168 -0
  108. messagefoundry/pipeline/state_convergence.py +143 -0
  109. messagefoundry/pipeline/wiring_runner.py +1722 -0
  110. messagefoundry/py.typed +0 -0
  111. messagefoundry/redaction.py +71 -0
  112. messagefoundry/scaffold.py +321 -0
  113. messagefoundry/secrets_dpapi.py +129 -0
  114. messagefoundry/store/__init__.py +46 -0
  115. messagefoundry/store/audit_tee.py +67 -0
  116. messagefoundry/store/base.py +758 -0
  117. messagefoundry/store/crypto.py +166 -0
  118. messagefoundry/store/keyprovider.py +192 -0
  119. messagefoundry/store/postgres.py +3447 -0
  120. messagefoundry/store/sqlserver.py +3014 -0
  121. messagefoundry/store/store.py +3790 -0
  122. messagefoundry/timezone.py +207 -0
  123. messagefoundry/transports/__init__.py +50 -0
  124. messagefoundry/transports/base.py +269 -0
  125. messagefoundry/transports/database.py +693 -0
  126. messagefoundry/transports/file.py +551 -0
  127. messagefoundry/transports/framing.py +164 -0
  128. messagefoundry/transports/loopback.py +53 -0
  129. messagefoundry/transports/mllp.py +644 -0
  130. messagefoundry/transports/remotefile.py +664 -0
  131. messagefoundry/transports/rest.py +281 -0
  132. messagefoundry/transports/signing.py +321 -0
  133. messagefoundry/transports/soap.py +507 -0
  134. messagefoundry/transports/tcp.py +307 -0
  135. messagefoundry/transports/timer.py +146 -0
  136. messagefoundry/transports/x12.py +323 -0
  137. messagefoundry-0.1.0.dist-info/METADATA +212 -0
  138. messagefoundry-0.1.0.dist-info/RECORD +142 -0
  139. messagefoundry-0.1.0.dist-info/WHEEL +4 -0
  140. messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
  141. messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
  142. messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
@@ -0,0 +1,664 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Remote-file transport: SFTP / FTP / FTPS — directory destination + directory-polling source.
4
+
5
+ A single connector type (``REMOTEFILE``) with a ``protocol`` setting selecting the wire protocol:
6
+
7
+ - ``sftp`` — SSH file transfer (paramiko, the ``[sftp]`` extra — lazily imported, so installs that
8
+ never use SFTP skip it). **Host-key verification is ON by default**; an unknown key is refused
9
+ unless the explicit dev escape ``MEFOR_ALLOW_INSECURE_TLS`` is set (and logged loudly when it is),
10
+ mirroring the SQL Server backend's weakened-TLS posture.
11
+ - ``ftp`` — plain FTP (stdlib ``ftplib``). Cleartext: credentials over plain ``ftp`` are **refused**
12
+ unless the escape is set (use ``ftps``/``sftp``), mirroring :func:`refuse_cleartext_credentials`.
13
+ - ``ftps`` — FTP over explicit TLS (``ftplib.FTP_TLS`` + ``PROT P``), credentials encrypted.
14
+
15
+ **Destination** uploads each payload to ``remote_dir``/``filename`` (``{HL7-path}`` placeholders
16
+ resolved via :func:`render_filename`). The write goes to a temp name then a **rename** to the final
17
+ name, so a poller on the far side never sees a partial file. A name collision is uniquified (never a
18
+ silent clobber). A transient failure (connect/timeout/transient FTP error) → :class:`DeliveryError`
19
+ (retried); a permanent server refusal (auth failure, no-such-dir, a 5xx-class permanent FTP error) →
20
+ :class:`NegativeAckError` (``permanent=True``) → dead-letter.
21
+
22
+ **Source** polls ``remote_dir`` for ``pattern`` files, hands each to the pipeline handler, then — only
23
+ after the handler returns — moves the file to ``processed_subdir`` (or deletes it per ``after_read``).
24
+ A handler failure leaves the file in place to re-emit (at-least-once); an over-``max_file_bytes`` file
25
+ is moved to ``error_subdir`` before it's retrieved (a transport-level reject, like the File source).
26
+
27
+ **Idempotency.** Delivery is at-least-once (an upload may re-send) and a poll may re-emit a file that
28
+ was handled but not yet marked, so downstream consumers **must** tolerate duplicates.
29
+
30
+ The client is opened **per operation** (no shared mutable client held across an ``await``), mirroring
31
+ the MLLP destination's fresh-connection-per-delivery — simplest and safest under the staged pipeline's
32
+ concurrent workers. All blocking client I/O runs via :func:`asyncio.to_thread`.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import abc
38
+ import asyncio
39
+ import ftplib # nosec B402 — plain FTP is gated: cleartext credentials are refused (see _validate_common); FTPS/SFTP are the encrypted defaults
40
+ import io
41
+ import logging
42
+ import posixpath
43
+ import uuid
44
+ from typing import Any, Callable, TypeVar
45
+
46
+ from messagefoundry.config.models import ConnectorType, Destination, Source
47
+ from messagefoundry.config.settings import INSECURE_TLS_ESCAPE_ENV, insecure_tls_allowed
48
+ from messagefoundry.transports.base import (
49
+ DeliveryError,
50
+ DestinationConnector,
51
+ InboundHandler,
52
+ NegativeAckError,
53
+ SourceConnector,
54
+ register_destination,
55
+ register_source,
56
+ )
57
+ from messagefoundry.transports.file import (
58
+ DEFAULT_MAX_FILE_BYTES,
59
+ ScanRejected,
60
+ render_filename,
61
+ scan_inbound_file,
62
+ )
63
+
64
+ __all__ = ["RemoteFileDestination", "RemoteFileSource"]
65
+
66
+ logger = logging.getLogger(__name__)
67
+
68
+ _PROTOCOLS = ("sftp", "ftp", "ftps")
69
+
70
+ _T = TypeVar("_T")
71
+
72
+
73
+ def _redact(host: str, path: str) -> str:
74
+ """``host:path`` only — never credentials, for a log line."""
75
+ return f"{host}:{path}"
76
+
77
+
78
+ # --- client abstraction ------------------------------------------------------
79
+
80
+
81
+ class _RemoteError(Exception):
82
+ """A remote-file operation failed. ``permanent`` distinguishes a server refusal that a retry can't
83
+ fix (auth failure, no-such-dir, a permanent FTP 5xx) from a transient connect/IO/timeout failure.
84
+
85
+ The connector maps a transient error to :class:`DeliveryError` (retry) and a permanent one to
86
+ :class:`NegativeAckError` (dead-letter), so the client layer stays transport-detail-only."""
87
+
88
+ def __init__(self, message: str, *, permanent: bool) -> None:
89
+ super().__init__(message)
90
+ self.permanent = permanent
91
+
92
+
93
+ class _RemoteClient(abc.ABC):
94
+ """Connect-per-operation remote-file client. Implementations are **synchronous** (blocking I/O);
95
+ the connector calls them via :func:`asyncio.to_thread`. Each method opens its own connection, does
96
+ the operation, and closes — nothing is held across calls."""
97
+
98
+ @abc.abstractmethod
99
+ def list_dir(self, remote_dir: str) -> list[tuple[str, int]]:
100
+ """``(name, size)`` for each regular file directly in ``remote_dir`` (no recursion)."""
101
+
102
+ @abc.abstractmethod
103
+ def retrieve(self, path: str) -> bytes:
104
+ """The full bytes of the file at ``path``."""
105
+
106
+ @abc.abstractmethod
107
+ def store(self, path: str, data: bytes) -> None:
108
+ """Write ``data`` to ``path`` (overwriting if it exists)."""
109
+
110
+ @abc.abstractmethod
111
+ def rename(self, src: str, dst: str) -> None:
112
+ """Rename ``src`` to ``dst`` (atomic publish / move-to-processed)."""
113
+
114
+ @abc.abstractmethod
115
+ def remove(self, path: str) -> None:
116
+ """Delete the file at ``path``."""
117
+
118
+ @abc.abstractmethod
119
+ def ensure_dir(self, remote_dir: str) -> None:
120
+ """Best-effort create ``remote_dir`` (ignore "already exists")."""
121
+
122
+
123
+ class _FtpClient(_RemoteClient):
124
+ """FTP / FTPS client over the stdlib ``ftplib``. ``tls`` selects ``FTP_TLS`` (explicit TLS, with
125
+ ``PROT P`` so the data channel is encrypted too) over plain ``FTP``."""
126
+
127
+ def __init__(self, settings: dict[str, Any], *, tls: bool) -> None:
128
+ self._host = str(settings["host"])
129
+ self._port = int(settings.get("port", 21))
130
+ self._user = settings.get("username")
131
+ self._password = settings.get("password")
132
+ self._tls = tls
133
+ self._timeout = float(settings.get("connect_timeout", 30.0))
134
+
135
+ def _connect(self) -> ftplib.FTP:
136
+ # B321: plain FTP only when explicitly selected; credentials over it are refused unless
137
+ # MEFOR_ALLOW_INSECURE_TLS is set (see _validate_common). FTPS/SFTP are the encrypted defaults.
138
+ if self._tls:
139
+ ftp: ftplib.FTP = ftplib.FTP_TLS(timeout=self._timeout)
140
+ else:
141
+ ftp = ftplib.FTP(timeout=self._timeout) # nosec B321
142
+ ftp.connect(self._host, self._port)
143
+ ftp.login(user=str(self._user or ""), passwd=str(self._password or ""))
144
+ if isinstance(ftp, ftplib.FTP_TLS):
145
+ ftp.prot_p() # encrypt the data channel, not just the control channel
146
+ return ftp
147
+
148
+ def list_dir(self, remote_dir: str) -> list[tuple[str, int]]:
149
+ return self._op(lambda ftp: self._list(ftp, remote_dir))
150
+
151
+ @staticmethod
152
+ def _list(ftp: ftplib.FTP, remote_dir: str) -> list[tuple[str, int]]:
153
+ out: list[tuple[str, int]] = []
154
+ # MLSD gives a reliable type + size; fall back to NLST + SIZE where the server lacks it.
155
+ try:
156
+ for name, facts in ftp.mlsd(remote_dir):
157
+ if name in (".", "..") or facts.get("type") != "file":
158
+ continue
159
+ out.append((name, int(facts.get("size", 0))))
160
+ return out
161
+ except (ftplib.error_perm, ftplib.error_proto):
162
+ pass
163
+ for name in ftp.nlst(remote_dir):
164
+ base = posixpath.basename(name)
165
+ if base in (".", ".."):
166
+ continue
167
+ try:
168
+ size = ftp.size(posixpath.join(remote_dir, base)) or 0
169
+ except (ftplib.error_perm, ftplib.error_proto):
170
+ size = 0 # a directory or an un-sizable entry; treat as 0 (oversize check skips it)
171
+ out.append((base, int(size)))
172
+ return out
173
+
174
+ def retrieve(self, path: str) -> bytes:
175
+ def run(ftp: ftplib.FTP) -> bytes:
176
+ buf = io.BytesIO()
177
+ ftp.retrbinary(f"RETR {path}", buf.write)
178
+ return buf.getvalue()
179
+
180
+ return self._op(run)
181
+
182
+ def store(self, path: str, data: bytes) -> None:
183
+ self._op(lambda ftp: ftp.storbinary(f"STOR {path}", io.BytesIO(data)))
184
+
185
+ def rename(self, src: str, dst: str) -> None:
186
+ self._op(lambda ftp: ftp.rename(src, dst))
187
+
188
+ def remove(self, path: str) -> None:
189
+ self._op(lambda ftp: ftp.delete(path))
190
+
191
+ def ensure_dir(self, remote_dir: str) -> None:
192
+ def run(ftp: ftplib.FTP) -> None:
193
+ try:
194
+ ftp.mkd(remote_dir)
195
+ except ftplib.error_perm:
196
+ pass # already exists (or no permission) — best-effort, like File's mkdir(exist_ok)
197
+
198
+ self._op(run)
199
+
200
+ def _op(self, fn: Callable[[ftplib.FTP], _T]) -> _T:
201
+ """Connect, run ``fn(ftp)``, always close. Maps ``ftplib`` failures to :class:`_RemoteError`:
202
+ a permanent reply (``error_perm`` — auth/no-such-file/no-perm) is permanent; a connect/IO/
203
+ timeout/protocol error is transient."""
204
+ try:
205
+ ftp = self._connect()
206
+ except (
207
+ ftplib.error_perm
208
+ ) as exc: # login refused — a permanent credential/permission problem
209
+ raise _RemoteError(f"FTP login refused: {exc}", permanent=True) from exc
210
+ except ftplib.all_errors as exc: # connect/timeout/protocol/OSError — transient
211
+ raise _RemoteError(f"FTP connect failed: {exc}", permanent=False) from exc
212
+ try:
213
+ return fn(ftp)
214
+ except ftplib.error_perm as exc:
215
+ raise _RemoteError(f"FTP rejected the operation: {exc}", permanent=True) from exc
216
+ except ftplib.all_errors as exc:
217
+ raise _RemoteError(f"FTP operation failed: {exc}", permanent=False) from exc
218
+ finally:
219
+ try:
220
+ ftp.quit()
221
+ except ftplib.all_errors:
222
+ ftp.close()
223
+
224
+
225
+ def _import_paramiko() -> Any:
226
+ """Import the optional ``paramiko`` SSH library, raising a clear install hint if the ``[sftp]``
227
+ extra isn't present — so installs that never use SFTP never touch it (mirrors ``_import_aioodbc``)."""
228
+ try:
229
+ import paramiko
230
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
231
+ raise RuntimeError(
232
+ "REMOTEFILE sftp protocol requires the 'sftp' extra: pip install 'messagefoundry[sftp]'"
233
+ ) from exc
234
+ return paramiko
235
+
236
+
237
+ class _SftpClient(_RemoteClient):
238
+ """SFTP client over paramiko. Host-key verification is ON by default (system known_hosts + an
239
+ optional ``known_hosts`` file, paramiko ``RejectPolicy``); an unknown key is refused unless the
240
+ explicit dev escape is set, in which case ``AutoAddPolicy`` is used and a warning is logged."""
241
+
242
+ def __init__(self, settings: dict[str, Any]) -> None:
243
+ self._host = str(settings["host"])
244
+ self._port = int(settings.get("port", 22))
245
+ self._user = settings.get("username")
246
+ self._password = settings.get("password")
247
+ self._private_key = settings.get("private_key")
248
+ self._key_password = settings.get("key_password")
249
+ self._known_hosts = settings.get("known_hosts")
250
+ self._timeout = float(settings.get("connect_timeout", 30.0))
251
+ # Fail fast at construction (build_check time): an unknown-host-key posture without the escape
252
+ # must never silently weaken to auto-accept. The accept-unknown policy is gated here so the
253
+ # connector refuses to build rather than trust-on-first-use a man-in-the-middle.
254
+ self._accept_unknown = insecure_tls_allowed()
255
+ if self._accept_unknown:
256
+ logger.warning(
257
+ "REMOTEFILE sftp %s accepts UNKNOWN host keys (AutoAddPolicy) because %s is set "
258
+ "— MITM-able; for a trusted-network dev/test bind only",
259
+ self._host,
260
+ INSECURE_TLS_ESCAPE_ENV,
261
+ )
262
+
263
+ def _connect(self) -> Any:
264
+ paramiko = _import_paramiko()
265
+ client = paramiko.SSHClient()
266
+ client.load_system_host_keys()
267
+ if self._known_hosts:
268
+ client.load_host_keys(str(self._known_hosts))
269
+ # RejectPolicy (default-secure): an unknown host key raises rather than being trusted. Only
270
+ # fall back to AutoAddPolicy behind the explicit insecure escape (set in __init__, logged).
271
+ client.set_missing_host_key_policy(
272
+ paramiko.AutoAddPolicy() if self._accept_unknown else paramiko.RejectPolicy()
273
+ )
274
+ pkey = self._load_key(paramiko)
275
+ client.connect(
276
+ hostname=self._host,
277
+ port=self._port,
278
+ username=str(self._user) if self._user else None,
279
+ password=str(self._password) if self._password else None,
280
+ pkey=pkey,
281
+ timeout=self._timeout,
282
+ allow_agent=False,
283
+ look_for_keys=False,
284
+ )
285
+ return client
286
+
287
+ def _load_key(self, paramiko: Any) -> Any:
288
+ if not self._private_key:
289
+ return None
290
+ passphrase = str(self._key_password) if self._key_password else None
291
+ return paramiko.RSAKey.from_private_key(
292
+ io.StringIO(str(self._private_key)), password=passphrase
293
+ )
294
+
295
+ def list_dir(self, remote_dir: str) -> list[tuple[str, int]]:
296
+ import stat as _stat
297
+
298
+ def run(sftp: Any) -> list[tuple[str, int]]:
299
+ out: list[tuple[str, int]] = []
300
+ for entry in sftp.listdir_attr(remote_dir):
301
+ mode = getattr(entry, "st_mode", 0) or 0
302
+ if _stat.S_ISREG(mode):
303
+ out.append((entry.filename, int(getattr(entry, "st_size", 0) or 0)))
304
+ return out
305
+
306
+ return self._op(run)
307
+
308
+ def retrieve(self, path: str) -> bytes:
309
+ def run(sftp: Any) -> bytes:
310
+ with sftp.open(path, "rb") as fh:
311
+ data: bytes = fh.read()
312
+ return data
313
+
314
+ return self._op(run)
315
+
316
+ def store(self, path: str, data: bytes) -> None:
317
+ def run(sftp: Any) -> None:
318
+ with sftp.open(path, "wb") as fh:
319
+ fh.write(data)
320
+
321
+ self._op(run)
322
+
323
+ def rename(self, src: str, dst: str) -> None:
324
+ self._op(lambda sftp: sftp.posix_rename(src, dst))
325
+
326
+ def remove(self, path: str) -> None:
327
+ self._op(lambda sftp: sftp.remove(path))
328
+
329
+ def ensure_dir(self, remote_dir: str) -> None:
330
+ def run(sftp: Any) -> None:
331
+ try:
332
+ sftp.stat(remote_dir)
333
+ except FileNotFoundError:
334
+ try:
335
+ sftp.mkdir(remote_dir)
336
+ except OSError:
337
+ pass # racing creator / no permission — best-effort
338
+
339
+ self._op(run)
340
+
341
+ def _op(self, fn: Callable[[Any], _T]) -> _T:
342
+ """Connect, open an SFTP channel, run ``fn(sftp)``, always close. Maps a host-key rejection
343
+ to a permanent error (the operator must add the key — a retry can't fix it) and authentication
344
+ failure to permanent; connect/IO/timeout to transient."""
345
+ paramiko = _import_paramiko()
346
+ try:
347
+ client = self._connect()
348
+ except paramiko.AuthenticationException as exc:
349
+ raise _RemoteError(f"SFTP authentication failed: {exc}", permanent=True) from exc
350
+ except paramiko.SSHException as exc:
351
+ # SSHException covers an unknown/rejected host key (RejectPolicy) — a security stop the
352
+ # operator must resolve, so it's permanent, not a retry.
353
+ raise _RemoteError(f"SFTP connection rejected: {exc}", permanent=True) from exc
354
+ except (OSError, EOFError) as exc:
355
+ raise _RemoteError(f"SFTP connect failed: {exc}", permanent=False) from exc
356
+ try:
357
+ sftp = client.open_sftp()
358
+ try:
359
+ return fn(sftp)
360
+ finally:
361
+ sftp.close()
362
+ except FileNotFoundError as exc:
363
+ raise _RemoteError(f"SFTP path not found: {exc}", permanent=True) from exc
364
+ except paramiko.SSHException as exc:
365
+ raise _RemoteError(f"SFTP operation failed: {exc}", permanent=False) from exc
366
+ except OSError as exc:
367
+ raise _RemoteError(f"SFTP operation failed: {exc}", permanent=False) from exc
368
+ finally:
369
+ client.close()
370
+
371
+
372
+ def _make_client(settings: dict[str, Any]) -> _RemoteClient:
373
+ """Build the protocol-appropriate client. Tests monkeypatch this (or the client classes) so no
374
+ real server/SSH is needed; both connectors call it per operation-batch."""
375
+ protocol = str(settings.get("protocol", "sftp")).lower()
376
+ if protocol == "sftp":
377
+ return _SftpClient(settings)
378
+ if protocol == "ftp":
379
+ return _FtpClient(settings, tls=False)
380
+ if protocol == "ftps":
381
+ return _FtpClient(settings, tls=True)
382
+ raise ValueError(f"REMOTEFILE protocol must be one of {_PROTOCOLS}, got {protocol!r}")
383
+
384
+
385
+ def _validate_common(s: dict[str, Any]) -> str:
386
+ """Shared construction-time validation: required ``host``/``remote_dir``, a known ``protocol``, and
387
+ the cleartext-FTP credential guard. Returns the normalized protocol."""
388
+ for req in ("host", "remote_dir"):
389
+ if not s.get(req):
390
+ raise ValueError(f"REMOTEFILE connector requires a {req!r} setting")
391
+ protocol = str(s.get("protocol", "sftp")).lower()
392
+ if protocol not in _PROTOCOLS:
393
+ raise ValueError(f"REMOTEFILE protocol must be one of {_PROTOCOLS}, got {protocol!r}")
394
+ if protocol == "ftp" and (s.get("username") or s.get("password")):
395
+ # Plain FTP sends the credential in cleartext (and the body is PHI). Refuse unless the
396
+ # explicit dev/trusted-network escape is set, mirroring refuse_cleartext_credentials.
397
+ if not insecure_tls_allowed():
398
+ raise ValueError(
399
+ "REMOTEFILE plain ftp transmits credentials in CLEARTEXT; refused unless "
400
+ f"{INSECURE_TLS_ESCAPE_ENV} is set — use ftps (tls=True) or sftp"
401
+ )
402
+ logger.warning(
403
+ "REMOTEFILE %s sends credentials over CLEARTEXT ftp (no TLS)",
404
+ _redact(str(s["host"]), str(s.get("remote_dir", ""))),
405
+ )
406
+ return protocol
407
+
408
+
409
+ class RemoteFileDestination(DestinationConnector):
410
+ """Upload each payload to ``remote_dir``/``filename`` over SFTP/FTP/FTPS (temp-then-rename)."""
411
+
412
+ def __init__(self, config: Destination) -> None:
413
+ s = config.settings
414
+ _validate_common(s)
415
+ # Constructing the SFTP client validates the host-key escape posture fail-fast (build_check).
416
+ self._client = _make_client(s)
417
+ self._settings = s
418
+ self._host = str(s["host"])
419
+ self._remote_dir = str(s["remote_dir"])
420
+ self._filename_template = str(s.get("filename", "{MSH-10}.hl7"))
421
+ self._overwrite = bool(s.get("overwrite", False))
422
+ self._encoding: str = s.get("encoding", "utf-8")
423
+
424
+ async def send(self, payload: str) -> None:
425
+ try:
426
+ await asyncio.to_thread(self._upload, payload)
427
+ except _RemoteError as exc:
428
+ if exc.permanent:
429
+ raise NegativeAckError(str(exc), code="remotefile", permanent=True) from exc
430
+ raise DeliveryError(str(exc)) from exc
431
+
432
+ def _upload(self, payload: str) -> None:
433
+ name = render_filename(self._filename_template, payload, fallback="message.hl7")
434
+ data = payload.encode(self._encoding)
435
+ self._client.ensure_dir(self._remote_dir)
436
+ final = posixpath.join(self._remote_dir, name)
437
+ if not self._overwrite:
438
+ final = self._unique(final)
439
+ # Write to a unique temp name then rename, so a poller on the far side never sees a partial
440
+ # file. The temp suffix is unguessable so two concurrent uploads never collide on it.
441
+ tmp = posixpath.join(self._remote_dir, f".{name}.{uuid.uuid4().hex}.part")
442
+ self._client.store(tmp, data)
443
+ try:
444
+ self._client.rename(tmp, final)
445
+ except _RemoteError:
446
+ # Publish failed — don't leave the temp behind. Best-effort cleanup, then re-raise so the
447
+ # delivery is classified (retry/dead-letter) by send().
448
+ try:
449
+ self._client.remove(tmp)
450
+ except _RemoteError:
451
+ logger.warning("REMOTEFILE could not remove temp %s after a failed rename", tmp)
452
+ raise
453
+
454
+ def _unique(self, final: str) -> str:
455
+ """Return ``final`` or, if a file already exists there, ``name-1.ext``, ``name-2.ext``, …
456
+ Never clobbers an existing file silently (mirrors the File destination)."""
457
+ try:
458
+ existing = {n for n, _ in self._client.list_dir(self._remote_dir)}
459
+ except _RemoteError:
460
+ return final # can't list (e.g. dir not yet created) → nothing to collide with
461
+ base = posixpath.basename(final)
462
+ if base not in existing:
463
+ return final
464
+ stem, dot, ext = base.partition(".")
465
+ n = 1
466
+ while True:
467
+ candidate = f"{stem}-{n}{dot}{ext}"
468
+ if candidate not in existing:
469
+ return posixpath.join(self._remote_dir, candidate)
470
+ n += 1
471
+
472
+ async def test_connection(self) -> None:
473
+ # Connect + authenticate + ensure the upload dir (the destination's normal first step) — no
474
+ # message data written. A failure is mapped like send()'s.
475
+ try:
476
+ await asyncio.to_thread(self._client.ensure_dir, self._remote_dir)
477
+ except _RemoteError as exc:
478
+ if exc.permanent:
479
+ raise NegativeAckError(str(exc), code="remotefile", permanent=True) from exc
480
+ raise DeliveryError(str(exc)) from exc
481
+
482
+ async def aclose(self) -> None:
483
+ return None # connect-per-operation — nothing held open
484
+
485
+
486
+ class RemoteFileSource(SourceConnector):
487
+ """Poll ``remote_dir`` for ``pattern`` files and feed each to the pipeline handler."""
488
+
489
+ polls_shared_resource = True # a remote dir is a shared external resource — leader-gate it
490
+
491
+ def __init__(self, config: Source) -> None:
492
+ s = config.settings
493
+ _validate_common(s)
494
+ self._client = _make_client(s)
495
+ self._host = str(s["host"])
496
+ self._remote_dir = str(s["remote_dir"])
497
+ self._pattern: str = s.get("pattern", "*.hl7")
498
+ self._poll_seconds: float = float(s.get("poll_seconds", 5.0))
499
+ self._after_read: str = s.get("after_read", "move") # "move" | "delete"
500
+ mfb = s.get("max_file_bytes", DEFAULT_MAX_FILE_BYTES)
501
+ self._max_file_bytes: int | None = int(mfb) if mfb else None
502
+ self._processed_dir = posixpath.join(
503
+ self._remote_dir, s.get("processed_subdir", ".processed")
504
+ )
505
+ self._error_dir = posixpath.join(self._remote_dir, s.get("error_subdir", ".error"))
506
+ self._handler: InboundHandler | None = None
507
+ # Leader-gate (Track B Step 4b): when set, the remote dir (a shared external resource) is
508
+ # listed/downloaded/moved only while the gate returns True, so in a cluster exactly one node
509
+ # ingests its files. None = always poll (single-node / direct callers / tests) — identical.
510
+ self._leader_gate: Callable[[], bool] | None = None
511
+ self._skipping = False # whether the last tick was gated out (for a single transition log)
512
+ self._stop = asyncio.Event()
513
+ self._task: asyncio.Task[None] | None = None
514
+
515
+ async def start(
516
+ self, handler: InboundHandler, *, leader_gate: Callable[[], bool] | None = None
517
+ ) -> None:
518
+ self._handler = handler
519
+ self._leader_gate = leader_gate
520
+ self._stop.clear()
521
+ self._task = asyncio.create_task(self._run())
522
+
523
+ async def stop(self) -> None:
524
+ self._stop.set()
525
+ if self._task is not None:
526
+ # return_exceptions: a faulted poll task must not re-raise here — stop() runs during reload
527
+ # quiesce, outside its rollback (mirrors the File / DATABASE sources).
528
+ await asyncio.gather(self._task, return_exceptions=True)
529
+ self._task = None
530
+
531
+ async def test_connection(self) -> None:
532
+ # Connect + authenticate + list the poll dir (read-only — what the source actually does), no
533
+ # files moved or deleted. A failure is mapped like the delivery path's.
534
+ try:
535
+ await asyncio.to_thread(self._client.list_dir, self._remote_dir)
536
+ except _RemoteError as exc:
537
+ if exc.permanent:
538
+ raise NegativeAckError(str(exc), code="remotefile", permanent=True) from exc
539
+ raise DeliveryError(str(exc)) from exc
540
+
541
+ async def _run(self) -> None:
542
+ while not self._stop.is_set():
543
+ try:
544
+ if self._may_poll():
545
+ await self._poll_once()
546
+ except asyncio.CancelledError:
547
+ raise
548
+ except Exception:
549
+ # A poll error (connection drop, a bad pattern, a retrieve/move failure) must NOT kill
550
+ # the poller — it would silently stop the connection from receiving while it still
551
+ # reports running. Log and retry next interval (mirrors the File / DATABASE sources).
552
+ logger.exception(
553
+ "REMOTEFILE source poll failed for %s; retrying next interval",
554
+ _redact(self._host, self._remote_dir),
555
+ )
556
+ try:
557
+ await asyncio.wait_for(self._stop.wait(), self._poll_seconds)
558
+ except asyncio.TimeoutError:
559
+ pass # poll interval elapsed; poll again
560
+
561
+ def _may_poll(self) -> bool:
562
+ """Whether this tick may list/retrieve/move the remote dir. False on a follower (leader-
563
+ gated, Step 4b): a non-leader must NOT list, download, or move/delete remote files, since
564
+ the dir is shared and two nodes ingesting it would duplicate intake. The loop still ticks,
565
+ so a node that becomes leader polls on its next tick (reactive-by-polling, no restart). When
566
+ the gate is None or True, behaves exactly as before. Logged once on each transition (never
567
+ per skipped tick — that would spam a follower's log every poll interval)."""
568
+ if self._leader_gate is None or self._leader_gate():
569
+ if self._skipping:
570
+ self._skipping = False
571
+ logger.debug(
572
+ "REMOTEFILE source resuming polling of %s (now leader)",
573
+ _redact(self._host, self._remote_dir),
574
+ )
575
+ return True
576
+ if not self._skipping:
577
+ self._skipping = True
578
+ logger.debug(
579
+ "REMOTEFILE source skipping polling of %s (not leader; another node ingests it)",
580
+ _redact(self._host, self._remote_dir),
581
+ )
582
+ return False
583
+
584
+ async def _poll_once(self) -> None:
585
+ import fnmatch
586
+
587
+ assert self._handler is not None
588
+ await asyncio.to_thread(self._client.ensure_dir, self._processed_dir)
589
+ await asyncio.to_thread(self._client.ensure_dir, self._error_dir)
590
+ entries = await asyncio.to_thread(self._client.list_dir, self._remote_dir)
591
+ for name, size in sorted(entries):
592
+ if self._stop.is_set():
593
+ break # shutting down — leave the rest for the next start (at-least-once)
594
+ if not fnmatch.fnmatch(name, self._pattern):
595
+ continue
596
+ path = posixpath.join(self._remote_dir, name)
597
+ if self._max_file_bytes is not None and size > self._max_file_bytes:
598
+ # Transport-level reject *before* any bytes are read — parallels the File source's
599
+ # oversize guard. It never became a "received message", so there's no store
600
+ # disposition; move it to the error dir and log it (never a silent drop).
601
+ logger.warning(
602
+ "REMOTEFILE file %s exceeds max_file_bytes (%s); routing to error dir",
603
+ name,
604
+ self._max_file_bytes,
605
+ )
606
+ await self._move(path, self._error_dir, name)
607
+ continue
608
+ try:
609
+ raw = await asyncio.to_thread(self._client.retrieve, path)
610
+ except _RemoteError as exc:
611
+ # Transient (locked / vanished mid-poll): leave it in place to retry next poll rather
612
+ # than quarantine a healthy file. Logged, never silently swallowed.
613
+ logger.warning(
614
+ "REMOTEFILE could not retrieve %s (will retry next poll): %s", name, exc
615
+ )
616
+ continue
617
+ try:
618
+ await asyncio.to_thread(scan_inbound_file, raw, name)
619
+ except ScanRejected as exc:
620
+ # A configured pre-ingest scanner (AV/ICAP/plugin) rejected the content before it
621
+ # entered the pipeline (ASVS 5.4.3) — the control that matters most for a remote /
622
+ # less-trusted drop source. Quarantine + log; like the oversize reject above it never
623
+ # became a "received message", so there's no store disposition.
624
+ logger.warning(
625
+ "REMOTEFILE file %s rejected by the pre-ingest scan hook (%s); routing to error dir",
626
+ name,
627
+ exc,
628
+ )
629
+ await self._move(path, self._error_dir, name)
630
+ continue
631
+ try:
632
+ await self._handler(raw)
633
+ except Exception as exc:
634
+ # The handler records every message-level outcome itself and returns, so an exception
635
+ # escaping here is an infrastructure failure (the durable store write failed). Leave the
636
+ # file in place so the next poll retries (at-least-once) — moving it would drop a
637
+ # received-but-unrecorded message (mirrors the File source's M-15).
638
+ logger.warning(
639
+ "REMOTEFILE handler failed for %s (will retry next poll): %s", name, exc
640
+ )
641
+ continue
642
+ await self._after_processing(path, name)
643
+
644
+ async def _after_processing(self, path: str, name: str) -> None:
645
+ if self._after_read == "delete":
646
+ try:
647
+ await asyncio.to_thread(self._client.remove, path)
648
+ except _RemoteError as exc:
649
+ # A processed file we can't delete will be re-read (a duplicate); surface it.
650
+ logger.warning("REMOTEFILE could not delete processed file %s: %s", name, exc)
651
+ else:
652
+ await self._move(path, self._processed_dir, name)
653
+
654
+ async def _move(self, path: str, dest_dir: str, name: str) -> None:
655
+ dst = posixpath.join(dest_dir, name)
656
+ try:
657
+ await asyncio.to_thread(self._client.rename, path, dst)
658
+ except _RemoteError as exc:
659
+ # A stuck file (locked / dest unwritable) stays and is re-read; log it.
660
+ logger.warning("REMOTEFILE could not move %s to %s: %s", name, dest_dir, exc)
661
+
662
+
663
+ register_destination(ConnectorType.REMOTEFILE, RemoteFileDestination)
664
+ register_source(ConnectorType.REMOTEFILE, RemoteFileSource)