@cosyte/dicom 0.0.5 → 0.0.7
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 +438 -31
- package/README.md +12 -7
- package/dist/index.cjs +548 -155
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +307 -12
- package/dist/index.d.ts +307 -12
- package/dist/index.mjs +549 -156
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,44 +4,360 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
-
###
|
|
7
|
+
### Security
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
(
|
|
11
|
-
`
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
repeating the heading below it.
|
|
9
|
+
- **An element whose on-wire VR is not one of the 34 PS3.5 §6.2 defines was kept verbatim by
|
|
10
|
+
`deidentify()`, carrying a source `(0010,0020)` Patient ID into de-identified output next to the
|
|
11
|
+
`(0012,0062) PatientIdentityRemoved = YES` this library writes** (`DICOM-CARRIER-LEAF-LEAKS`,
|
|
12
|
+
mechanism 2; pre-existing, live on the published `0.0.6`, identical on both trees). Re-derived on
|
|
13
|
+
`scripts/measure-sq-bound-grid.ts` at `35adc2d` before anything changed: **19 leaking cells → 11**.
|
|
14
|
+
Negative control first - the grid run against `d1031f5`'s `src/` restored **1,174** leaking cells
|
|
15
|
+
and reproduced `#54`'s published 2,448-cell cost - plus a second control confirming the harness
|
|
16
|
+
fails outright when pointed at another package.
|
|
18
17
|
|
|
19
|
-
**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
`200 image/png`, 10513 and 10455 bytes, before this landed, rather than taken from the `live` flag
|
|
27
|
-
in `assets/published-urls.json`, whose own `$fields.status` note says to read `live` for what it
|
|
28
|
-
is: a declaration made on evidence from another repo, never a fact checked there.
|
|
18
|
+
**This is the _under_-declare, not the swallow the entry below closed.** An under-declared Value
|
|
19
|
+
Length desynchronizes the reader: it finishes the short value early, reads the leftover bytes of
|
|
20
|
+
the value that was actually encoded as the next Data Element header, and consumes the element that
|
|
21
|
+
genuinely followed as that fabricated element's "value". Measured: a 14-byte carrier
|
|
22
|
+
under-declaring by 6 yields tag `(4156,554C)` with the VR bytes `"E "`, holding the Patient ID in
|
|
23
|
+
full, **silently** and with a clean report. It reaches **string** carriers as readily as binary
|
|
24
|
+
ones, so it is not bounded by the binary-VR story that frames the residual below.
|
|
29
25
|
|
|
30
|
-
|
|
26
|
+
**The remedy reads a field the parser already recorded, not the bytes.** PS3.5 2026c §6.2: "All
|
|
27
|
+
new VRs defined in future versions of DICOM shall be of the same Data Element Structure as defined
|
|
28
|
+
in [§7.1.2] with reserved bytes after the VR and a 32-bit unsigned integer VL". An unrecognized VR
|
|
29
|
+
is long-form by that rule while this parser reads it short-form (Postel's Law on the read path), so
|
|
30
|
+
its bytes are not a Value Field this library decoded under any VR and PS3.15 2026c §E.1.1's
|
|
31
|
+
obligation cannot be discharged inside them. Read from the vendored SHA-pinned documents, pins
|
|
32
|
+
re-derived, each sentence unique in its document. Such an element is **emptied**,
|
|
33
|
+
`report.undefinedVrElements` names the **byte offset** and the byte length dropped, and the new
|
|
34
|
+
`DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE` is raised. **The finding names no tag, uniquely among
|
|
35
|
+
the report's findings**: the header _may_ have been fabricated out of the middle of some element's
|
|
36
|
+
value, in which case its four tag bytes are document content - on a synthetic `ST` carrier holding
|
|
37
|
+
`"MR BRAIN SMITHSON"` the tag renders as `48544F53`, four letters of the surname. An honestly
|
|
38
|
+
written unrecognized VR raises the same code with an ordinary tag, and the two are
|
|
39
|
+
indistinguishable here, so the tag is withheld either way.
|
|
40
|
+
`renderTag` shape-checks a tag and cannot refuse one, so the withholding happens at the call site.
|
|
41
|
+
An undefined-VR carrier whose bytes happened to tile was reported in `report.embeddedAttributes`
|
|
42
|
+
before and now reports here instead. The test is a set-membership check on
|
|
43
|
+
`el.vr`: O(1), **no scan**. The record is **capped at 64 across the run** and the emptying never is
|
|
44
|
+
- an undefined-VR element costs an attacker only an 8-byte header, so 1 MiB is 131,072 of them.
|
|
31
45
|
|
|
32
|
-
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
**No carve-out, and structurally so:** `keepOrEmpty` is the only path that keeps a source value
|
|
47
|
+
verbatim and the test sits at its top, so `RetainSafePrivate` does not exempt an element - pinned
|
|
48
|
+
by a test. **`UN` is untouched**, being one of the 34, and the rule cannot fire under Implicit VR
|
|
49
|
+
LE at all, where the VR comes from the dictionary; 0 Implicit VR LE cells moved.
|
|
50
|
+
|
|
51
|
+
**Cost, published rather than described:** 23 grid cells lose a marker from de-identified output,
|
|
52
|
+
**15 of which were not leaking**. On a file conformant to PS3.5 2026c the cost is zero.
|
|
53
|
+
|
|
54
|
+
**The root cause is a parse behaviour this slice does not touch, and it is disclosed rather than
|
|
55
|
+
implied fixed.** §6.2's note says informatively that an unrecognized VR may be handled "by applying
|
|
56
|
+
the rules stated in [§7.1.2]" - i.e. read long-form, value copied unchanged. This parser reads it
|
|
57
|
+
**short-form**. Emptying at the de-identify boundary is therefore compensation, not conformance.
|
|
58
|
+
`PRE-EXISTING`, its own slice. No claim is made here about what a §6.2-conformant future-VR file
|
|
59
|
+
does on either tree.
|
|
60
|
+
|
|
61
|
+
**Still leaking, measured, and now priced:** the 11 remaining cells are the over-declare swallow
|
|
62
|
+
into a **binary** carrier at `delta=18`. The one candidate remedy was built and measured - it
|
|
63
|
+
takes 11 to 0 and empties **all 5** conformant binary tiling controls, i.e. deletes a legal
|
|
64
|
+
`OB`/`UN` value because 8 of its bytes read as a zero-length `(0010,0020)`. That is a product
|
|
65
|
+
decision, not a bug fix, and it has its own item.
|
|
66
|
+
|
|
67
|
+
- **A sequence the parser could not open was passed through `deidentify()` as raw bytes, so a
|
|
68
|
+
`(0010,0020)` Patient ID inside it reached de-identified output next to the
|
|
69
|
+
`(0012,0062) PatientIdentityRemoved = YES` this library writes** (`DICOM-DEIDENT-RAWBYTES-PASSTHROUGH`,
|
|
70
|
+
pre-existing and live on the published `0.0.6`). It is the larger half of the 2,127 leaking grid
|
|
71
|
+
cells the previous entry decomposed: **1,155 of the 6,348 cells that parse**, all Implicit VR LE,
|
|
72
|
+
all carrying exactly `["DICOM_SQ_NOT_DESCENDED"]`. Re-measured before anything was changed, with a
|
|
73
|
+
negative control (reverting the previous entry's call site restored exactly 2,127) so the harness
|
|
74
|
+
is known live rather than assumed.
|
|
75
|
+
|
|
76
|
+
When an element resolves to `SQ` from PS3.6 under Implicit VR LE and its defined-length value is
|
|
77
|
+
not a valid `(FFFE,E000)` item stream, the parser refuses the descent and keeps the declared span
|
|
78
|
+
on `Element.rawBytes` with `Element.items` undefined. That is right for a parser. It was not safe
|
|
79
|
+
for `deidentify()`, which recurses only into a sequence whose items exist: for a carrier such as
|
|
80
|
+
`(0008,1115)` with no Table E.1-1 row, the action table resolved "keep" and the span was re-emitted
|
|
81
|
+
verbatim, with the report naming nothing.
|
|
82
|
+
|
|
83
|
+
**The remedy reads the parser's recorded refusal, not the bytes.** PS3.5 2026c §7.5.1 "Item
|
|
84
|
+
Encoding Rules" states "Each Item Value shall contain a DICOM Data Set composed of Data Elements",
|
|
85
|
+
so an `SQ` value is never legitimately opaque the way an `OB` value is; PS3.15 2026c §E.1.1
|
|
86
|
+
"De-identifier" obliges an implementation claiming the Basic Profile to "protect or retain all
|
|
87
|
+
instances of the Attributes listed in [Table E.1-1], whether contained in the top level Data Set or
|
|
88
|
+
embedded in an Item of a Sequence of Items". Unable to enumerate the items, the obligation falls on
|
|
89
|
+
the carrier, which is the escalation §E.1.1 itself makes for a SOP Instance UID inside a Sequence.
|
|
90
|
+
Both sentences read from the vendored SHA-pinned documents, pins re-derived, each unique in its
|
|
91
|
+
document. A **standard** `SQ` with no items is therefore **emptied**,
|
|
92
|
+
`report.unauditableSequences` names the tag and the byte length dropped, and the new
|
|
93
|
+
`DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` is raised. That record is **capped at 64 across the run**
|
|
94
|
+
(element count is attacker-chosen too, and `#48` bound every other consumer-controlled
|
|
95
|
+
diagnostic); the **emptying is never capped**, so an array exactly 64 long means "at least 64". A
|
|
96
|
+
listed sequence kept by a Retain Option takes the same branch and its audit line now reads
|
|
97
|
+
`emptied` rather than `kept` - previously it produced an empty sequence anyway while claiming the
|
|
98
|
+
attribute was retained.
|
|
99
|
+
|
|
100
|
+
**It costs content, and the number is published rather than described:** 2,448 grid cells lose a
|
|
101
|
+
value from their de-identified output, of which **1,293 were not leaking anything** and pay purely
|
|
102
|
+
for the guarantee. The accompanying `DICOM_SQ_NOT_DESCENDED` says why the parse refused, which is
|
|
103
|
+
_usually_ a sender defect - but **not always**: a conformant file nested deeper than this
|
|
104
|
+
library's own `NESTING_DEPTH_LIMIT` of 64 (ours; PS3.5 sets no nesting bound) is refused the same
|
|
105
|
+
way and loses that sequence too. Because the trigger is `items === undefined` rather than a
|
|
106
|
+
content test there is no scan, so no cost follows an attacker-chosen value length; the cost tests
|
|
107
|
+
use a carrier where **every even offset** is a tiling candidate and assert that property rather
|
|
108
|
+
than describing it, as a **forward tripwire** - this path never traverses those bytes.
|
|
109
|
+
|
|
110
|
+
**New surface:** `UnauditableSequenceFinding`, `DeidentifyReport.unauditableSequences`, and
|
|
111
|
+
`DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` (**27 Tier-2 codes, was 26**; snapshot updated
|
|
112
|
+
deliberately). `DICOM_SQ_NOT_DESCENDED`'s message changed, since it said nested content was
|
|
113
|
+
invisible to de-identification.
|
|
114
|
+
|
|
115
|
+
**Not covered, and one of these is measured here for the first time.** An undefined-length `UN`
|
|
116
|
+
whose CP-246 descent was refused keeps `vr === "UN"`, and the rule cannot be extended to it because
|
|
117
|
+
every ordinary `UN` element also has no items - measured on a hand-built file, the identifier still
|
|
118
|
+
reaches output with no report entry. A **private** `SQ` under `RetainSafePrivate` plus a `Profile`
|
|
119
|
+
is still kept verbatim, deliberately - `keepsPrivate` decides first, so it never reaches this rule,
|
|
120
|
+
and measured on a synthetic vendor block the identifier does still reach output. Both carve-outs
|
|
121
|
+
are pinned by tests. And the **binary-VR carrier** residual the previous entry
|
|
122
|
+
disclosed but could not measure now has a number: the grid gained an over-declaring **leaf**
|
|
123
|
+
carrier dimension and finds **19 leaking cells, identical on both trees** - 11 at `delta=18` (the
|
|
124
|
+
swallow into `OB`/`OW`/`US`/`UN`, silent, with `LO`/`ST` controls on the identical fixture at 0)
|
|
125
|
+
and 8 at `delta=-6` (an _under_-declare, where leftover bytes are read as a Data Element header and
|
|
126
|
+
the identifier lands in a manufactured element with an unknown on-wire VR; this one hits string
|
|
127
|
+
carriers too and was not previously disclosed anywhere).
|
|
128
|
+
|
|
129
|
+
**Measured, on the now 76,599-cell grid against `d1031f5`:** sequence-sweep leaks 1,155 → **0**;
|
|
130
|
+
**0 cells differing in any parse respect**, now a printed count rather than an inference from
|
|
131
|
+
`changed`, because no parser file is touched; 0 new fatals, 0 lost parse values, 0 reports that
|
|
132
|
+
lose an attribute, 0 new strict fatals.
|
|
133
|
+
|
|
134
|
+
- **An element that over-declared its own Value Length swallowed the next element into its value,
|
|
135
|
+
where the PS3.15 Annex E action table cannot see it, and `deidentify()` wrote the identifier into
|
|
136
|
+
its output with a clean report** (`DICOM-OVERDECLARE-SWALLOWS-INTO-VALUE`, pre-existing and live
|
|
137
|
+
on the published `0.0.6`). **No sequence is involved.** PS3.5 2026c defines Value Length as "The
|
|
138
|
+
length of the Value Field of the Data Element" - that element's own value. A sender that writes a
|
|
139
|
+
larger number produces a file that is not detectably broken: the reader consumes the declared
|
|
140
|
+
count, the bytes it over-consumes are the following element header and all, every subsequent
|
|
141
|
+
offset still lines up, and nothing on the wire says which length field lied. After the swallow
|
|
142
|
+
there is no `(0010,0020)` in the object for Table E.1-1 to match, only bytes inside some other
|
|
143
|
+
attribute's value, so de-identification passed over PHI it never recognised as an attribute.
|
|
144
|
+
Measured on the committed grid at `244a372`: **877 of the 6,348 cells that parse** wrote a source
|
|
145
|
+
value into de-identified output this way, on Explicit VR LE and Explicit VR BE, **871 of them with
|
|
146
|
+
no warning on either channel and no throw under `{ strict: true }`**.
|
|
147
|
+
|
|
148
|
+
**`parseDicom` is unchanged, deliberately.** The two readings - "the length is right and the value
|
|
149
|
+
is odd" and "the length lied and the next element was absorbed" - produce identical bytes, so a
|
|
150
|
+
parser cannot choose between them and this release does not pretend to. `deidentify()` answers a
|
|
151
|
+
strictly narrower question, and only about values it was about to **keep**: does this value's tail
|
|
152
|
+
decode, in the file's own transfer syntax, as a complete run of Data Elements ending exactly at the
|
|
153
|
+
end of the value, at least one of which this run would have acted on, and containing a byte the
|
|
154
|
+
carrier's VR cannot legally hold (PS3.5 §6.1.3 and Table 6.1-1 permit five C0 control characters in
|
|
155
|
+
DICOM text; **Table 6.2-1 decides which of the five each VR may hold**, in three tiers)? All three have to hold. If they do, the value is **emptied**
|
|
156
|
+
rather than kept, a new `DICOM_DEIDENT_EMBEDDED_ATTRIBUTE_REMOVED` warning appears on
|
|
157
|
+
`report.warnings`, and the new `report.embeddedAttributes` names the carrier, its VR and the tags
|
|
158
|
+
that were hiding inside it. PS3.15 §E.1 requires a de-identifier to "protect or retain all
|
|
159
|
+
instances of the Attributes listed in [Table E.1-1]"; §E.3.5 is the standard's own statement that
|
|
160
|
+
identifying information embedded **inside a string attribute's value** is in scope for removal.
|
|
161
|
+
Emptying is the fail-safe direction; keeping is not.
|
|
162
|
+
|
|
163
|
+
**What this does not cover, stated rather than implied.** Carriers are **string VRs only**, and the
|
|
164
|
+
residual is live rather than theoretical: the identical over-declare into an `OB`, `OW`, `UN` or
|
|
165
|
+
`US` carrier still writes the identifier into de-identified output, with **no warning and no report
|
|
166
|
+
entry**, exactly as before. Arbitrary bytes are what those VRs are for, so no content test can tell
|
|
167
|
+
a swallow from a legitimate value there, and scanning them would trade a measured guarantee for a
|
|
168
|
+
coin-flip that deletes real pixel and lookup-table data. The committed grid never puts a binary VR
|
|
169
|
+
in the over-declaring role, so this residual is **disclosed but unmeasured** and needs its own
|
|
170
|
+
item. (It was measured later in this same release cycle, by the entry above: **19 grid cells**,
|
|
171
|
+
once the grid gained an over-declaring leaf carrier.) A sequence the
|
|
172
|
+
parser declined to descend and kept as opaque bytes is a **different** defect with a different
|
|
173
|
+
remedy and is untouched here (1,155 grid cells, all Implicit VR LE, all carrying
|
|
174
|
+
`DICOM_SQ_NOT_DESCENDED`). And nothing here recovers the correct reading of a malformed file: the
|
|
175
|
+
carrier's parsed value still holds the absorbed bytes, and `ds.get("00100020")` still returns
|
|
176
|
+
nothing. This closes the leak, not the mis-read.
|
|
177
|
+
|
|
178
|
+
**Measured, on the same 76,293-cell grid, against `244a372`:** 2,127 leaking cells → 1,155; every
|
|
179
|
+
Explicit VR cell → **0**; and **0 cells whose parse differs in any respect** - reading, both
|
|
180
|
+
warning channels, `{ strict: true }`, and which values survive anywhere in the object are
|
|
181
|
+
byte-identical, because no parser file is touched. 0 new fatals, 0 lost values, 0 reports that lose
|
|
182
|
+
an attribute. `scripts/measure-sq-bound-grid.ts`, previously only on an unmerged branch, is
|
|
183
|
+
committed so every figure above is one command away.
|
|
184
|
+
|
|
185
|
+
- **A defined-length sequence under Implicit VR Little Endian was never opened, so PHI nested inside
|
|
186
|
+
one survived `deidentify()` into serialized output while the report said it had been removed**
|
|
187
|
+
(`DICOM-IMPLICIT-SQ-NOT-DESCENDED`, pre-existing and live on the published `0.0.5`). The
|
|
188
|
+
Implicit VR LE parser delegated to the sequence parser **only** in the undefined-length
|
|
189
|
+
(`0xFFFFFFFF`) branch, so `Element.items` was `undefined` for every defined-length sequence under
|
|
190
|
+
that transfer syntax. Nothing that walks items could see inside one, and `deidentify()` recurses
|
|
191
|
+
into a kept sequence only when its items exist: a `(0010,0010)` Patient's Name in a defined-length
|
|
192
|
+
item reached `serializeDicom()` output verbatim, with the `DeidentifyReport` naming only the root
|
|
193
|
+
attribute. **The report therefore asserted a scrub it had not performed**, which is the worse half
|
|
194
|
+
of the defect: an incomplete audit that reads as a complete one is what a caller trusts before
|
|
195
|
+
sharing an object. The identical fixture under Explicit VR LE scrubbed clean, because that path
|
|
196
|
+
already descended both length forms.
|
|
197
|
+
|
|
198
|
+
PS3.5 2026c states the obligation twice, about two different length fields, and the one this broke
|
|
199
|
+
is section **7.5.2** "Delimitation of The Sequence of Items", which governs the `SQ` element's own
|
|
200
|
+
length: "The encoder of a Sequence of Items may choose either one of the two ways of encoding. Both
|
|
201
|
+
ways of encoding shall be supported by decoders of the Sequence of Items." Section 7.5.1 "Item
|
|
202
|
+
Encoding Rules" says the same of each Item's length field. The two choices are independent, so both
|
|
203
|
+
are now descended in every combination. The de-identified output of the
|
|
204
|
+
Implicit VR LE fixture and its Explicit VR LE twin are now the same report, attribute for
|
|
205
|
+
attribute and context path for context path.
|
|
206
|
+
|
|
207
|
+
**Three smaller consequences of a sequence now being read**, each the descent working rather than a
|
|
208
|
+
separate decision: `Element.vm` is the item count instead of the scalar placeholder `1`;
|
|
209
|
+
`Element.value` yields the real items instead of an empty sequence; and warnings raised **inside**
|
|
210
|
+
a defined-length sequence, which previously could not exist because its bytes were never parsed,
|
|
211
|
+
now appear on `ds.warnings` - so under `{ strict: true }` a file whose nested content was always
|
|
212
|
+
non-conformant now throws. The sequence element itself no longer carries `specificCharacterSet`,
|
|
213
|
+
matching every other sequence element in the package; items still inherit the enclosing charset.
|
|
214
|
+
|
|
215
|
+
- **A warning message no longer echoes anything the file said** (`PHI-WARNING-MESSAGE-LEAK`). Three
|
|
216
|
+
diagnostics reproduced consumer-controlled bytes verbatim, and one of them carried onto the dataset
|
|
217
|
+
`deidentify()` labels safe to share:
|
|
218
|
+
- `DICOM_UNSUPPORTED_CHARSET` interpolated the `(0008,0005)` term. That value is **multi-valued on
|
|
219
|
+
the backslash**, so any component of it, at any position, reached the message whole. It is the
|
|
220
|
+
measured leak, and `deidentify()` copies `Dataset.warnings` across, so it rode onto the shared
|
|
221
|
+
artifact.
|
|
222
|
+
- `DICOM_PRIVATE_CREATOR_UNKNOWN` interpolated the Private Creator string, which is an `LO` a
|
|
223
|
+
sender authored and which this warning fires on precisely because nothing vouches for it.
|
|
224
|
+
- The `UNSUPPORTED_TRANSFER_SYNTAX` fatal interpolated the `(0002,0010)` UID into `err.message`,
|
|
225
|
+
and so into `err.stack` and into whatever an error reporter ships off-box. The writer's
|
|
226
|
+
equivalent did the same for a `Dataset` a caller built.
|
|
227
|
+
|
|
228
|
+
Every message now comes from a **frozen registry** keyed by the warning code. Factories take a
|
|
229
|
+
position and structural constants only: a tag this parser composed, a VR checked against the closed
|
|
230
|
+
34-VR set, and input-derived numbers. There is no string parameter to pass a value through, which
|
|
231
|
+
is the property that distinguishes every `@cosyte/*` parser that does not leak from every one that
|
|
232
|
+
did. A token failing its check renders `<withheld>` rather than being echoed.
|
|
233
|
+
|
|
234
|
+
Where a token has to be named, it comes from a closed set this package controls: the unsupported
|
|
235
|
+
transfer syntax reads as the dictionary's own label for the UID (`JPEG Baseline (Process 1)`) when
|
|
236
|
+
PS3.6 publishes one, `DICOM_UNSUPPORTED_CHARSET` names the **1-based value index** of the offending
|
|
237
|
+
component rather than the component, and the inflate failure reports zlib's error _code_ instead of
|
|
238
|
+
forwarding zlib's message.
|
|
239
|
+
|
|
240
|
+
- **Two model fields are bounded, because a diagnostic fix does not protect a downstream package.**
|
|
241
|
+
`@cosyte/hl7` bounded its messages, went green, and `@cosyte/deid` still leaked off `Segment.type`.
|
|
242
|
+
The same two shapes here now bound on **membership**, not shape, because DICOM offers no shape to
|
|
243
|
+
test (`src/parser/tokens.ts` argues why):
|
|
244
|
+
- `Element.specificCharacterSet` holds only terms PS3.3's closed table names; anything else reads
|
|
245
|
+
`<withheld>`. Decoding is unchanged: an unmappable term was already skipped.
|
|
246
|
+
- `Element.privateCreator` holds only creators the active `Profile`'s overlay names. **With no
|
|
247
|
+
profile, nothing is recognized and the field reads `<withheld>`** - a real reduction in what it
|
|
248
|
+
tells a caller, and deliberate. The raw creator is still available as the `(gggg,00EE)` element's
|
|
249
|
+
own `rawBytes`, as a value, where value discipline applies.
|
|
250
|
+
|
|
251
|
+
`RetainSafePrivate` is unaffected in either direction: `deidentify()` now re-derives the block
|
|
252
|
+
reservation from the creator elements of **each Data Set** it walks, so passing a `Profile` at
|
|
253
|
+
de-identification works whether or not one was passed at parse. **One behaviour does change against
|
|
254
|
+
0.0.5:** a private data element inside a sequence item whose creator is declared only at the root is
|
|
255
|
+
now removed rather than retained, because the item has no reservation of its own. That is the
|
|
256
|
+
conformant reading and the fail-safe direction, but a sender that declares a creator once at the
|
|
257
|
+
root and writes private data into Per-Frame Functional Groups items will lose those elements under
|
|
258
|
+
`RetainSafePrivate`. Declare the creator in the Data Set that uses it. Per Data Set is load-bearing, not a
|
|
259
|
+
detail: PS3.5 §7.5 makes each Sequence Item its own Data Set and §7.8.1 scopes a reservation to the
|
|
260
|
+
Data Set the creator appears in, so the same block number names different vendors at the root and
|
|
261
|
+
inside an item. Resolving one against the other retained an item's private element on the root's
|
|
262
|
+
reservation and wrote its value into the serialized output.
|
|
42
263
|
|
|
43
264
|
### Fixed
|
|
44
265
|
|
|
266
|
+
- **A malformed defined-length sequence degrades instead of failing the object, and says so.** Under
|
|
267
|
+
Implicit VR Little Endian there is no VR on the wire: `SQ` is this parser's inference from PS3.6,
|
|
268
|
+
not something the sender wrote. So an element whose defined-length value turns out not to be an
|
|
269
|
+
`(FFFE,E000)` item stream keeps its declared byte range on `Element.rawBytes`, leaves the rest of
|
|
270
|
+
the object readable, and raises the new Tier-2 `DICOM_SQ_NOT_DESCENDED` warning. The undefined-length
|
|
271
|
+
form keeps its existing Tier-3 fatal, and that asymmetry is deliberate: a defined length leaves a
|
|
272
|
+
complete alternative reading of the value, and an undefined one leaves none. **The warning exists
|
|
273
|
+
because silence is the defect above**: an undescended sequence is invisible to `deidentify()`, so a
|
|
274
|
+
caller has to be told the audit did not reach inside it. It is not the whole story, though: see the
|
|
275
|
+
next entry for the shape that stays silent.
|
|
276
|
+
|
|
277
|
+
- **Only one of the two un-auditable shapes warns, and the docs now say which.** `deidentify()`
|
|
278
|
+
recurses on `Element.items`, so a sequence the parser could not open is kept verbatim and appears
|
|
279
|
+
nowhere in the report. The refusal added above raises `DICOM_SQ_NOT_DESCENDED`; an undefined-length
|
|
280
|
+
`UN` value the CP-246 descent could not read as a sequence raises **nothing** and keeps `vr ===
|
|
281
|
+
"UN"`. That silence is `PRE-EXISTING` and unchanged. The reliable check is `el.items === undefined`
|
|
282
|
+
on the element, not `ds.warnings`.
|
|
283
|
+
|
|
284
|
+
- **Residual, pre-existing and now reachable from one more shape.** A refused descent drops its
|
|
285
|
+
warnings from `ds.warnings`, but an `onWarning` callback has already been handed them: the emit
|
|
286
|
+
chokepoint delivers to the callback before the rollback can undo the push. The two disagree for
|
|
287
|
+
exactly those warnings, and `ds.warnings` is the accurate record. Unchanged from the CP-246 path,
|
|
288
|
+
disclosed rather than fixed (buffering emissions is a larger change than this slice).
|
|
289
|
+
|
|
290
|
+
- **A test named "explicit-length SQ also descends" proved nothing, and that is how this shipped.**
|
|
291
|
+
Its two assertions were the VR, which dictionary resolution answers whether or not anything
|
|
292
|
+
descends, and `vm === 1`, which was the **scalar placeholder** every non-sequence element already
|
|
293
|
+
carried. Both were green against a parser that never opened the sequence. It now asserts the items.
|
|
294
|
+
|
|
295
|
+
- **A private block reserved in one Data Set resolved the Implicit VR of a private element in
|
|
296
|
+
another, so the same bytes decoded as a different value than the file declared**
|
|
297
|
+
(`DICOM-PARSE-CREATORS-SCOPE`). The parser kept **one** private-creator reservation map for the
|
|
298
|
+
whole file, so a block number claimed by different vendors at the root and inside a Sequence Item
|
|
299
|
+
resolved to whichever creator was read **last**, wherever it sat. That map feeds Implicit-VR
|
|
300
|
+
resolution, which is why this is a wrong decode and not a mislabelling: in a file where an item
|
|
301
|
+
claims block `0x11` and the root afterwards writes `(0029,1101)` without claiming it, the root
|
|
302
|
+
element took the **item's** vendor, and with a `Profile` naming that creator's element byte as `SS`
|
|
303
|
+
the bytes `FF FF` read as **`-1`** rather than as raw bytes. No warning fired. Reservations are now
|
|
304
|
+
scoped to the Data Set the Private Creator Data Element appears in, at every depth the parser
|
|
305
|
+
recurses to: an item does not inherit the enclosing Data Set's blocks, an item's own do not reach a
|
|
306
|
+
sibling item, and none of them survive back out to the enclosing Data Set once the sequence closes.
|
|
307
|
+
|
|
308
|
+
PS3.5 2026c section 7.5.1 is the basis: "Each Item Value shall contain a DICOM Data Set composed of
|
|
309
|
+
Data Elements", closing by delegating to section 7.8 for "rules for incorporating Private Data
|
|
310
|
+
Elements into Sequence Items", where a Private Creator Data Element "shall be used to reserve a
|
|
311
|
+
block of Elements with Group Number gggg". Its first Note states the nesting case outright: each
|
|
312
|
+
item needs to claim the corresponding private block of Elements. Both documents are the vendored,
|
|
313
|
+
SHA-pinned copies under `vendor/nema/part05/`.
|
|
314
|
+
|
|
315
|
+
**Three behaviour changes against `0.0.5`, all confined to files that mix private data with
|
|
316
|
+
sequences.** (1) A private data element in a Sequence Item whose block is claimed only in an
|
|
317
|
+
enclosing Data Set now resolves to `UN` plus `DICOM_PRIVATE_TAG_NO_CREATOR`, where it previously
|
|
318
|
+
took the enclosing claim's VR. The bytes stay on `Element.rawBytes`; what is withheld is a typed
|
|
319
|
+
decode the file never licensed. Declare the creator in the Data Set that uses it. (2)
|
|
320
|
+
`Element.privateCreator` is `undefined` on those elements rather than naming a vendor from another
|
|
321
|
+
Data Set - and that half applies under **every** transfer syntax, including the Explicit VR ones
|
|
322
|
+
where the VR is on the wire and only the attribution was wrong. (3) **Under `{ strict: true }` the
|
|
323
|
+
same file now throws**, because `DICOM_PRIVATE_TAG_NO_CREATOR` is a Tier-2 warning and strict
|
|
324
|
+
promotes Tier-2 warnings to a thrown `DicomParseError`. Nothing about strict changed; the warning
|
|
325
|
+
is simply now emitted where the standard says the reservation does not reach. The default lenient
|
|
326
|
+
parse keeps the file and records the warning.
|
|
327
|
+
|
|
328
|
+
- **An undefined-length `UN` under Implicit VR LE is descended as a sequence instead of failing the
|
|
329
|
+
whole parse.** `parseImplicitLE` treated any resolved VR other than `SQ` at length `0xFFFFFFFF` as
|
|
330
|
+
unrecoverable structural corruption, so one private element the reader could not attribute cost the
|
|
331
|
+
entire object: no patient, no study, no modality, a thrown `INVALID_FILE_META`. It now takes the
|
|
332
|
+
same CP-246 route the Explicit VR path already took for exactly this shape (`VR=UN` plus undefined
|
|
333
|
+
length), promoting the element to `SQ` with a `DICOM_UN_PARSED_AS_SQ` warning when the bytes really
|
|
334
|
+
are items. PS3.5 section 7.5.1 requires decoders to support both item encodings, and losing vendor
|
|
335
|
+
context is not corruption. Bytes that are genuinely not a sequence still throw exactly as before:
|
|
336
|
+
the descent restores parser state and drops its warnings on failure. This matters most alongside
|
|
337
|
+
the scoping fix above, which is what can now turn a profile-resolved `SQ` into a `UN`.
|
|
338
|
+
`DICOM_UN_PARSED_AS_SQ`'s message now says the element _has_ `VR=UN` rather than _declared_ it,
|
|
339
|
+
because on this new path nothing was declared: the `UN` is what VR resolution returned.
|
|
340
|
+
|
|
341
|
+
This is the read-path half of the defect whose de-identify half shipped above. The harm differs
|
|
342
|
+
and that is why they are separate: there, an out-of-scope reservation **retained** a private
|
|
343
|
+
element and wrote it into serialized output; here it hands correct bytes the wrong meaning.
|
|
344
|
+
|
|
345
|
+
- **`DICOM_UN_PARSED_AS_SQ` printed the literal string `UN` where its message promised a tag.** The
|
|
346
|
+
descent primitive is handed a byte range rather than a tag, but both call sites hold one, so it is
|
|
347
|
+
threaded through rather than dropped.
|
|
348
|
+
- **A warning raised inside a Deflated dataset cut its strict-mode snippet from the wrong buffer.**
|
|
349
|
+
The inner descent forwarded to the outer emission chokepoint, which closes over the **compressed**
|
|
350
|
+
source, while the forwarded `position.byteOffset` indexes the **inflated** stream. The result was a
|
|
351
|
+
confidently wrong 16 bytes. The descent now builds its own chokepoint over the inflated buffer; it
|
|
352
|
+
shares the outer `warnings` array, `onWarning` and `strict`, so warning accumulation, callback
|
|
353
|
+
ordering and profile posture are unchanged. **One thing does change and it is worth saying plainly:
|
|
354
|
+
the snippet on that path now carries the real inflated bytes**, where before it carried whatever
|
|
355
|
+
happened to sit at that offset in the compressed source. It was accidentally uninformative and is
|
|
356
|
+
now correctly PHI, under the same `snippet`-is-raw-input contract every other path already has.
|
|
357
|
+
- **`DICOM_NONZERO_RESERVED_BYTES` reported the two reserved bytes as one 16-bit number**, which had
|
|
358
|
+
to pick an endianness the reserved field does not have: it read unambiguously and was wrong under
|
|
359
|
+
one of the two Explicit VR syntaxes. They are now reported separately, in wire order.
|
|
360
|
+
|
|
45
361
|
- **`README.md` documented an element-access API that does not exist.** `Dataset.get` / `has` /
|
|
46
362
|
`getAll` take the 8-character `(group,element)` tag (`"00080060"`, case-insensitive) and nothing
|
|
47
363
|
else, but the README's quickstart, feature list, access-pattern section, typed-value examples and
|
|
@@ -81,6 +397,97 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
|
|
|
81
397
|
`troubleshooting.md` also stopped sending readers to `CLAUDE.md`, an internal file that does not
|
|
82
398
|
ship in the tarball.
|
|
83
399
|
|
|
400
|
+
### Changed
|
|
401
|
+
|
|
402
|
+
- **Four documentation claims stated the reverse of the source and are corrected.**
|
|
403
|
+
`spec-notes-tolerance.md`, `troubleshooting.md`, `cookbook.md` and `README.md` all said warnings and
|
|
404
|
+
errors were "PHI-free by construction" while three factories interpolated values, and two of them
|
|
405
|
+
added that a `DicomParseError` "retains no raw input snippet" while `DicomParseError.snippet`
|
|
406
|
+
carries up to 16 bytes of the source as hex and its own JSDoc says so. The messages are now safe and
|
|
407
|
+
the docs say why; the snippet is documented as the PHI it is. `troubleshooting.md` also records that
|
|
408
|
+
**value-decode warnings never reach `ds.warnings`** at all (decode is lazy, so they ride on
|
|
409
|
+
`el.value.warnings`), so a logger reading only the dataset array sees none of them.
|
|
410
|
+
- **The `DeidentifyReport` is no longer described as value-free without qualification.** It is, apart
|
|
411
|
+
from `uidMap`, whose **keys are the source UIDs read out of the file** and kept so replacement stays
|
|
412
|
+
consistent across a study. A Study or SOP Instance UID is a unique identifying number; that field is
|
|
413
|
+
PHI and the rest of the report is not.
|
|
414
|
+
|
|
415
|
+
- **`CHANGELOG.md` now records what each released version contained.** Every entry in this file sat
|
|
416
|
+
under `[Unreleased]` while npm served `0.0.1`, `0.0.3` and `0.0.4`, and `CHANGELOG.md` is in
|
|
417
|
+
`package.json#files`, so the file shipped inside the tarball telling a consumer that the version
|
|
418
|
+
they had installed was unreleased. That is a false claim on a distributed surface rather than
|
|
419
|
+
untidy bookkeeping. No entry's substance is rewritten: each is moved to the version that actually
|
|
420
|
+
shipped it, reconstructed from the tags, the GitHub releases, and the changesets each
|
|
421
|
+
"Version Packages" commit consumed. `0.0.2` gets no heading because it was never published: it was
|
|
422
|
+
version-bumped on 2026-07-27 and the publish that followed did not run, so its four changesets
|
|
423
|
+
reached npm inside `0.0.3` and are recorded there. That is also why npm's version list skips
|
|
424
|
+
`0.0.2`.
|
|
425
|
+
|
|
426
|
+
### Added
|
|
427
|
+
|
|
428
|
+
- **`UndefinedVrFinding`, `DeidentifyReport.undefinedVrElements` and
|
|
429
|
+
`DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE`** (**28 Tier-2 codes, was 27**; the locked
|
|
430
|
+
`WARNING_CODES` snapshot was updated deliberately). `MAX_UNDEFINED_VR_FINDINGS` (64) caps the
|
|
431
|
+
record, never the emptying.
|
|
432
|
+
|
|
433
|
+
- **A conformant-tiling control family in `scripts/measure-sq-bound-grid.ts`**
|
|
434
|
+
(`LEGIT_TILING_CARRIERS`) plus the `conformant tiling control emptied` counters in `--diff`. It
|
|
435
|
+
exists to price the remaining binary-carrier leak, which `LEAKING` structurally cannot: a rule that
|
|
436
|
+
empties every binary value whose tail tiles reads as `11 → 0` there and says nothing about what it
|
|
437
|
+
destroyed. Restricted to stride-0 VRs, because `buildDicom` byte-swaps `OW`/`US` values under
|
|
438
|
+
Explicit VR BE - a first draft included them and read 9 emptied rows where the honest number was 6.
|
|
439
|
+
|
|
440
|
+
- **`DICOM_SQ_NOT_DESCENDED`**, a Tier-2 warning code (25 total, was 24). Public surface: the locked
|
|
441
|
+
`WARNING_CODES` snapshot was updated deliberately, and a profile may `escalate` or `suppress` it
|
|
442
|
+
like any other code.
|
|
443
|
+
|
|
444
|
+
- **A slot table of consumer-controlled positions in the format**
|
|
445
|
+
(`test/integration/phi-diagnostic-surface.test.ts`), bound to `assertNoDiagnosticPhiLeak` from
|
|
446
|
+
`@cosyte/test-utils` (pin bumped to `^0.0.2`; a caret on a `0.0.x` resolves to that version exactly,
|
|
447
|
+
so the old pin would have tested against a kit without the runner and passed). Thirty-eight slots,
|
|
448
|
+
one test each, across all four transfer syntaxes and encapsulated pixel data, plus the de-identify
|
|
449
|
+
surface and the serialized "safe to share" bytes.
|
|
450
|
+
|
|
451
|
+
**It does not make the diagnostic surface PHI-free, and the difference matters.**
|
|
452
|
+
`DicomParseError.snippet` is 16 raw source bytes as hex on every fatal and every strict-mode
|
|
453
|
+
escalation. That is deliberate and long-standing (D-10), the docs name it as PHI, and no slot here
|
|
454
|
+
can go red on it: the runner matches verbatim echoes, and hex is a re-encoding. What the table
|
|
455
|
+
proves is that no _message_, position, thrown value or model identifier carries a planted byte.
|
|
456
|
+
|
|
457
|
+
**It was run red on the base commit and named seven leaking slots**, which is the point of it. The
|
|
458
|
+
suite it joins could not have done that: `test/property/_arbitraries.ts` blocks the leaking path
|
|
459
|
+
three independent ways, and the sharpest is that its `TEXT_ALPHABET` excludes the backslash "so a
|
|
460
|
+
single-valued element stays single-valued" - the exact byte `(0008,0005)` splits on before the
|
|
461
|
+
leaking branch. It also never generates `(0008,0005)` or a Private Creator element at all.
|
|
462
|
+
|
|
463
|
+
Two things the table pins that a marker search alone would not. Every slot names the diagnostic
|
|
464
|
+
**code** it must reach, so a probe that was quietly normalized away cannot pass as evidence; the
|
|
465
|
+
marker is invalid in `UI` and `CS`, the two VRs most worth probing here, and what makes it reach
|
|
466
|
+
those branches anyway is that this parser validates no VR character set on read. And one test
|
|
467
|
+
compares every emitted message against the **registry template** rather than searching it for the
|
|
468
|
+
marker, so any interpolation of anything at all breaks it, whatever was planted.
|
|
469
|
+
|
|
470
|
+
- **`README.md` now opens with the shared Cosyte lockup, which follows the reader's color scheme**
|
|
471
|
+
(`ASSETS-P8`). A `<picture>` block above the H1 carries the dark-ground org tile behind a
|
|
472
|
+
`prefers-color-scheme: dark` media query and the light-ground tile as the inner `<img>`, so the
|
|
473
|
+
mark sits on a ground that matches the page it is read on. It replaces the per-package banner,
|
|
474
|
+
which baked the package name and the one-line tagline into pixels while the two lines directly
|
|
475
|
+
beneath it repeated both: the lockup reads "Cosyte" where the H1 reads `@cosyte/dicom`, so the
|
|
476
|
+
duplication goes and the heading stays. The alt text is content rather than decoration, because it
|
|
477
|
+
is what a screen reader reads on the npm page, and it describes the mark itself rather than
|
|
478
|
+
repeating the heading below it.
|
|
479
|
+
|
|
480
|
+
**One stated reason is corrected rather than dropped.** The per-package banner was chosen as a
|
|
481
|
+
plain markdown image, with no `<img>` and no `<picture>`, expressly because whether npm's markdown
|
|
482
|
+
sanitizer preserves a `<picture>` was unverified. It has since been measured on a published
|
|
483
|
+
package page: the `<img>` is hoisted out of its `<picture>` by npm's anchor wrapper, so the light
|
|
484
|
+
cut renders there, and npmjs.com has no dark mode, so that is the correct cut. A renderer that
|
|
485
|
+
strips `<source>` still renders the inner `<img>`, so the worst case is a light-ground mark on a
|
|
486
|
+
dark page, never a missing or broken image. Both tile URLs were rechecked with `curl -I` as
|
|
487
|
+
`200 image/png`, 10513 and 10455 bytes, before this landed, rather than taken from the `live` flag
|
|
488
|
+
in `assets/published-urls.json`, whose own `$fields.status` note says to read `live` for what it
|
|
489
|
+
is: a declaration made on evidence from another repo, never a fact checked there.
|
|
490
|
+
|
|
84
491
|
## [0.0.4] - 2026-07-30
|
|
85
492
|
|
|
86
493
|
### Added
|
package/README.md
CHANGED
|
@@ -45,9 +45,9 @@ That's the pitch: no config, no schema upload, no spec lookup. The parser accept
|
|
|
45
45
|
- **One-line metadata extraction**: `ds.patient`, `ds.study`, `ds.series`, `ds.image`: typed, fail-safe views over the safety-critical attributes. No `(group,element)` tags to memorise.
|
|
46
46
|
- **Two access patterns**: named views, or structural `ds.get("00100010")` by 8-character `(group,element)` tag (resolve a keyword to its tag with `Dictionary.byKeyword`), plus `ds.elements()` to walk everything.
|
|
47
47
|
- **Lazy typed value decode**: `element.value` decodes raw bytes into a discriminated `DicomValue` across all 34 VRs (numbers, `bigint`s, person names, dates/times, sequences, raw `binary`), honoring `(0008,0005)` Specific Character Set through nested items.
|
|
48
|
-
- **Real-world tolerance, Postel's Law**: a lenient reader emits
|
|
48
|
+
- **Real-world tolerance, Postel's Law**: a lenient reader emits 25 stable warning codes for what it tolerated; only 4 truly-structural conditions are fatal. The serializer always writes spec-clean Part 10.
|
|
49
49
|
- **Source/vendor profile system**: `defineProfile()` + 5 built-ins (`ge`, `siemens`, `philips`, `strict`, `lenient`) that only ever _tighten or annotate_ a parse, resolving vendor private tags by the file's live Private Creator string, never a wrong decode.
|
|
50
|
-
- **Metadata-level de-identification**: `deidentify()` applies the PS3.15 Annex E Basic Profile + the nine metadata Options, returning a fresh dataset and
|
|
50
|
+
- **Metadata-level de-identification**: `deidentify()` applies the PS3.15 Annex E Basic Profile + the nine metadata Options, returning a fresh dataset and an audit report that is value-free apart from the source UIDs in `report.uidMap`.
|
|
51
51
|
- **Spec-clean serializer**: `serializeDicom(ds)` round-trips a dataset back to Part 10 bytes in its source transfer syntax (no transcode), with correct File Meta group length, even-length padding, byte-exact sequence passthrough, and lossless File Meta: non-modeled `(0002,xxxx)` elements are preserved and re-emitted in tag order.
|
|
52
52
|
- **Strict TypeScript, dual ESM + CJS, Node ≥ 22**: `noUncheckedIndexedAccess`, no `any`, JSDoc + `@example` on every public export feeding your editor's IntelliSense. Zero runtime dependencies today.
|
|
53
53
|
|
|
@@ -197,7 +197,7 @@ if (img.isEnhancedMultiFrame) {
|
|
|
197
197
|
|
|
198
198
|
### De-identify before sharing
|
|
199
199
|
|
|
200
|
-
`deidentify()` applies the PS3.15 Annex E Basic Application Level Confidentiality Profile (replacing, emptying, or removing every attribute the standard lists as identifying) and returns a fresh dataset plus a value-free report.
|
|
200
|
+
`deidentify()` applies the PS3.15 Annex E Basic Application Level Confidentiality Profile (replacing, emptying, or removing every attribute the standard lists as identifying) and returns a fresh dataset plus a report that is value-free apart from the source UIDs in `report.uidMap`.
|
|
201
201
|
|
|
202
202
|
```ts
|
|
203
203
|
import { parseDicom, deidentify, serializeDicom } from "@cosyte/dicom";
|
|
@@ -207,6 +207,8 @@ const safe = serializeDicom(dataset); // safe to share: input dataset never muta
|
|
|
207
207
|
|
|
208
208
|
report.attributes.length; // count of attributes acted on (each carries tag/keyword/action, no values)
|
|
209
209
|
report.warnings; // e.g. DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED
|
|
210
|
+
report.unauditableSequences; // SQ elements emptied because their items could not be walked
|
|
211
|
+
report.undefinedVrElements; // elements emptied because their on-wire VR is not a VR
|
|
210
212
|
```
|
|
211
213
|
|
|
212
214
|
UIDs are remapped to deterministic `2.25` replacements that stay consistent across files, so a de-identified study still hangs together. Opt into any of the nine metadata-affecting Annex E Options to keep specific classes of attribute:
|
|
@@ -272,7 +274,7 @@ const ds = parseDicom(buf, { profile: profiles.siemens });
|
|
|
272
274
|
|
|
273
275
|
A profile bundles three things that only ever **tighten or annotate** a parse, never loosen it past the lenient default:
|
|
274
276
|
|
|
275
|
-
- **Private-dictionary overlay**: resolves the Implicit VR of vendor private data elements by the file's _live_ Private Creator string (e.g. `"SIEMENS CSA HEADER"`), keyed canonically as `"GGGGxxLL"` (PS3.5 §7.8.1), never a hard-coded block number. (This is why Agfa IMPAX re-assigning blocks still resolves.) An unknown creator degrades to `UN` plus a `DICOM_PRIVATE_CREATOR_UNKNOWN` warning.
|
|
277
|
+
- **Private-dictionary overlay**: resolves the Implicit VR of vendor private data elements by the file's _live_ Private Creator string (e.g. `"SIEMENS CSA HEADER"`), keyed canonically as `"GGGGxxLL"` (PS3.5 §7.8.1), never a hard-coded block number. (This is why Agfa IMPAX re-assigning blocks still resolves.) An unknown creator degrades to `UN` plus a `DICOM_PRIVATE_CREATOR_UNKNOWN` warning. The lookup is scoped to one Data Set, and every Sequence Item is its own (PS3.5 §7.5.1, §7.8.1): a block claimed at the root does not resolve an element inside an item, and an element whose block was never claimed in its own Data Set reads `UN` plus `DICOM_PRIVATE_TAG_NO_CREATOR` rather than borrowing a neighbour's VR. Declare the creator in each item that writes private data.
|
|
276
278
|
- **Escalations**: Tier-2 warning codes promoted to a thrown `DicomParseError` (a stricter posture for known-unsafe deviations).
|
|
277
279
|
- **Suppressions**: benign, high-volume warning codes silenced for a known-quirky source.
|
|
278
280
|
|
|
@@ -303,7 +305,7 @@ At an RSNA-era interoperability test, ~80% of real-world patient CDs failed stri
|
|
|
303
305
|
| 2 | Warning | Recoverable deviation | `DICOM_MISSING_PREAMBLE` |
|
|
304
306
|
| 3 | Fatal (always) | Unrecoverable structural error | `NOT_DICOM_PART_10` |
|
|
305
307
|
|
|
306
|
-
Tier-2 warnings are plain data on `ds.warnings`. Each carries a stable string `code`, a
|
|
308
|
+
Tier-2 warnings are plain data on `ds.warnings`. Each carries a stable string `code`, a `message` looked up from a frozen registry (never composed from the document), and a `position` with the byte offset where it occurred, so you can react programmatically:
|
|
307
309
|
|
|
308
310
|
```ts
|
|
309
311
|
import { parseDicom, WARNING_CODES } from "@cosyte/dicom";
|
|
@@ -316,7 +318,7 @@ for (const w of ds.warnings) {
|
|
|
316
318
|
}
|
|
317
319
|
```
|
|
318
320
|
|
|
319
|
-
The
|
|
321
|
+
The 26 Tier-2 codes (`DICOM_MISSING_PREAMBLE`, `DICOM_FILE_META_GROUP_LENGTH_MISMATCH`, `DICOM_UN_PARSED_AS_SQ`, `DICOM_ODD_LENGTH_VALUE_PADDED`, `DICOM_PRIVATE_CREATOR_UNKNOWN`, `DICOM_VR_MISMATCH`, `DICOM_DA_LEGACY_FORMAT`, … ) live in [`src/parser/warnings.ts`](./src/parser/warnings.ts). Narrow on `w.code === WARNING_CODES.…` for typo-free comparisons, or pass `{ onWarning }` to `parseDicom` to stream them.
|
|
320
322
|
|
|
321
323
|
The 4 Tier-3 fatal codes (`NOT_DICOM_PART_10`, `INVALID_FILE_META`, `UNSUPPORTED_TRANSFER_SYNTAX`, `EMPTY_INPUT`) always throw a `DicomParseError`; they represent input the parser cannot meaningfully recover.
|
|
322
324
|
|
|
@@ -328,7 +330,7 @@ The library throws five typed errors, all exported from the package barrel. Warn
|
|
|
328
330
|
|
|
329
331
|
### `DicomParseError`
|
|
330
332
|
|
|
331
|
-
Thrown by `parseDicom` on one of the 4 Tier-3 fatal codes. Carries the byte position and a
|
|
333
|
+
Thrown by `parseDicom` on one of the 4 Tier-3 fatal codes. Carries the byte position, a registry-composed message, and a 16-byte hex `snippet` of the source. The snippet is raw input: treat it as PHI and redact it at your own boundary.
|
|
332
334
|
|
|
333
335
|
```ts
|
|
334
336
|
import { parseDicom, DicomParseError, FATAL_CODES } from "@cosyte/dicom";
|
|
@@ -362,6 +364,9 @@ Thrown by `serializeDicom` for `MISSING_TRANSFER_SYNTAX` (the dataset names no t
|
|
|
362
364
|
|
|
363
365
|
- **Pixel data.** No decode/decompression, no rendering, no measurements: Pixel Data is exposed as raw bytes. And v1 does not read a **compressed object at all**, not even structurally: a transfer syntax outside the four listed below is the fatal `UNSUPPORTED_TRANSFER_SYNTAX`, so JPEG / JPEG-LS / JPEG2000 / RLE / HTJ2K objects do not parse. → `@cosyte/dicom-pixel`.
|
|
364
366
|
- **Burned-in PHI.** v1 **warns** it cannot remove burned-in annotation; a "de-identified" output is **metadata-de-identified only**.
|
|
367
|
+
- **A sequence the parser could not open: content is dropped, not passed through.** `deidentify()` recurses only into a sequence whose items the parser materialized. A **standard** `SQ` element with no items is now **emptied**: its bytes are Data Sets by PS3.5 §7.5.1 and PS3.15 §E.1.1 obliges the de-identifier to reach the listed attributes inside them, so a run that cannot enumerate them must not ship them. `report.unauditableSequences` names the carrier and the byte length dropped (capped at 64 entries; the emptying itself is never capped), and `DICOM_DEIDENT_SEQUENCE_NOT_AUDITABLE` is raised. Expect **data loss** on such a file. The parse refusal is usually a sender-side encoding defect, but not always: a **conformant** file nested deeper than this library's own `NESTING_DEPTH_LIMIT` of 64 is refused the same way and loses that sequence too. **Two shapes are exempt and still leak.** A **private** `SQ` kept under `RetainSafePrivate` + a `Profile` is kept verbatim and unaudited, because the profile vouched for it. And an undefined-length **`UN`** the CP-246 descent could not read as a sequence keeps `vr === "UN"`, so the rule does not reach it and cannot be extended there: every ordinary `UN` element also has no items. For either, the reliable test remains `el.items === undefined` on the element itself.
|
|
368
|
+
- **An element whose on-wire VR is not a VR: the value is dropped, not passed through.** Under an Explicit VR syntax the VR is two bytes the sender wrote and this parser trusts them. If those two bytes are not one of the 34 PS3.5 §6.2 defines, `deidentify()` **empties** the element: §6.2 requires every undefined VR to be long-form with a 32-bit VL, this parser reads it short-form, so those bytes are not a Value Field the library decoded and PS3.15 §E.1.1's obligation cannot be discharged inside them. `report.undefinedVrElements` names the **byte offset** and the byte length dropped (capped at 64 entries; the emptying itself is never capped) and `DICOM_DEIDENT_UNDEFINED_VR_NOT_AUDITABLE` is raised. It names **no tag**, deliberately and uniquely among the report's findings: this header _may_ have been fabricated out of the middle of some element's value, in which case its tag bytes are document content - and nothing distinguishes that case from an honestly-written unrecognized VR, so the tag is withheld either way. The usual cause is an **under**-declared Value Length earlier in the file, which desynchronizes the reader so that leftover value bytes are read as a Data Element header - and the element that genuinely followed becomes the fabricated element's value, which is how a `(0010,0020)` Patient ID used to reach de-identified output. **A file conformant to PS3.5 2026c never trips this**, and an Implicit VR LE file cannot: there the VR comes from the dictionary. (The edition matters: §6.2 exists precisely to say how a _future_ VR will be encoded.) `UN` is one of the 34 and is not affected. **There is no exemption** - unlike the sequence rule above, `RetainSafePrivate` does not keep such an element.
|
|
369
|
+
- **An over-declared Value Length into a binary carrier still leaks, and it is a known trade.** The mirror case - an _over_-declared length that swallows the following element into an `OB`/`OW`/`US`/`UN` value - is **not** detected, and a `(0010,0020)` inside it reaches de-identified output with no warning and no report entry. Arbitrary bytes are exactly what those VRs are for, so no content test can decide it; the only candidate remedy was measured and empties conformant binary values (a legal LUT or blob deleted because 8 of its bytes read as a zero-length `(0010,0020)`). String carriers **are** covered, because there the same bytes are provably outside the VR's repertoire. If you accept files from a sender you do not control, treat a binary attribute's length as untrusted.
|
|
365
370
|
- **Networking & web.** No DIMSE (C-STORE/FIND/MOVE, MWL, MPPS); no DICOMweb (QIDO/WADO/STOW). → `@cosyte/dicom-net`, `@cosyte/dicomweb`.
|
|
366
371
|
- **Transcoding.** No transfer-syntax conversion. The serializer re-emits in the dataset's source syntax only.
|
|
367
372
|
- **Terminology resolution.** Coded values are surfaced (designator + canonical source) but not validated against SNOMED/LOINC/etc.
|