@cosyte/mllp 0.0.4 → 0.0.6

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.
package/CHANGELOG.md CHANGED
@@ -76,6 +76,52 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
76
76
 
77
77
  ### Security
78
78
 
79
+ - **BREAKING: ACK-correlation diagnostics no longer carry the control ID (`PHI-WARNING-MESSAGE-LEAK`).**
80
+ The opt-in `correlateByControlId` path built its diagnostics by interpolation, and both ends were
81
+ consumer-controlled and unbounded. The unmatched-ACK path read the **peer's** MSA-2 straight off an
82
+ inbound frame into `MllpFramingError.message` and onto a `controlId` field of the frozen `'error'`
83
+ payload; measured against a peer sending a 1,000,000-byte MSA-2, that produced a 1,000,026-byte
84
+ `Error.message` and an equally large event field, both headed for a log. `MLLP_ACK_AFTER_TIMEOUT`
85
+ did the same with the timed-out send's own MSH-10, and `MllpTimeoutError.messageControlId` carried
86
+ it on an `Error`, whose `stack` is what an error reporter ships off the box.
87
+ The fix is the frozen-registry shape: `src/client/ack-diagnostics.ts` holds one literal per code and
88
+ `ackDiagnosticMessage(code)` takes **no value parameter**, so no caller can widen it into an
89
+ interpolation site. The `Correlator` withholds the string at the source: `onWarning` now receives
90
+ `controlIdBytes` and `onUnmatchedAck` receives a byte length (or `null` when no MSA-2 could be read
91
+ at all), so nothing downstream of it has a control ID to interpolate.
92
+ Breaking field changes, all pre-alpha: `MllpTimeoutError.messageControlId: string | undefined` is
93
+ now `messageControlIdBytes: number | undefined`; the `'error'` event's `controlId` is now
94
+ `controlIdBytes`; the `'warning'` payload for the two correlation codes is the new exported
95
+ `AckCorrelationWarning` (`MllpWarning` plus `controlIdBytes` and `elapsedSinceSendMs`). Warning
96
+ codes are unchanged. A truncated ID was considered and rejected, as was a hex rendering: both still
97
+ grow the diagnostic with the input and both still disclose the bytes. This is the same answer the
98
+ ACK adapter's verbatim-echo warning already reached.
99
+ - **A diagnostic-surface PHI gate that instantiates a client (`test/phi/diagnostic-phi-leak.test.ts`).**
100
+ Binds the shared `assertNoDiagnosticPhiLeak` runner (`@cosyte/test-utils`, pin raised to `^0.0.2`;
101
+ a caret on a `0.0.x` version resolves to that version exactly, so without the bump the runner is not
102
+ even installed). Nineteen declared slots, each naming the diagnostic code it must reach, covering
103
+ the message control ID through correlation, an ACK's own MSH-10 and MSA-3, the outbound payload
104
+ body, embedded VT and FS bytes inbound and outbound, an oversized frame, missing leading VT,
105
+ leading whitespace, FS without CR, LF after FS, an empty payload after a marker-bearing frame, the
106
+ in-flight orphan on disconnect, and all three of the ACK adapter's control-ID paths (unparseable
107
+ inbound, a provably non-verbatim echo, and an unverifiable text echo). The suite it supplements
108
+ scoped itself to the framing layer and never instantiated a client, which is exactly why the three
109
+ correlation surfaces were the three it could not reach. Verified red on the base commit on three
110
+ slots before any fix existed, and re-verified by four mutations afterwards, one of which is a
111
+ hex-encoded echo that a verbatim match cannot see and `checkLengthInvariance` catches.
112
+ `checkLengthInvariance` is decided **per slot and by measurement**: 8 of the 19 hold it, and the
113
+ other 11 give it up because a byte offset or a byte count is the prescribed report there and
114
+ growth is correct. A first draft split the table in two by argument and was wrong about four
115
+ slots, which were giving up the re-encoded-echo check for nothing.
116
+ Scope, stated precisely rather than generously: this closes **unbounded** consumer-controlled input
117
+ on the `correlateByControlId` path. The slot table covers three surfaces (the client, framing via
118
+ `Connection`, and `ack-from-hl7`) and **no slot instantiates `MllpServer`**; the server's diagnostic
119
+ surfaces were read and carry static strings, counts, or Node's own TLS and socket text, so that gap
120
+ holds no HL7 payload today, which is why it was not urgent rather than why the table is complete.
121
+ Two framing codes still render the hex of a **single** byte, and
122
+ `MllpFramingError.snippet` still carries one, both bounded by design and both now described
123
+ accurately on `MllpWarning.message` (whose doc comment claimed the opposite, and shipped that claim
124
+ into the published declarations).
79
125
  - **Repo-side PHI commit-scanner (`scripts/phi-scan.ts`), matching the `@cosyte/hl7` pilot.**
80
126
  mllp transports HL7 v2 payloads (MLLP wraps HL7 in `VT … FS CR`), so its data fixtures
81
127
  (`test/**` `.frame.bin` frames) carry the same PHI shapes hl7's do. The scanner is a direct
@@ -139,6 +185,89 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
139
185
 
140
186
  ### Fixed
141
187
 
188
+ - **The PHI scanner no longer refuses an entire sweep because a transient file went away while it
189
+ was reading.** `all` mode lists `test/` + `src/`, then reads each file; anything created and
190
+ removed inside that window made a read throw `ENOENT` and took the whole scan down with it
191
+ (exit 2, the invocation-error code). The refusal was never wrong, the **enumeration** was, so the
192
+ enumeration is what changed. `PHI-SCAN-ENUMERATE-THEN-READ-CLASS`, porting the remedy that
193
+ shipped in `ccda#80`.
194
+
195
+ **This repo is genuinely reachable, and that was measured rather than read off the code.** With a
196
+ `git` shim first on `PATH` (the scanner runs `git` between the walk and the first read, so the
197
+ shim is a deterministic hook into exactly that gap), a `test/phi-scan-cap-*/capture.txt` removed
198
+ in the window made `pnpm phi-scan` on this checkout exit 2. Those directories are not
199
+ hypothetical: `test/scripts/phi-scan.test.ts` `mkdtemp`s two of them inside `test/`, which IS a
200
+ walk root, and polling the tree during a suite run measured each existing for about **510 ms**
201
+ (about 1.03 s of exposure per `pnpm test`). The siblings are spared only because their walk roots
202
+ are not the repo root, which is where a build transient lands. **The exposure is real but not
203
+ self-triggering:** nothing in this repo runs an `all`-mode sweep beside the suite (CI runs
204
+ `pnpm phi-scan` as a step strictly before `pnpm test` in one sequential job, the pre-commit hook
205
+ runs `--staged` and reads blobs from the index, and inside the test file the sweep is ordered
206
+ before both capture tests), so what is live is a hand-run or agent-run sweep during a test run.
207
+ Racing 88 sweeps against 6 suite runs produced 0 refusals, which is consistent with the measured
208
+ duty cycle: walk 2-4 ms, `git check-ignore` 6-9 ms, reads 11-19 ms, inside a ~4 s process.
209
+
210
+ **Exactly one case is now tolerated:** a file the walk enumerated **itself**, that **git does not
211
+ track**, failing with **`ENOENT`**. It is reported on stderr as skipped, naming the path, and is
212
+ never silent. **Still refusing:** a tracked file that cannot be read, any non-`ENOENT` failure, a
213
+ tolerated file back on disk at sweep end, a `git` that cannot report the tracked set, and an
214
+ **empty** tracked set (a removed `.git/index` exits 0 with no output, which would make every file
215
+ untracked and quietly delete the tracked-file bound; a corrupt one exits 128 and was already
216
+ caught). `all` mode additionally refuses when it observed **no** files, so the tolerance can never
217
+ decay into a clean report of nothing.
218
+
219
+ **Which bounds are pinned, and which are only disclosed.** Pinned by eight tests in
220
+ `test/scripts/phi-scan.test.ts`, each against a throwaway tree under `tmpdir()` (seven of them a
221
+ git repo, one deliberately not) and **five** of them driven by the `git`-shim technique, with no
222
+ sleep and no real build: the tolerance itself; the tracked-file bound; the `ENOENT`-only bound
223
+ (`EISDIR` via a decoy replaced by a directory); git unable to answer; the empty tracked set;
224
+ `observed no files`; a violator in an untracked file that does not vanish; and a violator inside a
225
+ transient that is still there when the read arrives. Each bound was **mutation-tested**: widening
226
+ `tolerateVanish` to `true` fails three of them, dropping the `ENOENT` check fails one, dropping
227
+ the `size > 0` guard fails one, and dropping the `observed === 0` refusal fails one. **Not pinned,
228
+ and measured as not pinned:** the back-on-disk re-check. Stubbing it out leaves all 40 tests
229
+ green. Reaching it needs a timed re-create against a deliberately slowed sweep, and a
230
+ load-sensitive sleep guarding a load-dependent race is the failure mode this defect itself
231
+ teaches; losing that branch loses the re-check, never the tolerance's bounds. **The gate drove
232
+ that branch by hand** (shim anchored on the second git call, `git ls-files`, against a 20,000-file
233
+ read loop with a tuned re-create delay) and confirmed the code right at all three delays, so the
234
+ branch is measured-correct and unpinned rather than unknown.
235
+
236
+ **Two residuals carried, not closed.** The post-sweep re-check is keyed on the enumerated PATH,
237
+ not on content, so an untracked file RENAMED inside the window goes unscanned under a clean
238
+ report; it is bounded (committing the file means `git add`, after which it is tracked and
239
+ untolerable) and closing it needs a content-addressed sweep, a different design rather than a
240
+ wider bound. And `walk()`'s own `existsSync` -> `readdirSync` race, one phase earlier, is
241
+ untouched: a directory removed in that window throws a plain `SystemError` `main()` does not
242
+ convert, so Node exits **1**, the code the contract reserves for "hits found". It matters more
243
+ here than in the siblings because this repo's transient is a **directory**, removed wholesale, and
244
+ it is unpinnable by the same technique because nothing runs before the walk.
245
+
246
+ **The suite keeps writing inside the walk root, deliberately.** The two capture tests exist to
247
+ prove that a file whose repo-relative path starts with `test/` earns the structured scan, so the
248
+ path is the fixture and moving them would delete what they test.
249
+
250
+ **Three things the gate checked hardest and could not break, recorded so they are not re-derived.**
251
+ The tracked-set spelling comparison holds: instrumented against this checkout, **zero** files come
252
+ out `tolerateVanish: true`, so the tolerance is inert on a clean tree, and a purpose-built repo of
253
+ 15 adversarial tracked filenames (space, tab, newline, quote, backslash, `$`, `*`, `;`, non-ASCII,
254
+ CJK, emoji, plus an invalid-UTF-8 name) never marked a tracked file untracked, because `-z`
255
+ suppresses `core.quotePath` on both sides. The tolerance cannot reach `paths` or `staged` mode,
256
+ and `--allow-fixture` forces `mode = "paths"`, so an allow-list subtraction can never be used to
257
+ drive `all` mode to zero observed files. And in `all` mode `report()` is unreachable while
258
+ `observed === 0`, while in every mode a sweep that observed nothing has no hits to publish.
259
+ (`staged` legitimately reaches `report()` at `observed === 0` and prints `OK, no hits` when a
260
+ commit stages nothing in scope, which is why the refusal is scoped to `all`.)
261
+
262
+ **Two scope notes surfaced by the same review, one pre-existing and one introduced here.**
263
+ PRE-EXISTING: `walk()` tests `e.isFile()`, which is false for a symlink Dirent, so a symlinked
264
+ file under `test/` or `src/` is never swept in `all` mode; it is bounded, because git cannot
265
+ commit content through a symlink and `--staged` reads the index. INTRODUCED by this change: a
266
+ tolerated skip exits **0** with only a stderr line, including in CI, where the tree is at rest and
267
+ a skip is therefore anomalous by definition. On the base commit that same input exited 2. Both are
268
+ stated rather than changed: the first is not this defect, and the second **is** the shipped design
269
+ under review.
270
+
142
271
  - **Three doc comments that had outlived the code they described**, all of which shipped into the
143
272
  published type declarations. The `'reconnecting'` payload was documented as carrying
144
273
  `connectionId` only, while `MllpClient` populates `attempt` and `delayMs` when it schedules a