@cosyte/dicom 0.0.11 → 0.0.13
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 +512 -3
- package/README.md +10 -8
- package/dist/index.cjs +651 -279
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +282 -73
- package/dist/index.d.ts +282 -73
- package/dist/index.mjs +652 -280
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,517 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## 0.0.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b7a77fe: Build every Tier-3 fatal message from a frozen registry, and cut the `{ strict: true }` snippet in
|
|
8
|
+
the frame its offset names (`DICOM-FATAL-MESSAGE-REGISTRY`).
|
|
9
|
+
|
|
10
|
+
Tier-2 warnings have been registry-bound for several releases; Tier-3 messages were still assembled
|
|
11
|
+
at the throw site out of template literals, and four of them printed four bytes of the document each.
|
|
12
|
+
The messages that interpolated most were the ones raised **when a length field is lying**, which is
|
|
13
|
+
the condition that makes a reader read bytes inside somebody's value as a Data Element header, so the
|
|
14
|
+
tag and the length they printed were that value. Measured on a synthetic `"MR BRAIN SMITHSON "`:
|
|
15
|
+
`Element 41524E49 declared length=1330858068` is `"RAIN"` then `"THSO"`, eight consecutive payload
|
|
16
|
+
bytes, each recoverable with one typed read.
|
|
4
17
|
|
|
5
|
-
|
|
18
|
+
The bound is the factory signature, matching the three Tier-2 codes that paid for this lesson before
|
|
19
|
+
it: the token type has **no tag field and no wire-length field**, so there is no slot for one to
|
|
20
|
+
travel through, and `err.byteOffset` identifies the element instead. A VR still renders when it names
|
|
21
|
+
one of the 34; a byte count still renders when it is bounded by the buffer being read.
|
|
22
|
+
|
|
23
|
+
Separately, `DicomParseError.snippet` is 16 raw source bytes cut at the diagnostic's own
|
|
24
|
+
`byteOffset`. That offset moves with the frame the element was read in, but the cut was always taken
|
|
25
|
+
from the whole file, so a strict-mode escalation raised inside a defined-length Sequence Item cut the
|
|
26
|
+
file at an item-relative number and returned **an unrelated element's** bytes. The parse context's
|
|
27
|
+
buffer now follows the frame at all four places this parser changes one. **The snippet is still
|
|
28
|
+
unredacted source bytes and is still PHI.**
|
|
29
|
+
|
|
30
|
+
**⚠ Some fatal messages are reworded, so a consumer string-matching one stops matching.** No count is
|
|
31
|
+
given, deliberately: a first draft said "six", a graded pass measured nine, and the honest remedy for
|
|
32
|
+
a count corrected once is to delete it rather than increment it. Diff `FATAL_MESSAGES` against the
|
|
33
|
+
previous release's template literals if you need the set. **`err.code` is unchanged on every path and
|
|
34
|
+
which files throw is unchanged.** Narrow on the code, never on the prose.
|
|
35
|
+
|
|
36
|
+
Three residuals are named rather than closed, each with an asserted test row: the same fabricated
|
|
37
|
+
header still reaches the Tier-2 `DICOM_PRIVATE_TAG_NO_CREATOR` message;
|
|
38
|
+
`report.embeddedAttributes[].hidden` still lists a fabricated tag alongside the real one that made
|
|
39
|
+
its run reportable; and because of the first, `ds.warnings[].message` is **not** unconditionally safe
|
|
40
|
+
to log, which the docs now say. Narrowing any of the three is a product call, not a fix.
|
|
41
|
+
|
|
42
|
+
- 3617034: 🩺 `DeidentifyReport`'s `contextPath` was documented as structural and it is not: a segment is
|
|
43
|
+
`TAG[index]` and the tag half is read off the wire, bound by neither a shape test nor a closed table.
|
|
44
|
+
A file whose under-declared Value Length desynchronizes the reader onto four bytes inside somebody's
|
|
45
|
+
value, followed by `SQ`, gets that fabricated sequence descended and its fabricated tag published in
|
|
46
|
+
every `contextPath` beneath it. Measured on a synthetic `LO` carrier holding `"MRS BRAIN SMITHSON"`:
|
|
47
|
+
`contextPath: ["53484E4F[0]"]`, which is `"HSON"` in wire order, with no warning raised and every
|
|
48
|
+
finding array empty. **Redacting it is a logging fix and not an object fix**: on that same file the
|
|
49
|
+
de-identified object still carries the fabricated `(5348,4E4F)` and the serializer writes `"HSON"`
|
|
50
|
+
back out under a `Patient Identity Removed = YES` stamp, which is the already-disclosed
|
|
51
|
+
under-declared carrier class and not this field. The claim is corrected in the type, the tolerance table and the
|
|
52
|
+
troubleshooting guide, and `contextPath` is added to the report's list of fields that are not
|
|
53
|
+
value-free; no guard was widened, because withholding the tag would destroy the audit on every
|
|
54
|
+
well-formed file to bound a malformed one. `PRE-EXISTING`; no runtime behaviour changes. **Treat
|
|
55
|
+
`contextPath` as PHI when the source is untrusted.**
|
|
56
|
+
|
|
57
|
+
The `DICOM_ITEM_CROSSES_SEQUENCE_END` disclosure no longer says `contextPath` names "an item it was
|
|
58
|
+
never in" - that asserts which of two byte-identical files you have. It is deleted rather than
|
|
59
|
+
reworded a third time, and replaced by the two measurements that were already pinned.
|
|
60
|
+
|
|
61
|
+
- 369abbe: 🩺 A private `SQ` a `Profile` vouched for under `RetainSafePrivate` is no longer written into de-identified output verbatim (`DICOM-PRIVATE-SQ-CARVE-OUT`, `PRE-EXISTING`, live through the published `0.0.10`).
|
|
62
|
+
|
|
63
|
+
`keepsPrivate` decided retention before the descent and routed a "yes" to `keepOrEmpty`, the only path in the module that writes a source value into output unchanged. So the whole vendor sequence was kept without anything inside it being examined: Table E.1-1 attributes the vendor encoded in its items, UIDs inside it, and any private element the file's own length fields pulled into it, all with `report.removedPrivateTags` reading `[]` and the object stamped `(0012,0062) Patient Identity Removed = YES`. On a fully conformant file, a `(0010,0010)` Patient's Name inside such a carrier was copied straight through.
|
|
64
|
+
|
|
65
|
+
PS3.15 2026c §E.3.10 licenses retention for "Private Attributes that are known by the de-identifier to be safe from identity leakage", which is knowledge about one Private Attribute and not about a Data Set nested in its value; PS3.5 2026c §7.5.1 makes an Item Value exactly that, and PS3.15 2026c §E.1.1 obliges protecting Table E.1-1 attributes "whether contained in the top level Data Set or embedded in an Item of a Sequence of Items".
|
|
66
|
+
|
|
67
|
+
The retention decision is unchanged and no guard is widened. A vouched-for private `SQ` now takes the same two branches every other `SQ` takes: it is walked when its items exist, and emptied with `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` plus a `report.unauditableSequences` entry when the parser never materialized them. A non-`SQ` private element is untouched, and there is no new public surface.
|
|
68
|
+
|
|
69
|
+
The price is PS3.5 2026c §7.8.1's per-Data-Set reservation scope, which now applies inside the carrier: a nested private element whose block is reserved only at the root is removed and named, while one whose Private Creator is inside the Item, as §7.8.1 requires, is kept. No reading changes: 0 of 83,037 grid cells differ in any parse respect against base `495c9fc`.
|
|
70
|
+
|
|
71
|
+
- 92c0373: 🩺 A private carrier a `Profile` declared `SQ` is no longer written into de-identified output verbatim when the parse tree resolved it to something else (`DICOM-PRIVATE-SQ-PARSE-VR`, `PRE-EXISTING`, live through the published `0.0.10`).
|
|
72
|
+
|
|
73
|
+
`keepRetainedPrivate` branched on `el.vr === "SQ"`, and the parse tree and the profile disagree about the same bytes in two ordinary, conformant situations. Under Implicit VR LE a private tag carries no VR on the wire (PS3.5 2026c §7.1.3), so `SQ` there is an inference the parser draws from a `Profile` it was given, and a profile passed only to `deidentify()` leaves the element `UN`. Under Explicit VR the wire's VR wins in the parser, so a sender who writes a profile-declared `SQ` attribute as `OB` or `UN` yields that instead, with an honest defined length wrapping a well-formed `(FFFE,E000)` item stream. Both shapes were measured shipping a `(0010,0010)` Patient's Name into output stamped `(0012,0062) Patient Identity Removed = YES`, and the Explicit VR one raises nothing at all on `ds.warnings`.
|
|
74
|
+
|
|
75
|
+
The remedy is a second authority rather than a content test: the same `Profile` that vouched for the element declares its VR. A retained private element the profile declares `SQ` whose parse tree carries no items is emptied through the channel a parsed `SQ` with no items already used, `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` plus a `report.unauditableSequences` entry, keeping the VR the file actually carried instead of re-typing the element to `SQ`. `keepsPrivate` and the retention decision are unchanged, no parser file is touched, and there is no new public surface.
|
|
76
|
+
|
|
77
|
+
It reaches the CP-246 `UN` too wherever a profile named it, because the test does not look at the length field; the undefined-length `UN` residual is a statement about elements no profile named. Two conjuncts ahead of it keep properties the module already had. An element whose on-wire VR is not one of the 34 still takes the tag-free `DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE` route, which keeps a header fabricated from bytes inside some element's value off the diagnostic **when its fabricated VR is outside the 34, and only then**: fabricate `OB` and the tag does reach `report.unauditableSequences`. That is disclosed rather than guarded, because a fabricated `OB` header and a genuine one are byte-identical, and because on that same input the previous behaviour kept the carrier verbatim and shipped the whole nested name. `DeidentifyReport` is not a value-free surface: its value-bearing fields are now a list on the type with no count anywhere, and re-deriving that list disclosed a fourth nobody had named, `embeddedAttributes[].hidden`, whose entries are four bytes found inside a value and whose own documentation said "safe to log" (`PRE-EXISTING`, byte-identical on every release that has shipped the field, disclosed rather than narrowed here). And a zero-length value is left alone so a second `deidentify()` pass reports no second drop. `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE`'s message no longer reads "is VR=SQ", because it is shared by both producers and the second one's premise is that the parse tree and the profile disagree about the VR.
|
|
78
|
+
|
|
79
|
+
The cost: a caller who passes a profile to `deidentify()` but not to `parseDicom` now loses that vendor sequence's content instead of shipping it unexamined. Pass the same profile to `parseDicom` and the sequence is walked and its non-PHI content retained. Deliberately not closed, and still open rather than decided: a carrier whose profile entry declares a binary VR (`OB`/`OW`/`UN`) over a well-formed item stream, which would need a content test on exactly the VRs arbitrary bytes are for.
|
|
80
|
+
|
|
81
|
+
- ad675da: The README lockup now links to cosyte.com (`ASSETS`).
|
|
82
|
+
|
|
83
|
+
The `<picture>` block above the H1 is wrapped in an anchor to https://cosyte.com, per the founder
|
|
84
|
+
requirement of 2026-08-06. Nothing inside the block moved: the `<source>`, the `<img>`, the alt text
|
|
85
|
+
and both tile URLs are byte-identical.
|
|
86
|
+
|
|
87
|
+
What the anchor does was measured on both surfaces by `fhir`, not assumed, because fourteen READMEs
|
|
88
|
+
carry this shape. On GitHub the anchor works and the colour-scheme switch keeps working, because the
|
|
89
|
+
`<img>` stays a direct child of `<picture>`, which is the condition the HTML spec puts on `<source>`
|
|
90
|
+
applying at all. On an npm package page the anchor is lost: npm wraps a README image in its own
|
|
91
|
+
anchor to the image file, a nested anchor is not representable, so the parser closes ours early and
|
|
92
|
+
the image ends up linked to the image file rather than to cosyte.com. Shipped anyway by founder
|
|
93
|
+
decision of 2026-08-07: on npm that is no worse than the unlinked lockup it replaces, and GitHub is
|
|
94
|
+
where these READMEs are read.
|
|
95
|
+
|
|
96
|
+
- 0a8c6e3: Correct the disclosed extent of the surviving `RetainSafePrivate` retain-route leak
|
|
97
|
+
(`DICOM-RETAIN-ROUTE-RESIDUALS`). Six artifacts described it as "a private carrier whose profile
|
|
98
|
+
entry declares a binary VR". The predicate has two conjuncts and that wording named neither: the
|
|
99
|
+
profile does not declare `SQ`, and the embedded-attribute scanner cannot read the value (it reads
|
|
100
|
+
string carriers only, and decodes tiles in the file's own encoding). The two sets are incomparable,
|
|
101
|
+
not nested - a profile entry declaring `LO` over a carrier the sender wrote `OB` ships the identical
|
|
102
|
+
nested `(0010,0010)`, while one declaring `OB` over a carrier written `LO` is emptied. The
|
|
103
|
+
prose enumeration is deleted rather than reworded, and a measured matrix pins the surface instead:
|
|
104
|
+
declared VR against encoding, and against wire VR under Explicit VR only, because Implicit VR LE
|
|
105
|
+
writes no VR at all. No `src/` predicate changes: the behaviour is `PRE-EXISTING` and identical
|
|
106
|
+
before and after. The matrix also strengthens the `DICOM-PRIVATE-SQ-PARSE-VR` closure beside it,
|
|
107
|
+
proving it on five distinct inputs rather than on the one cell that opened it.
|
|
108
|
+
- c0fa362: The UID registry is now sourced from the normative PS3.6 Annex A, and every release from here on writes its own section into `CHANGELOG.md`.
|
|
109
|
+
|
|
110
|
+
**`Dictionary.uid` resolves the whole UID registry, not a subset of it.** The transfer syntax and well-known UID names used to be a hand-typed table inside the dictionary generator, merged with a SOP Class list, and nothing compared either one against the normative text. That table is gone. The generator now reads PS3.6 2026c Annex A directly (Table A-1 "UID Values" and Table A-2 "Well-known Frames of Reference") from the SHA-256-pinned DocBook already vendored here, and overlays it per field exactly as the element registry is overlaid: the normative source wins on name, type and retirement for a UID both carry, its own additions are taken, and an entry only the mirror carries is kept rather than dropped, because the standard retires UIDs rather than deleting them.
|
|
111
|
+
|
|
112
|
+
The registry goes from 268 entries to 494, and **every one of the 268 comes through byte identical**: no shipped name, type or retirement flag moved. What changed is coverage. Six transfer syntaxes the current edition defines, including the High-Throughput JPEG 2000 family and Deflated Image Frame Compression, previously resolved to `undefined`. Nothing was ever mis-read, because a transfer syntax is dispatched by UID value and never by name, but a caller asking the dictionary what it was holding got no answer.
|
|
113
|
+
|
|
114
|
+
**The two deliberate departures from the normative spelling are preserved, and they are now derived rather than typed.** Retirement stays a structured `retired` boolean instead of the trailing `(Retired)` every retired Annex A row carries at the end of its UID Name. Four transfer syntaxes keep the short form every DICOM toolkit prints instead of the longer `...: Default Transfer Syntax for ...` form, and that short form is cut from the normative name at build time rather than written out by hand, so it cannot drift away from it: a name that stops carrying the clause fails the build instead of silently keeping a stale string. Measured on this edition, those four are exactly the Annex A names that carry such a clause, so the departure is the complete set rather than a subset. Two rows that the standard retired and left unnamed in the same edition are excluded rather than shipped with an empty name, because an entry whose name is empty reads as a successful lookup and a caller's fallback stops firing.
|
|
115
|
+
|
|
116
|
+
**And the changelog is generated.** `.changeset/config.json` set `changelog: false` for this package's whole published history, so no release ever wrote a version heading and the file was maintained by hand under one `[Unreleased]` heading that nothing rolled over. Ten published versions shipped a changelog inside the tarball describing already-released work as unreleased. The fix is the flag rather than the prose: correcting the text by hand leaves the mechanism that produced it. The hand-written history is preserved verbatim, unsorted and unreworded, under a `Released before this file was generated` divider, and every release from here on prepends its own section above it.
|
|
117
|
+
|
|
118
|
+
Both halves are graded by tests rather than asserted. The UID registry is re-parsed from the pinned DocBook independently of the generator and compared against what the package actually exports, so the check cannot pass by agreeing with the generator. The changelog contract runs the real release tool against the real file in a throwaway repository, proves the archived history survives it byte for byte, and proves the region above the divider carries only what a release generates: a fabricated version section is refused wherever it is placed, which closes a gap a sibling package measured as open.
|
|
119
|
+
|
|
120
|
+
## Released before this file was generated
|
|
121
|
+
|
|
122
|
+
Every release section above this heading is written by
|
|
123
|
+
[Changesets](https://github.com/changesets/changesets) from the changesets in `.changeset/`, newest
|
|
124
|
+
release first. The release writes its own version heading, so nothing above this line is maintained
|
|
125
|
+
by hand, and a change is recorded here by adding a changeset rather than by editing this file.
|
|
126
|
+
|
|
127
|
+
Everything below this heading was maintained by hand, in the
|
|
128
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format, on the
|
|
129
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html) ladder this package still follows. Most
|
|
130
|
+
of it sat under a single `[Unreleased]` heading that no release ever rolled over, which is why ten
|
|
131
|
+
published versions of this package shipped a `CHANGELOG.md` describing already-released work as
|
|
132
|
+
unreleased, inside the tarball, for as long as they did. That heading is gone; its entries are not.
|
|
133
|
+
|
|
134
|
+
The text is left exactly as it was written rather than re-sorted into version sections. The file
|
|
135
|
+
never recorded which release each of those entries went out in, so assigning them now would be a
|
|
136
|
+
guess presented as a record, and this is the text that installed copies already carry on disk. Only
|
|
137
|
+
the `[Unreleased]` heading itself and the preamble above it were removed.
|
|
6
138
|
|
|
7
139
|
### Fixed
|
|
8
140
|
|
|
141
|
+
- **🩺 `DeidentifyReport`'s `contextPath` was documented as structural, and it is not: it can publish
|
|
142
|
+
four bytes of a value, silently** (`DICOM-ITEM-CROSSES-RESIDUALS`). A segment is `TAG[index]`, and
|
|
143
|
+
the tag half is whatever tag the descent walked, read straight off the wire, bound by neither a
|
|
144
|
+
shape test nor a closed table. `attributes[].tag` is bound - it is only populated for a tag Annex E
|
|
145
|
+
carries a row for - and that contrast is the finding. **It is not the only report identifier read
|
|
146
|
+
off the wire**: `removedPrivateTags` and `unauditableSequences[].tag` are as well. Those two were
|
|
147
|
+
already disclosed as such; this one was documented as structural. So a file whose
|
|
148
|
+
under-declared Value Length desynchronizes the reader onto four bytes sitting **inside** somebody's
|
|
149
|
+
value, where those four bytes are followed by `SQ`, gets that fabricated sequence descended and its
|
|
150
|
+
fabricated tag published in the `contextPath` of everything beneath it.
|
|
151
|
+
|
|
152
|
+
Measured on a synthetic `LO` carrier holding `"MRS BRAIN SMITHSON"` that under-declares by four:
|
|
153
|
+
the report reads `contextPath: ["53484E4F[0]"]`, which is `"HSON"` in wire order, recovered by
|
|
154
|
+
writing the two halves back with `writeUInt16LE`. Change the surname to `"DAVIDSON"` and the
|
|
155
|
+
published segment changes with it. **No warning is raised and every finding array on the report is
|
|
156
|
+
empty**, so a consumer following the old guidance had no signal at all. A conformant file's segment
|
|
157
|
+
is the tag the sender wrote, which is why the field is still published.
|
|
158
|
+
|
|
159
|
+
**🛑 REDACTING `contextPath` IS A LOGGING FIX AND NOT AN OBJECT FIX, AND A GRADED PASS REFUTED THE
|
|
160
|
+
FIRST DRAFT FOR SAYING OTHERWISE.** That draft claimed in six places that this field was "the only
|
|
161
|
+
trace of that header in the entire output". It is not: the de-identified `Dataset` still carries
|
|
162
|
+
the fabricated `(5348,4E4F)`, so `serializeDicom` writes its header back out in full, `"HSON"`
|
|
163
|
+
included, under `(0012,0062) Patient Identity Removed = YES` - and the re-emitted bytes track the
|
|
164
|
+
surname exactly as the log field does. That re-emission is the already-disclosed under-declared
|
|
165
|
+
carrier class, not this field's doing, and **neither one is a bound on the other.** Both rows are
|
|
166
|
+
now pinned.
|
|
167
|
+
|
|
168
|
+
**The claim was corrected and no guard was widened**, on the same footing as `removedPrivateTags`:
|
|
169
|
+
_where_ an attribute sat is the whole audit value of the field, and withholding it would destroy
|
|
170
|
+
that on every well-formed file in order to bound a malformed one. **`contextPath` is now named on
|
|
171
|
+
the report's list of fields that are not value-free** - a list whose numerals were deleted rather
|
|
172
|
+
than incremented, per this package's own rule - and corrected in the type, the tolerance table and
|
|
173
|
+
the troubleshooting guide, which all called it structural. **Treat it as PHI when the source is
|
|
174
|
+
untrusted.** `PRE-EXISTING` on every release that has shipped the field; no runtime behaviour
|
|
175
|
+
changes.
|
|
176
|
+
|
|
177
|
+
The shared PHI runner has swept this field from the start and could never have gone red on it: it
|
|
178
|
+
hunts a **verbatim** marker and a tag is a re-encoding, the same blind spot already documented for
|
|
179
|
+
`DicomParseError.snippet`. The pin is a purpose-built measurement with a name-bearing payload, a
|
|
180
|
+
mutation control and a conformant negative control.
|
|
181
|
+
|
|
182
|
+
- **The `DICOM_ITEM_CROSSES_SEQUENCE_END` disclosure no longer claims `contextPath` names "an item it
|
|
183
|
+
was never in".** That sentence asserts which of two byte-identical files you have, which is the one
|
|
184
|
+
thing this package has repeatedly measured the wire as not carrying: the partner file, built from
|
|
185
|
+
the opposite intention, is the same bytes with the element genuinely inside the item, where the
|
|
186
|
+
identical `contextPath` is right. It had already been narrowed once in the troubleshooting guide
|
|
187
|
+
and left standing everywhere else; this package's rule for a disclosure reworded twice is to delete
|
|
188
|
+
it, so it is deleted rather than given a third wording. **What replaces it is the pair of
|
|
189
|
+
measurements that were already there** - one shape puts the element in the item with a
|
|
190
|
+
`contextPath`, the other leaves it at the root with none - **and neither is labelled the mistake.**
|
|
191
|
+
No reading, no warning and no report field moved.
|
|
192
|
+
|
|
193
|
+
- **🩺 Tier-3 fatal messages are built from a frozen registry, and four of them were printing four
|
|
194
|
+
bytes of the document each** (`DICOM-FATAL-MESSAGE-REGISTRY`). Tier-2 warnings have been
|
|
195
|
+
registry-bound since the warning-message slice; Tier-3 messages were still assembled at the throw
|
|
196
|
+
site out of template literals. That reads as harmless and is not, because the messages that
|
|
197
|
+
interpolated most were the ones raised **when a length field is lying** - the condition that makes
|
|
198
|
+
a reader read bytes inside somebody's value as a Data Element header, so the tag and the length it
|
|
199
|
+
then prints are that value.
|
|
200
|
+
|
|
201
|
+
Measured on a synthetic `"MR BRAIN SMITHSON "` carried in an `ST` whose Value Length under-declares:
|
|
202
|
+
`Element 41524E49 declared length=1330858068` is `"RAIN"` then `"THSO"`, eight consecutive payload
|
|
203
|
+
bytes in two fields, each recoverable with one `readUInt16LE` / `readUInt32LE`. Also
|
|
204
|
+
`Unexpected tag 524D4220 inside sequence` and `Item length=1109414477`, both `"MR B"`, and
|
|
205
|
+
`Unexpected FFFE marker FFFEE00D (length=1109414477)`, the same four bytes again.
|
|
206
|
+
|
|
207
|
+
**The bound is the factory signature, exactly as it was for the three Tier-2 codes that paid for
|
|
208
|
+
this lesson before it.** The new `FatalTokens` type has **no tag field and no wire-length field**,
|
|
209
|
+
so there is no slot for one to travel through, and a shape check would not have helped: a tag has
|
|
210
|
+
only a shape, so a renderer for one cannot refuse a fabricated tag. `err.byteOffset` identifies the
|
|
211
|
+
element instead, and it is a count the parser kept. What a fatal message may still carry is named
|
|
212
|
+
one registry entry at a time: a VR checked against the closed 34-VR set, a residual byte count
|
|
213
|
+
bounded by the buffer being read, a library constant, PS3.6's own registry name for an unsupported
|
|
214
|
+
Transfer Syntax UID, and a zlib error code checked against zlib's nine-name table.
|
|
215
|
+
|
|
216
|
+
**⚠ SOME FATAL MESSAGES ARE REWORDED, SO A CONSUMER STRING-MATCHING ONE STOPS MATCHING. NO COUNT IS
|
|
217
|
+
GIVEN, AND THAT IS DELIBERATE** - the first draft of this notice said "six", a graded pass measured
|
|
218
|
+
nine, and this repo's rule is to **delete a count rather than increment it**. The set is derivable
|
|
219
|
+
in one command and can never go stale: diff `FATAL_MESSAGES` in `src/parser/fatals.ts` against the
|
|
220
|
+
template literals in `git show 0a8c6e3 -- src/parser/`. **`err.code` is unchanged on every path,
|
|
221
|
+
and which files throw is unchanged.** Narrow on the code, never on the prose.
|
|
222
|
+
|
|
223
|
+
- **🩺 The `{ strict: true }` snippet returned an unrelated element's bytes inside a defined-length
|
|
224
|
+
Sequence or Item.** `PRE-EXISTING`, found and disclosed by the `(0012,0063)` slice rather than
|
|
225
|
+
fixed, and closed here. `DicomParseError.snippet` is 16 raw source bytes cut at the diagnostic's own
|
|
226
|
+
`byteOffset`; that offset moves with the frame the element was read in, but the cut was always taken
|
|
227
|
+
from the whole file. So an escalation raised inside a defined-length Item cut the **file** at an
|
|
228
|
+
**item-relative** number and handed back whatever sat there - a diagnostic disclosing part of an
|
|
229
|
+
element the reader was never asked about, which in a de-identification library is a PHI surface
|
|
230
|
+
rather than a cosmetic offset bug. The parse context's buffer now follows the frame at all four
|
|
231
|
+
places this parser changes one; the deflate boundary already did, and the three sequence boundaries
|
|
232
|
+
did not.
|
|
233
|
+
|
|
234
|
+
**This does not make `snippet` safe to log, and nothing here should be read that way.** It is still
|
|
235
|
+
16 unredacted source bytes (the documented design), and making the frame honest makes them more
|
|
236
|
+
certainly the named element's own content, not less.
|
|
237
|
+
|
|
238
|
+
**Three residuals are named rather than closed, each with an asserted row in
|
|
239
|
+
`test/integration/fatal-diagnostic-surface.test.ts` so no artifact can read this entry as an
|
|
240
|
+
all-clear.** (1) The identical fabricated-header shape still reaches a **Tier-2** message: the same
|
|
241
|
+
desynchronized read lands on an odd group, so `DICOM_PRIVATE_TAG_NO_CREATOR` names the fabricated
|
|
242
|
+
tag (`4E495320`, `"IN S"`), through the shape-checking `renderTag` that cannot refuse one. (2)
|
|
243
|
+
`report.embeddedAttributes[].hidden` lists every tag in a run the embedded scanner found inside a
|
|
244
|
+
kept value, and a run needs only **one** actionable attribute to be reported, so a fabricated header
|
|
245
|
+
beside a real one is listed too: measured at `4D535449`, `"SMIT"` in wire order, beside the genuine
|
|
246
|
+
`00100020`. (3) Because of (1), **`ds.warnings[].message` is not unconditionally safe to log**, and
|
|
247
|
+
the docs that said so are corrected in this release rather than the guard being widened. All three
|
|
248
|
+
are the same product call as the one already filed for `report.removedPrivateTags`: on a well-formed
|
|
249
|
+
file these are the real tags of real attributes, and withholding them would destroy the field's
|
|
250
|
+
audit value to close a shape only a crafted file produces.
|
|
251
|
+
|
|
252
|
+
- **🩺 The surviving PHI leak on the `RetainSafePrivate` retain route was disclosed as the WRONG SET,
|
|
253
|
+
in six artifacts at once** (`DICOM-RETAIN-ROUTE-RESIDUALS`). `PRE-EXISTING` and unchanged in
|
|
254
|
+
behaviour: **no `src/` predicate moves in this release entry**, and every cell described below
|
|
255
|
+
leaks identically before and after it. What changed is the claim.
|
|
256
|
+
|
|
257
|
+
Every artifact that described what the private-`SQ` closure leaves open said it was "a private
|
|
258
|
+
carrier whose **profile entry declares a binary VR** (`OB`/`OW`/`UN`)". That is not the predicate,
|
|
259
|
+
and the true set is **neither narrower nor wider than it - the two are incomparable**. A profile
|
|
260
|
+
entry declaring `LO` over a carrier the sender wrote `OB` leaks; a profile entry declaring `OB`
|
|
261
|
+
over a carrier the sender wrote `LO` does not. **There are two conjuncts and the deleted wording
|
|
262
|
+
named neither.** `keepRetainedPrivate` asks one question - does the `Profile` declare this
|
|
263
|
+
attribute `SQ`? - and every other answer falls through to the ordinary keep path, where the only
|
|
264
|
+
thing between the value and de-identified output is the embedded-attribute scanner. That scanner
|
|
265
|
+
reads **string carriers only**, and it decodes candidate Data Element headers in the **file's own
|
|
266
|
+
encoding**. So the value survives exactly when the profile does not say `SQ` **and** the scanner
|
|
267
|
+
cannot read it: a string carrier holding Explicit-VR-shaped bytes inside an Implicit VR LE file is
|
|
268
|
+
in the set for the same reason a binary carrier is. In every such case the carrier reaches output
|
|
269
|
+
**byte-identical to the file's own value**, stamped `(0012,0062) Patient Identity Removed = YES`,
|
|
270
|
+
with `report.unauditableSequences` and `ds.warnings` both empty, because none of these files is
|
|
271
|
+
malformed: the Value Length is honest and the value is a well-formed `(FFFE,E000)` item stream.
|
|
272
|
+
|
|
273
|
+
**The enumeration is deleted rather than reworded a third time, and a measured matrix replaces it.**
|
|
274
|
+
`test/integration/deident-private-reservation.test.ts` now sweeps the profile's declared VR against
|
|
275
|
+
the encoding and, under Explicit VR only, the wire VR, with a name-bearing payload, and asserts the
|
|
276
|
+
**emptied** set exactly - so the leaking set is whatever is left and cannot go stale in prose. It
|
|
277
|
+
pins the leaking cells as kept verbatim and silent rather than inferring it from a substring
|
|
278
|
+
search. **There is deliberately no wire-VR axis under Implicit VR LE**, which writes no VR at all
|
|
279
|
+
(PS3.5 2026c §7.1.3); crossing one in measures a single file four times. The same sweep strengthens
|
|
280
|
+
the closure it sits beside: a profile-declared `SQ` is emptied to zero bytes and named on all four
|
|
281
|
+
Explicit wire VRs and on the VR-less Implicit encoding, where the case it replaced measured one
|
|
282
|
+
`OB`/Explicit cell. Non-vacuity is by fixture and by mutation: the payload carries a real name,
|
|
283
|
+
every Explicit cell really does present a distinct VR to the parser, and disabling the closure's
|
|
284
|
+
branch reds the matrix.
|
|
285
|
+
|
|
286
|
+
**Two remedies exist and both are product calls, so neither is taken here.** A content test on
|
|
287
|
+
binary carriers is already priced in `src/deident/embedded.ts` at 11 grid cells to 0 while emptying
|
|
288
|
+
all 5 conformant binary tiling controls (`DICOM-DEIDENT-OVER-REDACTION`, open). Refusing retention
|
|
289
|
+
wherever the profile's declared VR and the parse tree disagree needs no content test, but a `UN`
|
|
290
|
+
carrier whose profile declares `LO` is the most ordinary real-world private element there is - an
|
|
291
|
+
intermediary without the private dictionary rewrote the VR, PS3.5 2026c §6.2.2 - so it would drop
|
|
292
|
+
vendor values out of a large share of conformant files. Both are recorded as evidence, neither is
|
|
293
|
+
recommended, and the call is not made here.
|
|
294
|
+
|
|
295
|
+
- **🩺 A private carrier a `Profile` declared `SQ` was still written into de-identified output
|
|
296
|
+
verbatim whenever the parse tree resolved it to anything else** (`DICOM-PRIVATE-SQ-PARSE-VR`).
|
|
297
|
+
`PRE-EXISTING`, live through the published `0.0.10` and through the carve-out below, which closed
|
|
298
|
+
only what the parse tree happened to resolve. `keepRetainedPrivate` branched on `el.vr === "SQ"`,
|
|
299
|
+
and the parse tree and the profile disagree about the same bytes in **two ordinary, conformant
|
|
300
|
+
situations**: under **Implicit VR LE a private tag carries no VR on the wire** (PS3.5 2026c
|
|
301
|
+
§7.1.3), so `SQ` there is an inference the parser draws from a `Profile` _it_ was given and a
|
|
302
|
+
profile passed only to `deidentify()` leaves the element `UN`; and under **Explicit VR the wire's
|
|
303
|
+
VR wins in the parser**, so a sender who writes a profile-declared `SQ` attribute as `OB` or `UN`
|
|
304
|
+
yields that instead - with an honest defined length wrapping a well-formed `(FFFE,E000)` item
|
|
305
|
+
stream. Both were measured shipping a `(0010,0010)` Patient's Name into output stamped
|
|
306
|
+
`(0012,0062) Patient Identity Removed = YES` with an empty `report.unauditableSequences`.
|
|
307
|
+
|
|
308
|
+
**The remedy adds a second authority, not a content test.** The `Profile` that vouched for the
|
|
309
|
+
element also declares its VR, and that declaration is what `RetainSafePrivate` is trusting in the
|
|
310
|
+
first place. A retained private element the profile declares `SQ` whose parse tree carries no
|
|
311
|
+
items is now emptied through the same channel a parsed `SQ` with no items already used -
|
|
312
|
+
`DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` plus a `report.unauditableSequences` entry - **keeping the
|
|
313
|
+
VR the file actually carried** rather than re-typing the element to `SQ`. Nothing inspects the
|
|
314
|
+
value's bytes, `keepsPrivate` and the retention decision are unchanged, no parser file is touched,
|
|
315
|
+
and there is no new public surface. `UnauditableSequenceFinding`'s documentation now names both
|
|
316
|
+
producers, because the field is no longer only about a parsed `SQ`.
|
|
317
|
+
|
|
318
|
+
**🛑 One of the two shapes is silent on `ds.warnings`.** The Implicit VR LE file raises
|
|
319
|
+
`DICOM_IMPLICIT_VR_FOR_PRIVATE_TAG_WITHOUT_VR`; the Explicit VR `OB` file is fully conformant and
|
|
320
|
+
raises nothing at all. `report.unauditableSequences`, not `ds.warnings`, is this class's channel.
|
|
321
|
+
|
|
322
|
+
**The cost, stated here rather than discovered later.** A caller who passes a profile to
|
|
323
|
+
`deidentify()` but not to `parseDicom` now **loses** that vendor sequence's content instead of
|
|
324
|
+
shipping it unexamined. **Pass the same profile to `parseDicom`** and the sequence is walked, its
|
|
325
|
+
Table E.1-1 attributes de-identified and the rest retained.
|
|
326
|
+
|
|
327
|
+
**It reaches the CP-246 `UN` too, wherever a profile named it.** The test does not look at the
|
|
328
|
+
length **field**, so an undefined-length `UN` whose CP-246 descent this parser refused is emptied
|
|
329
|
+
when a `Profile` declares that private attribute `SQ`. The undefined-length `UN` residual is a
|
|
330
|
+
statement about elements **no profile named**, and survives only there. (Read that as stated: the
|
|
331
|
+
predicate does have a length conjunct, `el.length > 0`, and a refused CP-246 descent carries
|
|
332
|
+
`0xFFFFFFFF`, so it passes.)
|
|
333
|
+
|
|
334
|
+
**Two properties of the module are kept by conjuncts ahead of that test, and each is pinned by its
|
|
335
|
+
own test.** An element whose on-wire VR is not one of the 34 is still answered by the tag-free
|
|
336
|
+
`DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE` route. And a zero-length value is left alone, so
|
|
337
|
+
de-identifying an already de-identified object reports no second drop.
|
|
338
|
+
|
|
339
|
+
**🩺 The first of those is a PARTIAL bound, and it is disclosed rather than grown.** It keeps the
|
|
340
|
+
tag off the diagnostic only when the fabricated header's own VR bytes fall outside the 34. A
|
|
341
|
+
length under-declared upstream can resynchronize the reader onto four bytes that spell a genuine
|
|
342
|
+
private block, and if those bytes are followed by `OB` rather than something unrecognized, the
|
|
343
|
+
fabricated tag reaches `report.unauditableSequences` and the warning. There is nothing to key on:
|
|
344
|
+
a fabricated `OB` header and a genuine one are byte-identical. The direction is still a strict
|
|
345
|
+
improvement, because the previous behaviour kept that carrier **verbatim** and shipped the whole
|
|
346
|
+
nested name, and this one empties it. It joins `report.removedPrivateTags`, which can echo the
|
|
347
|
+
same four bytes from a fabricated odd-group header on both trees: **`DeidentifyReport` is not a
|
|
348
|
+
value-free surface and must not be treated as one.** Pinned as the second row of the
|
|
349
|
+
fabricated-header test.
|
|
350
|
+
|
|
351
|
+
**The report's value-bearing fields are now a LIST on `DeidentifyReport`, with no count anywhere.**
|
|
352
|
+
The count read one, then two, then three, and was wrong each time, because each correction bumped
|
|
353
|
+
the numeral instead of re-deriving the list. Re-deriving it found a fourth that no release had
|
|
354
|
+
disclosed: `embeddedAttributes[].hidden`, whose entries are composed from four bytes found
|
|
355
|
+
**inside** a value, so on any file that populates that field they are document content by
|
|
356
|
+
construction, and whose own documentation said "safe to log". That is `PRE-EXISTING` and
|
|
357
|
+
byte-identical on every release that has shipped the field; it is **disclosed** here and on the
|
|
358
|
+
type, and narrowing or capping it is its own change.
|
|
359
|
+
|
|
360
|
+
`DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE`'s message no longer reads "is VR=SQ". It is shared by both
|
|
361
|
+
producers, and the second one's premise is that the parse tree and the profile disagree about the
|
|
362
|
+
VR, so naming one stated a fact the file contradicts.
|
|
363
|
+
|
|
364
|
+
**Deliberately not closed**, and see the entry above for why this paragraph no longer enumerates
|
|
365
|
+
the surviving shape: everything the profile does **not** declare `SQ`, over a value that happens
|
|
366
|
+
to be a well-formed item stream, is still kept verbatim. Closing any of it needs a content test on
|
|
367
|
+
exactly the VRs arbitrary bytes are for. That is the same reasoning
|
|
368
|
+
`DICOM-BINARY-CARRIER-OVERDECLARE` was accepted on, but it is **a different route** - that
|
|
369
|
+
decision priced a measured over-declare swallow, and this is an honest length reached through
|
|
370
|
+
`RetainSafePrivate`. It remains open and undecided, and is pinned as a measured matrix rather than
|
|
371
|
+
described in prose.
|
|
372
|
+
|
|
373
|
+
Against base `src/` at `369abbe`, replaced wholesale: **5 of 1,081** tests run red over the full
|
|
374
|
+
suite; suite 1074 to 1080 passing plus the 1 `todo`. Two of the seven new or rewritten tests are
|
|
375
|
+
green on both trees **by design**, because they are controls: that a retained private element the
|
|
376
|
+
profile declares `LO` is still kept **verbatim**, and that a fabricated header keeps the tag-free
|
|
377
|
+
diagnostic (base already withheld it). Every conjunct's non-vacuity was proven by mutation rather
|
|
378
|
+
than asserted. `scripts/measure-sq-bound-grid.ts` was **not** re-run and holds no
|
|
379
|
+
private-`SQ` cell in any family; this remedy is reachable only from inside `keepRetainedPrivate`.
|
|
380
|
+
|
|
381
|
+
- **🩺 A private `SQ` a `Profile` vouched for under `RetainSafePrivate` was written into
|
|
382
|
+
de-identified output verbatim, so nothing inside it was ever examined for PHI**
|
|
383
|
+
(`DICOM-PRIVATE-SQ-CARVE-OUT`). `PRE-EXISTING`, live through the published `0.0.10`, found by
|
|
384
|
+
`#66`'s `conformance-refuter`. `keepsPrivate` decides retention before the descent, and that part
|
|
385
|
+
was never wrong; what was wrong is that a "yes, retain" routed the element to `keepOrEmpty`, **the
|
|
386
|
+
only path in the module that writes a source value into output unchanged**. So a vendor sequence
|
|
387
|
+
the profile named was blitted whole: Table E.1-1 attributes the vendor encoded in its items, UIDs
|
|
388
|
+
inside it, and any private element the file's own length fields pulled into it all survived, with
|
|
389
|
+
`report.removedPrivateTags` reading `[]` and the object stamped `(0012,0062) Patient Identity
|
|
390
|
+
Removed = YES`.
|
|
391
|
+
|
|
392
|
+
**The sharper half needs no malformed file at all.** On a fully conformant file, with `ds.warnings`
|
|
393
|
+
empty, a `(0010,0010)` Patient's Name written inside a vouched-for private `SQ` was copied straight
|
|
394
|
+
through. That case is now pinned with a name-bearing payload and non-vacuity assertions, alongside
|
|
395
|
+
the absorb shape the original residual held.
|
|
396
|
+
|
|
397
|
+
**What bounds the profile's licence is the spec, not a judgement call here.** PS3.15 2026c §E.3.10
|
|
398
|
+
retains "Private Attributes that are known by the de-identifier to be safe from identity leakage".
|
|
399
|
+
A profile entry is knowledge about **one Private Attribute**. It is not knowledge about a Data Set
|
|
400
|
+
nested inside that attribute's value, and PS3.5 2026c §7.5.1 makes an Item Value exactly that ("a
|
|
401
|
+
DICOM Data Set composed of Data Elements"). PS3.15 2026c §E.1.1 then obliges an implementation
|
|
402
|
+
claiming the Basic Profile to protect Table E.1-1 attributes "whether contained in the top level
|
|
403
|
+
Data Set or embedded in an Item of a Sequence of Items", and a private carrier is one of those
|
|
404
|
+
Sequences. A vendor cannot vouch for `(0010,0010)`; it is not a Private Attribute.
|
|
405
|
+
|
|
406
|
+
**The remedy widens no guard.** `keepsPrivate` still decides retention and the profile is still the
|
|
407
|
+
only vouching authority. A vouched-for private `SQ` is simply routed into the two branches every
|
|
408
|
+
other `SQ` in the module already takes: `descendSequence` when its items exist,
|
|
409
|
+
`emptyUnauditableSequence` (with `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` and
|
|
410
|
+
`report.unauditableSequences`) when the parser never materialized them. A non-`SQ` private element
|
|
411
|
+
is untouched. **No new public surface**: no Tier-2 code, no report field, no snapshot change.
|
|
412
|
+
|
|
413
|
+
**The price, measured and pinned.** Walking the carrier means PS3.5 2026c §7.8.1's per-Data-Set
|
|
414
|
+
reservation scope applies inside it, and Items do not inherit the enclosing Data Set's
|
|
415
|
+
reservations. A vendor who nests a private element in a private `SQ` and reserves its block only at
|
|
416
|
+
the **root** loses it, named on `report.removedPrivateTags` rather than dropped silently. A vendor
|
|
417
|
+
who writes the Private Creator **inside the Item**, as §7.8.1 requires, keeps it. Both rows are
|
|
418
|
+
pinned as a pair.
|
|
419
|
+
|
|
420
|
+
**No reading changes, and the grid cannot see this remedy.** `scripts/measure-sq-bound-grid.ts`
|
|
421
|
+
builds its `priv|` family's private data element as `LO` behind a public `(0008,1115)` carrier, so
|
|
422
|
+
it holds **no private-`SQ` cell**. Against base `495c9fc` over **83,037 cells**: 0 cells differing
|
|
423
|
+
in any parse respect, 0 whose reading differs, 0 changed, 0 PHI regressions, 0 de-identified
|
|
424
|
+
outputs lost a marker, and every `priv:` counter identical. Read that as evidence of blast radius,
|
|
425
|
+
never as a safety measurement. The regression net is the unit tests: against base `src/` at
|
|
426
|
+
`495c9fc`, **5 of the 57** tests across
|
|
427
|
+
`test/integration/deident-private-reservation.test.ts` and
|
|
428
|
+
`test/integration/deident-unauditable-sequence.test.ts` run red: the **4** that assert this
|
|
429
|
+
closure, plus the control row of the `DICOM-PRIVATE-SQ-PARSE-VR` residual, which asserts the same
|
|
430
|
+
closure on a file the parser did resolve. The residual's own leaking row is green on base by
|
|
431
|
+
design. Full suite 1071 to 1074 passing. The two residuals that asserted the leaking behaviour
|
|
432
|
+
were rewritten to assert the closure, which is what those pins existed for.
|
|
433
|
+
|
|
434
|
+
**The bound below is CLOSED by the entry above (`DICOM-PRIVATE-SQ-PARSE-VR`), which ships in this
|
|
435
|
+
same release; it is kept because it is why that remedy needed a second authority.** The branch
|
|
436
|
+
keyed on the PARSED VR, not on the VR the profile declares. Under Implicit VR LE a private tag carries no
|
|
437
|
+
VR on the wire, so `SQ` there is an inference the parser draws from a `Profile` it was given. Pass
|
|
438
|
+
the profile to `parseDicom` and the element arrives as an `SQ` with items and is walked; pass it
|
|
439
|
+
only to `deidentify()` and the identical bytes arrive as `UN` with no items, take the non-`SQ`
|
|
440
|
+
branch, and are kept verbatim as before, under `(0012,0062) = YES`, with
|
|
441
|
+
`DICOM_IMPLICIT_VR_FOR_PRIVATE_TAG_WITHOUT_VR` the only signal. That is
|
|
442
|
+
`DICOM-PRIVATE-SQ-PARSE-VR`, `PRE-EXISTING`, its own item and pinned as a residual test. It is
|
|
443
|
+
**not** the undefined-length `UN` residual below: that carrier's length is defined, so CP-246
|
|
444
|
+
never runs. **Pass your profile to `parseDicom` as well as to `deidentify()`.** The
|
|
445
|
+
`creatorsInScope` note claiming `RetainSafePrivate` "behaves identically whether the profile
|
|
446
|
+
arrived at parse or at de-identification" is retracted for the same reason; it was true only while
|
|
447
|
+
every retained private element was kept verbatim.
|
|
448
|
+
|
|
449
|
+
**Still open at the time this entry was written, and `DICOM-PRIVATE-SQ-PARSE-VR` has since been
|
|
450
|
+
closed by the entry above:** the undefined-length `UN`
|
|
451
|
+
whose CP-246 descent was refused
|
|
452
|
+
(it keeps `vr === "UN"`, so the rule cannot reach it without emptying every unknown-VR element in
|
|
453
|
+
every file); and the 11 leaf-carrier cells of `DICOM-BINARY-CARRIER-OVERDECLARE` (11 to 11 on the
|
|
454
|
+
grid, with the conformant tiling-control counter unmoved at 7 to 7).
|
|
455
|
+
|
|
456
|
+
- **🩺 The `(0012,0063)` De-identification Method this library writes for itself exceeded the 64-character
|
|
457
|
+
maximum PS3.5 gives an `LO` Value, on every file it ever de-identified**
|
|
458
|
+
(`DICOM-LO-LENGTH-AND-SILENT-REPLACE`). `PRE-EXISTING`; measured through the built package on
|
|
459
|
+
`da1f209`: **76** characters with no options, **130** with `RetainUIDs + RetainSafePrivate +
|
|
460
|
+
RetainDeviceIdentity`, **272** with all nine, and **all 512 option subsets over the maximum**. PS3.5
|
|
461
|
+
2026c **Table 6.2-1**, `LO` row, is "64 chars maximum" and that row describes a **Value**;
|
|
462
|
+
`(0012,0063)` is `1-n`, so the bound falls per value. Reading it as a bound on the Value Field is the
|
|
463
|
+
same misreading that left the fixed-point hole above (where §6.4, a clause about the **encoder**, was
|
|
464
|
+
read as a bound on a **comparison**), and it matters because the attribute concerned is the one a
|
|
465
|
+
receiver reads to decide whether an object was de-identified at all.
|
|
466
|
+
|
|
467
|
+
The default method text is now **multi-valued**: one Value naming the Profile
|
|
468
|
+
(`@cosyte/dicom Basic Application Level Confidentiality Profile`, **61** characters) and one Value per
|
|
469
|
+
active Annex E Option, joined with `\`. No option name exceeds **28** characters, so no subset can
|
|
470
|
+
breach the maximum - proved by sweeping **all 512 subsets / 2,816 value cells** rather than by
|
|
471
|
+
argument: **0 over the maximum**, against **512 of 512** on the commit before. The Value Field is
|
|
472
|
+
still 111 and 247 bytes for those two option sets, which is legal and is asserted alongside the
|
|
473
|
+
per-value figure so a remedy that merely shortened the text could not pass. Options are emitted in
|
|
474
|
+
`DEIDENTIFY_OPTIONS` order rather than the caller's, so the same option set always writes the same
|
|
475
|
+
bytes. The fixed point is unaffected and re-measured over six real wire round trips: **62** bytes flat
|
|
476
|
+
by default and **248** flat with all nine.
|
|
477
|
+
|
|
478
|
+
**Your own `deidentificationMethod` is not bounded for you** and neither is a prior value the source
|
|
479
|
+
file wrote: splitting or truncating either would invent a de-identification record nobody made. Both
|
|
480
|
+
are pinned as residual tests rather than left to be rediscovered. Split your string on `\` yourself if
|
|
481
|
+
a strict receiver is in your path.
|
|
482
|
+
|
|
483
|
+
- **🩺 A `(0012,0063)` a file encoded under a VR other than `LO` was replaced silently**, and now raises
|
|
484
|
+
the new Tier-2 code **`DICOM_DEIDENT_METHOD_NOT_LO`** on `report.warnings`. The replacement itself is
|
|
485
|
+
deliberate and unchanged: the provenance chain is built by concatenating `LO` values with the `5CH`
|
|
486
|
+
delimiter, which is a text operation and not defined over the arbitrary octets an `OB` or `UN` value
|
|
487
|
+
holds, so guessing an encoding for them was refused. What was wrong was that the sender's earlier
|
|
488
|
+
de-identification record left the object with `report.warnings` **empty**, under
|
|
489
|
+
`(0012,0062) = YES`. It is a separate code from `DICOM_DEIDENT_METHOD_NOT_ADDED` because the causes
|
|
490
|
+
are unrelated - the chain outgrew the VR, or the bytes were never in that VR at all - and a consumer
|
|
491
|
+
that has to tell them apart now can. A prior value that is empty or padding only raises neither,
|
|
492
|
+
because nothing was lost. The code carries **no value, no length and no VR**: two bytes read out of a
|
|
493
|
+
fabricated header are document content, which is how four letters of a surname once reached
|
|
494
|
+
`DICOM_NONZERO_RESERVED_BYTES`.
|
|
495
|
+
|
|
496
|
+
- **🩺 `{ strict: true }` renders source bytes the warning it replaces deliberately withholds.**
|
|
497
|
+
`PRE-EXISTING`, **disclosed rather than changed**: the escalation raises a `DicomParseError` whose
|
|
498
|
+
`snippet` is 16 raw source bytes read from the file at the warning's own `byteOffset` (D-10), while
|
|
499
|
+
`err.message` stays the frozen registry string. Measured on
|
|
500
|
+
`DICOM_DUPLICATE_FILE_META_ELEMENT`, whose group is never nested and whose offset is therefore
|
|
501
|
+
unambiguously the dropped copy's header: a `(0002,0016)` Source AE Title of `AE-SMITHSON` comes
|
|
502
|
+
back as `02 00 16 00 41 45 0c 00 41 45 2d 53 4d 49 54 48`, five letters of the surname. **Nothing
|
|
503
|
+
more general is claimed, and two graded passes are why**: which element a snippet's bytes belong to
|
|
504
|
+
is **not contracted** anywhere in this package: that offset's frame follows where the element was
|
|
505
|
+
read (file-absolute at the root, relative to the enclosing slice inside a defined-length Sequence or
|
|
506
|
+
Item, into the inflated stream under Deflated Explicit VR LE), while the snippet is cut from
|
|
507
|
+
whichever buffer the parse is holding, so the two can disagree. Do not reason from a code's message to what its snippet holds;
|
|
508
|
+
measure it, and treat every snippet as document content. Redacting `snippet` is a decision about every Tier-3
|
|
509
|
+
fatal in the library, not a rider on a File Meta disclosure, so the **claim** was corrected instead:
|
|
510
|
+
the test block asserting "the diagnostic is not itself a PHI surface" read `warning.message` and
|
|
511
|
+
nothing else, and is now titled for what it proves, with the strict path pinned beside it as the
|
|
512
|
+
residual it is. `ParseOptions.strict` and the "Keeping PHI out of logs" guide now say so where the
|
|
513
|
+
decision is made. Review the two paths separately.
|
|
514
|
+
|
|
9
515
|
- **🩺 Repeated de-identification was not a fixed point: `(0012,0063)` grew by the whole method
|
|
10
516
|
string on every pass, for any `deidentificationMethod` ending in a SPACE or a NUL**
|
|
11
517
|
(`DICOM-DEIDENT-NOT-A-FIXED-POINT`). **`INTRODUCED` by the entry below and never published** - it
|
|
@@ -742,7 +1248,10 @@ Removed = YES` with `report.warnings` empty **and** `report.retained` `[]` - an
|
|
|
742
1248
|
followed by a root `(0010,0020)` Patient ID that is 18 bytes on the wire (an 8-byte Explicit VR
|
|
743
1249
|
short-form header plus a 9-character value padded to 10). The Patient ID is **absent from the root**
|
|
744
1250
|
and present instead as an attribute of the item; the `DeidentifyReport` names it with a
|
|
745
|
-
`contextPath` pointing at
|
|
1251
|
+
`contextPath` pointing at that sequence item. (This entry said "pointing at a sequence item it was
|
|
1252
|
+
never in" until `DICOM-ITEM-CROSSES-RESIDUALS` deleted that wording: it asserts which of two
|
|
1253
|
+
byte-identical files you have. See the entry at the top of this file.) It is swallowed once and
|
|
1254
|
+
relocated, not
|
|
746
1255
|
read twice: the parser resumes where the descent actually ended. The parse was silent about all of
|
|
747
1256
|
it, including under `{ strict: true }`, and that silence is what is fixed. The mis-structure itself
|
|
748
1257
|
is **not repaired here** and is pinned as a residual by a test.
|