dicom-synth 1.12.0 → 1.13.0
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.
|
@@ -6,12 +6,14 @@ import { describeDirectory } from '../dist/esm/index.js'
|
|
|
6
6
|
|
|
7
7
|
function usage() {
|
|
8
8
|
console.error(
|
|
9
|
-
'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--gzip]\n' +
|
|
9
|
+
'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--no-preserve-uids] [--uid-salt <hex>] [--gzip]\n' +
|
|
10
10
|
'\n' +
|
|
11
11
|
'Scans a DICOM tree and emits a DatasetSpec describing its shape\n' +
|
|
12
12
|
'(studies/series, per-file size, modality).\n' +
|
|
13
13
|
'\n' +
|
|
14
|
-
'
|
|
14
|
+
'UIDs are preserved by default as hash-derived synthetic UIDs, keeping\n' +
|
|
15
|
+
'cross-file links (FrameOfReferenceUID etc.) without emitting any original\n' +
|
|
16
|
+
'UID. Other tags are not copied unless named.\n' +
|
|
15
17
|
'--keep-tags copies the named tags (DICOM keywords or 8-hex tags)\n' +
|
|
16
18
|
"verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
|
|
17
19
|
"responsibility, not this tool's.\n" +
|
|
@@ -19,6 +21,11 @@ function usage() {
|
|
|
19
21
|
'line, # comments); merged with any --keep-tags.\n' +
|
|
20
22
|
'--size-tolerance folds near-identical file sizes (fraction, e.g. 0.05\n' +
|
|
21
23
|
'= ±5%) into one entry; default exact.\n' +
|
|
24
|
+
'--no-preserve-uids emits shape only with freshly minted UIDs (no link\n' +
|
|
25
|
+
'preservation).\n' +
|
|
26
|
+
'--uid-salt fixes the hashing salt (hex) for a specific namespace; when\n' +
|
|
27
|
+
"omitted the salt derives from the dataset's own study UIDs (reproducible,\n" +
|
|
28
|
+
'and disjoint across datasets). The salt is reported on stderr.\n' +
|
|
22
29
|
'--gzip (or a .gz --out suffix) writes the spec gzip-compressed.',
|
|
23
30
|
)
|
|
24
31
|
process.exit(1)
|
|
@@ -38,6 +45,8 @@ if (args.length === 0) usage()
|
|
|
38
45
|
let dir
|
|
39
46
|
let outPath
|
|
40
47
|
let sizeTolerance
|
|
48
|
+
let preserveUids = true
|
|
49
|
+
let uidSalt
|
|
41
50
|
let gzip = false
|
|
42
51
|
const keepTags = []
|
|
43
52
|
|
|
@@ -64,6 +73,10 @@ for (let i = 0; i < args.length; i++) {
|
|
|
64
73
|
console.error('--size-tolerance must be a non-negative number')
|
|
65
74
|
process.exit(1)
|
|
66
75
|
}
|
|
76
|
+
} else if (args[i] === '--no-preserve-uids') {
|
|
77
|
+
preserveUids = false
|
|
78
|
+
} else if (args[i] === '--uid-salt' && args[i + 1]) {
|
|
79
|
+
uidSalt = args[++i]
|
|
67
80
|
} else if (args[i] === '--gzip') {
|
|
68
81
|
gzip = true
|
|
69
82
|
} else if (args[i].startsWith('--')) {
|
|
@@ -79,11 +92,18 @@ for (let i = 0; i < args.length; i++) {
|
|
|
79
92
|
|
|
80
93
|
if (dir === undefined) usage()
|
|
81
94
|
|
|
95
|
+
if (uidSalt !== undefined && !preserveUids) {
|
|
96
|
+
console.error('--uid-salt cannot be combined with --no-preserve-uids')
|
|
97
|
+
process.exit(1)
|
|
98
|
+
}
|
|
99
|
+
|
|
82
100
|
let result
|
|
83
101
|
try {
|
|
84
102
|
result = describeDirectory(dir, {
|
|
85
103
|
...(keepTags.length ? { keepTags } : {}),
|
|
86
104
|
...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
|
|
105
|
+
preserveUids,
|
|
106
|
+
...(uidSalt !== undefined ? { uidSalt } : {}),
|
|
87
107
|
})
|
|
88
108
|
} catch (err) {
|
|
89
109
|
console.error(`Error: ${err.message}`)
|
|
@@ -103,6 +123,11 @@ const { dicomFiles, skipped, unknownModality } = result.stats
|
|
|
103
123
|
console.error(
|
|
104
124
|
`Described ${dicomFiles} DICOM file(s); skipped ${skipped} non-DICOM/ungroupable file(s)`,
|
|
105
125
|
)
|
|
126
|
+
if (result.spec.uidSalt) {
|
|
127
|
+
console.error(
|
|
128
|
+
` UIDs hash-derived with salt ${result.spec.uidSalt} (reuse via --uid-salt to reproduce)`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
106
131
|
if (unknownModality > 0) {
|
|
107
132
|
console.error(
|
|
108
133
|
` ${unknownModality} file(s) had an unsupported Modality — emitted as default (CT)`,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/describe/describe.ts
|
|
2
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
closeSync,
|
|
4
5
|
openSync,
|
|
@@ -435,6 +436,14 @@ function validateDatasetSpec(raw) {
|
|
|
435
436
|
};
|
|
436
437
|
}
|
|
437
438
|
|
|
439
|
+
// src/syntheticFixtures/uid.ts
|
|
440
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
441
|
+
function hashUid(salt, key) {
|
|
442
|
+
const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
|
|
443
|
+
const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
|
|
444
|
+
return `2.25.${n.toString()}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
438
447
|
// src/describe/describe.ts
|
|
439
448
|
var dcmjsAny = dcmjs;
|
|
440
449
|
var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
|
|
@@ -592,6 +601,53 @@ function extractTags(record, keepKeywords) {
|
|
|
592
601
|
}
|
|
593
602
|
return found ? tags : void 0;
|
|
594
603
|
}
|
|
604
|
+
var DICOM_ORG_ROOT = "1.2.840.10008.";
|
|
605
|
+
var UID_SWEEP_EXCLUDE = /* @__PURE__ */ new Set([
|
|
606
|
+
"StudyInstanceUID",
|
|
607
|
+
"SeriesInstanceUID",
|
|
608
|
+
"SOPClassUID"
|
|
609
|
+
]);
|
|
610
|
+
function vrOf(keyword) {
|
|
611
|
+
const nm = dcmjsAny.data.DicomMetaDictionary.nameMap;
|
|
612
|
+
return (nm?.[keyword] ?? nm?.[`RETIRED_${keyword}`])?.vr;
|
|
613
|
+
}
|
|
614
|
+
function hashUidVia(map, salt, original) {
|
|
615
|
+
let synthetic = map.get(original);
|
|
616
|
+
if (synthetic === void 0) {
|
|
617
|
+
synthetic = hashUid(salt, original);
|
|
618
|
+
map.set(original, synthetic);
|
|
619
|
+
}
|
|
620
|
+
return synthetic;
|
|
621
|
+
}
|
|
622
|
+
function collectUidOriginals(record) {
|
|
623
|
+
const uids = {};
|
|
624
|
+
let found = false;
|
|
625
|
+
for (const [keyword, value] of Object.entries(record)) {
|
|
626
|
+
if (UID_SWEEP_EXCLUDE.has(keyword)) continue;
|
|
627
|
+
if (typeof value !== "string" || value.startsWith(DICOM_ORG_ROOT)) continue;
|
|
628
|
+
if (vrOf(keyword) !== "UI") continue;
|
|
629
|
+
uids[keyword] = value;
|
|
630
|
+
found = true;
|
|
631
|
+
}
|
|
632
|
+
return found ? uids : void 0;
|
|
633
|
+
}
|
|
634
|
+
function hashUidTags(uids, salt, map) {
|
|
635
|
+
const tags = {};
|
|
636
|
+
for (const [keyword, original] of Object.entries(uids)) {
|
|
637
|
+
tags[keyword] = hashUidVia(map, salt, original);
|
|
638
|
+
}
|
|
639
|
+
return tags;
|
|
640
|
+
}
|
|
641
|
+
function deriveSalt(studyUids) {
|
|
642
|
+
const h = createHash2("sha256");
|
|
643
|
+
for (const uid of [...studyUids].sort()) h.update(uid).update("\n");
|
|
644
|
+
return h.digest("hex");
|
|
645
|
+
}
|
|
646
|
+
function mergeTags(a, b) {
|
|
647
|
+
if (!a) return b;
|
|
648
|
+
if (!b) return a;
|
|
649
|
+
return { ...a, ...b };
|
|
650
|
+
}
|
|
595
651
|
function sizeKbOf(bytes) {
|
|
596
652
|
return Math.max(1, Math.round(bytes / 1024));
|
|
597
653
|
}
|
|
@@ -696,7 +752,9 @@ function seriesSpecFromAccum(accum) {
|
|
|
696
752
|
}
|
|
697
753
|
function describeDirectory(dir, options = {}) {
|
|
698
754
|
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
699
|
-
const
|
|
755
|
+
const preserveUids = options.preserveUids ?? true;
|
|
756
|
+
const uidMap = /* @__PURE__ */ new Map();
|
|
757
|
+
const useRecords = keepKeywords.length > 0 || preserveUids;
|
|
700
758
|
const tolerance = options.sizeTolerance ?? 0;
|
|
701
759
|
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
702
760
|
throw new Error(
|
|
@@ -734,9 +792,16 @@ function describeDirectory(dir, options = {}) {
|
|
|
734
792
|
stats.unknownModality++;
|
|
735
793
|
}
|
|
736
794
|
}
|
|
737
|
-
const
|
|
795
|
+
const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
796
|
+
const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
|
|
738
797
|
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
739
|
-
const fileRecord = {
|
|
798
|
+
const fileRecord = {
|
|
799
|
+
large,
|
|
800
|
+
bytes,
|
|
801
|
+
modality,
|
|
802
|
+
tags: keptTags,
|
|
803
|
+
uidOriginals
|
|
804
|
+
};
|
|
740
805
|
let study = studies.get(studyUid);
|
|
741
806
|
if (!study) {
|
|
742
807
|
study = /* @__PURE__ */ new Map();
|
|
@@ -744,7 +809,7 @@ function describeDirectory(dir, options = {}) {
|
|
|
744
809
|
}
|
|
745
810
|
let series = study.get(seriesUid);
|
|
746
811
|
if (!series) {
|
|
747
|
-
series =
|
|
812
|
+
series = useRecords ? [] : /* @__PURE__ */ new Map();
|
|
748
813
|
study.set(seriesUid, series);
|
|
749
814
|
}
|
|
750
815
|
if (Array.isArray(series)) {
|
|
@@ -753,13 +818,45 @@ function describeDirectory(dir, options = {}) {
|
|
|
753
818
|
accumulate(series, fileRecord);
|
|
754
819
|
}
|
|
755
820
|
}
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
821
|
+
const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
|
|
822
|
+
if (salt !== void 0) {
|
|
823
|
+
for (const study of studies.values()) {
|
|
824
|
+
for (const series of study.values()) {
|
|
825
|
+
if (!Array.isArray(series)) continue;
|
|
826
|
+
for (const rec of series) {
|
|
827
|
+
if (rec.uidOriginals) {
|
|
828
|
+
rec.tags = mergeTags(
|
|
829
|
+
rec.tags,
|
|
830
|
+
hashUidTags(rec.uidOriginals, salt, uidMap)
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
|
|
838
|
+
...spec2,
|
|
839
|
+
tags: {
|
|
840
|
+
...spec2.tags,
|
|
841
|
+
SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
const studySpecs = [...studies.entries()].map(
|
|
845
|
+
([studyUid, study]) => ({
|
|
846
|
+
...salt !== void 0 ? { tags: { StudyInstanceUID: hashUidVia(uidMap, salt, studyUid) } } : {},
|
|
847
|
+
series: [...study.entries()].map(
|
|
848
|
+
([seriesUid, accum]) => withGroupUid(seriesSpecFromAccum(accum), seriesUid)
|
|
849
|
+
)
|
|
850
|
+
})
|
|
851
|
+
);
|
|
759
852
|
if (studySpecs.length === 0) {
|
|
760
853
|
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
761
854
|
}
|
|
762
|
-
const spec = {
|
|
855
|
+
const spec = {
|
|
856
|
+
studies: studySpecs,
|
|
857
|
+
layout: "hierarchical",
|
|
858
|
+
...salt !== void 0 ? { uidSalt: salt } : {}
|
|
859
|
+
};
|
|
763
860
|
validateDatasetSpec(spec);
|
|
764
861
|
return { spec, stats };
|
|
765
862
|
}
|
package/dist/esm/index.js
CHANGED
|
@@ -1390,6 +1390,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
1390
1390
|
}
|
|
1391
1391
|
|
|
1392
1392
|
// src/describe/describe.ts
|
|
1393
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1393
1394
|
import {
|
|
1394
1395
|
closeSync as closeSync2,
|
|
1395
1396
|
openSync as openSync2,
|
|
@@ -1555,6 +1556,53 @@ function extractTags(record, keepKeywords) {
|
|
|
1555
1556
|
}
|
|
1556
1557
|
return found ? tags : void 0;
|
|
1557
1558
|
}
|
|
1559
|
+
var DICOM_ORG_ROOT = "1.2.840.10008.";
|
|
1560
|
+
var UID_SWEEP_EXCLUDE = /* @__PURE__ */ new Set([
|
|
1561
|
+
"StudyInstanceUID",
|
|
1562
|
+
"SeriesInstanceUID",
|
|
1563
|
+
"SOPClassUID"
|
|
1564
|
+
]);
|
|
1565
|
+
function vrOf(keyword) {
|
|
1566
|
+
const nm = dcmjsAny2.data.DicomMetaDictionary.nameMap;
|
|
1567
|
+
return (nm?.[keyword] ?? nm?.[`RETIRED_${keyword}`])?.vr;
|
|
1568
|
+
}
|
|
1569
|
+
function hashUidVia(map, salt, original) {
|
|
1570
|
+
let synthetic = map.get(original);
|
|
1571
|
+
if (synthetic === void 0) {
|
|
1572
|
+
synthetic = hashUid(salt, original);
|
|
1573
|
+
map.set(original, synthetic);
|
|
1574
|
+
}
|
|
1575
|
+
return synthetic;
|
|
1576
|
+
}
|
|
1577
|
+
function collectUidOriginals(record) {
|
|
1578
|
+
const uids = {};
|
|
1579
|
+
let found = false;
|
|
1580
|
+
for (const [keyword, value] of Object.entries(record)) {
|
|
1581
|
+
if (UID_SWEEP_EXCLUDE.has(keyword)) continue;
|
|
1582
|
+
if (typeof value !== "string" || value.startsWith(DICOM_ORG_ROOT)) continue;
|
|
1583
|
+
if (vrOf(keyword) !== "UI") continue;
|
|
1584
|
+
uids[keyword] = value;
|
|
1585
|
+
found = true;
|
|
1586
|
+
}
|
|
1587
|
+
return found ? uids : void 0;
|
|
1588
|
+
}
|
|
1589
|
+
function hashUidTags(uids, salt, map) {
|
|
1590
|
+
const tags = {};
|
|
1591
|
+
for (const [keyword, original] of Object.entries(uids)) {
|
|
1592
|
+
tags[keyword] = hashUidVia(map, salt, original);
|
|
1593
|
+
}
|
|
1594
|
+
return tags;
|
|
1595
|
+
}
|
|
1596
|
+
function deriveSalt(studyUids) {
|
|
1597
|
+
const h = createHash2("sha256");
|
|
1598
|
+
for (const uid of [...studyUids].sort()) h.update(uid).update("\n");
|
|
1599
|
+
return h.digest("hex");
|
|
1600
|
+
}
|
|
1601
|
+
function mergeTags(a, b) {
|
|
1602
|
+
if (!a) return b;
|
|
1603
|
+
if (!b) return a;
|
|
1604
|
+
return { ...a, ...b };
|
|
1605
|
+
}
|
|
1558
1606
|
function sizeKbOf(bytes) {
|
|
1559
1607
|
return Math.max(1, Math.round(bytes / 1024));
|
|
1560
1608
|
}
|
|
@@ -1659,7 +1707,9 @@ function seriesSpecFromAccum(accum) {
|
|
|
1659
1707
|
}
|
|
1660
1708
|
function describeDirectory(dir, options = {}) {
|
|
1661
1709
|
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
1662
|
-
const
|
|
1710
|
+
const preserveUids = options.preserveUids ?? true;
|
|
1711
|
+
const uidMap = /* @__PURE__ */ new Map();
|
|
1712
|
+
const useRecords = keepKeywords.length > 0 || preserveUids;
|
|
1663
1713
|
const tolerance = options.sizeTolerance ?? 0;
|
|
1664
1714
|
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
1665
1715
|
throw new Error(
|
|
@@ -1697,9 +1747,16 @@ function describeDirectory(dir, options = {}) {
|
|
|
1697
1747
|
stats.unknownModality++;
|
|
1698
1748
|
}
|
|
1699
1749
|
}
|
|
1700
|
-
const
|
|
1750
|
+
const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
1751
|
+
const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
|
|
1701
1752
|
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
1702
|
-
const fileRecord = {
|
|
1753
|
+
const fileRecord = {
|
|
1754
|
+
large,
|
|
1755
|
+
bytes,
|
|
1756
|
+
modality,
|
|
1757
|
+
tags: keptTags,
|
|
1758
|
+
uidOriginals
|
|
1759
|
+
};
|
|
1703
1760
|
let study = studies.get(studyUid);
|
|
1704
1761
|
if (!study) {
|
|
1705
1762
|
study = /* @__PURE__ */ new Map();
|
|
@@ -1707,7 +1764,7 @@ function describeDirectory(dir, options = {}) {
|
|
|
1707
1764
|
}
|
|
1708
1765
|
let series = study.get(seriesUid);
|
|
1709
1766
|
if (!series) {
|
|
1710
|
-
series =
|
|
1767
|
+
series = useRecords ? [] : /* @__PURE__ */ new Map();
|
|
1711
1768
|
study.set(seriesUid, series);
|
|
1712
1769
|
}
|
|
1713
1770
|
if (Array.isArray(series)) {
|
|
@@ -1716,13 +1773,45 @@ function describeDirectory(dir, options = {}) {
|
|
|
1716
1773
|
accumulate(series, fileRecord);
|
|
1717
1774
|
}
|
|
1718
1775
|
}
|
|
1719
|
-
const
|
|
1720
|
-
|
|
1721
|
-
|
|
1776
|
+
const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
|
|
1777
|
+
if (salt !== void 0) {
|
|
1778
|
+
for (const study of studies.values()) {
|
|
1779
|
+
for (const series of study.values()) {
|
|
1780
|
+
if (!Array.isArray(series)) continue;
|
|
1781
|
+
for (const rec of series) {
|
|
1782
|
+
if (rec.uidOriginals) {
|
|
1783
|
+
rec.tags = mergeTags(
|
|
1784
|
+
rec.tags,
|
|
1785
|
+
hashUidTags(rec.uidOriginals, salt, uidMap)
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
|
|
1793
|
+
...spec2,
|
|
1794
|
+
tags: {
|
|
1795
|
+
...spec2.tags,
|
|
1796
|
+
SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
|
|
1797
|
+
}
|
|
1798
|
+
};
|
|
1799
|
+
const studySpecs = [...studies.entries()].map(
|
|
1800
|
+
([studyUid, study]) => ({
|
|
1801
|
+
...salt !== void 0 ? { tags: { StudyInstanceUID: hashUidVia(uidMap, salt, studyUid) } } : {},
|
|
1802
|
+
series: [...study.entries()].map(
|
|
1803
|
+
([seriesUid, accum]) => withGroupUid(seriesSpecFromAccum(accum), seriesUid)
|
|
1804
|
+
)
|
|
1805
|
+
})
|
|
1806
|
+
);
|
|
1722
1807
|
if (studySpecs.length === 0) {
|
|
1723
1808
|
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
1724
1809
|
}
|
|
1725
|
-
const spec = {
|
|
1810
|
+
const spec = {
|
|
1811
|
+
studies: studySpecs,
|
|
1812
|
+
layout: "hierarchical",
|
|
1813
|
+
...salt !== void 0 ? { uidSalt: salt } : {}
|
|
1814
|
+
};
|
|
1726
1815
|
validateDatasetSpec(spec);
|
|
1727
1816
|
return { spec, stats };
|
|
1728
1817
|
}
|
|
@@ -1759,7 +1848,7 @@ function loadCaseById(casesJsonPath, id) {
|
|
|
1759
1848
|
}
|
|
1760
1849
|
|
|
1761
1850
|
// src/public-fixtures/fetch.ts
|
|
1762
|
-
import { createHash as
|
|
1851
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
1763
1852
|
import { existsSync, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1764
1853
|
import { homedir } from "node:os";
|
|
1765
1854
|
import { join as join3 } from "node:path";
|
|
@@ -1768,7 +1857,7 @@ function caseCachePath(sha256, root = DEFAULT_CACHE_ROOT) {
|
|
|
1768
1857
|
return join3(root, sha256, "file.dcm");
|
|
1769
1858
|
}
|
|
1770
1859
|
function verifySha256(buffer, expected) {
|
|
1771
|
-
const h =
|
|
1860
|
+
const h = createHash3("sha256").update(buffer).digest("hex");
|
|
1772
1861
|
if (h !== expected) {
|
|
1773
1862
|
throw new Error(
|
|
1774
1863
|
`SHA256 mismatch: expected ${expected}, got ${h}. Upstream may have changed; bump metadata after review.`
|