dicom-curate 0.40.1 → 0.40.3

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.
@@ -86462,6 +86462,9 @@ var md5 = /* @__PURE__ */ createHasher(() => new _MD5());
86462
86462
  // src/hash.ts
86463
86463
  var import_js_crc = __toESM(require_crc(), 1);
86464
86464
  var DEFAULT_HASH_PART_SIZE = 5 * 1024 * 1024;
86465
+ function phiSafeToken(input) {
86466
+ return bytesToHex(sha256(new TextEncoder().encode(input))).slice(0, 16);
86467
+ }
86465
86468
  async function hashStream(stream, hashMethod, hashPartSize) {
86466
86469
  switch (hashMethod) {
86467
86470
  case "sha256":
@@ -86824,11 +86827,13 @@ async function curateOne({
86824
86827
  error3
86825
86828
  );
86826
86829
  const mapResults = {
86827
- anomalies: [`Could not parse ${fileInfo.name} as DICOM data`],
86828
- errors: [
86829
- `File ${fileInfo.name} is not a valid DICOM file or is corrupted`
86830
- ],
86831
- sourceInstanceUID: `invalid_${fileInfo.name.replace(/[^a-zA-Z0-9]/g, "_")}`,
86830
+ // PHI-safe strings: the raw filename is intentionally omitted because
86831
+ // anomalies/errors are shared between the private (input) log and the
86832
+ // server-bound (output) log. The raw name/path is carried only in
86833
+ // fileInfo, keeping it in the private log. See #283.
86834
+ anomalies: [`Could not parse file as DICOM data`],
86835
+ errors: [`File is not a valid DICOM file or is corrupted`],
86836
+ sourceInstanceUID: `invalid_${phiSafeToken(`${fileInfo.path}/${fileInfo.name}`)}`,
86832
86837
  fileInfo: {
86833
86838
  name: fileInfo.name,
86834
86839
  size: fileInfo.size,
@@ -80171,6 +80171,9 @@ var md5 = /* @__PURE__ */ createHasher(() => new _MD5());
80171
80171
  // src/hash.ts
80172
80172
  var import_js_crc = __toESM(require_crc(), 1);
80173
80173
  var DEFAULT_HASH_PART_SIZE = 5 * 1024 * 1024;
80174
+ function phiSafeToken(input) {
80175
+ return bytesToHex(sha256(new TextEncoder().encode(input))).slice(0, 16);
80176
+ }
80174
80177
  async function hashStream(stream, hashMethod, hashPartSize) {
80175
80178
  switch (hashMethod) {
80176
80179
  case "sha256":
@@ -80533,11 +80536,13 @@ async function curateOne({
80533
80536
  error3
80534
80537
  );
80535
80538
  const mapResults = {
80536
- anomalies: [`Could not parse ${fileInfo.name} as DICOM data`],
80537
- errors: [
80538
- `File ${fileInfo.name} is not a valid DICOM file or is corrupted`
80539
- ],
80540
- sourceInstanceUID: `invalid_${fileInfo.name.replace(/[^a-zA-Z0-9]/g, "_")}`,
80539
+ // PHI-safe strings: the raw filename is intentionally omitted because
80540
+ // anomalies/errors are shared between the private (input) log and the
80541
+ // server-bound (output) log. The raw name/path is carried only in
80542
+ // fileInfo, keeping it in the private log. See #283.
80543
+ anomalies: [`Could not parse file as DICOM data`],
80544
+ errors: [`File is not a valid DICOM file or is corrupted`],
80545
+ sourceInstanceUID: `invalid_${phiSafeToken(`${fileInfo.path}/${fileInfo.name}`)}`,
80541
80546
  fileInfo: {
80542
80547
  name: fileInfo.name,
80543
80548
  size: fileInfo.size,
package/dist/esm/hash.js CHANGED
@@ -800,6 +800,9 @@ var sha256 = /* @__PURE__ */ createHasher(
800
800
  // src/hash.ts
801
801
  var import_js_crc = __toESM(require_crc(), 1);
802
802
  var DEFAULT_HASH_PART_SIZE = 5 * 1024 * 1024;
803
+ function phiSafeToken(input) {
804
+ return bytesToHex(sha256(new TextEncoder().encode(input))).slice(0, 16);
805
+ }
803
806
  async function hash(buffer, hashMethod, hashPartSize) {
804
807
  switch (hashMethod) {
805
808
  case "sha256":
@@ -975,7 +978,8 @@ function crc64Hex(input) {
975
978
  }
976
979
  export {
977
980
  hash,
978
- hashStream
981
+ hashStream,
982
+ phiSafeToken
979
983
  };
980
984
  /*! Bundled license information:
981
985
 
package/dist/esm/index.js CHANGED
@@ -88025,6 +88025,9 @@ var md5 = /* @__PURE__ */ createHasher(() => new _MD5());
88025
88025
  // src/hash.ts
88026
88026
  var import_js_crc = __toESM(require_crc(), 1);
88027
88027
  var DEFAULT_HASH_PART_SIZE = 5 * 1024 * 1024;
88028
+ function phiSafeToken(input) {
88029
+ return bytesToHex(sha256(new TextEncoder().encode(input))).slice(0, 16);
88030
+ }
88028
88031
  async function hash(buffer, hashMethod, hashPartSize) {
88029
88032
  switch (hashMethod) {
88030
88033
  case "sha256":
@@ -88427,11 +88430,13 @@ async function curateOne({
88427
88430
  error3
88428
88431
  );
88429
88432
  const mapResults = {
88430
- anomalies: [`Could not parse ${fileInfo.name} as DICOM data`],
88431
- errors: [
88432
- `File ${fileInfo.name} is not a valid DICOM file or is corrupted`
88433
- ],
88434
- sourceInstanceUID: `invalid_${fileInfo.name.replace(/[^a-zA-Z0-9]/g, "_")}`,
88433
+ // PHI-safe strings: the raw filename is intentionally omitted because
88434
+ // anomalies/errors are shared between the private (input) log and the
88435
+ // server-bound (output) log. The raw name/path is carried only in
88436
+ // fileInfo, keeping it in the private log. See #283.
88437
+ anomalies: [`Could not parse file as DICOM data`],
88438
+ errors: [`File is not a valid DICOM file or is corrupted`],
88439
+ sourceInstanceUID: `invalid_${phiSafeToken(`${fileInfo.path}/${fileInfo.name}`)}`,
88435
88440
  fileInfo: {
88436
88441
  name: fileInfo.name,
88437
88442
  size: fileInfo.size,
@@ -94704,8 +94709,26 @@ async function dispatchMappingJobs() {
94704
94709
  console.log("job is finished");
94705
94710
  if (!mapResultsList)
94706
94711
  mapResultsList = [];
94707
- if (!mappingWorkerOptions.skipWrite) {
94708
- scanAnomalies.forEach(({ fileInfo, anomalies }) => {
94712
+ scanAnomalies.forEach(({ fileInfo, anomalies, errors }) => {
94713
+ const hasReadErrors = !!errors && errors.length > 0;
94714
+ if (hasReadErrors) {
94715
+ const scanErrorResult = {
94716
+ // phiSafeToken rather than the sanitised name: this result reaches
94717
+ // the server-bound log, so the UID must not encode the filename.
94718
+ sourceInstanceUID: `scan_${phiSafeToken(`${fileInfo.path}/${fileInfo.name}`)}`,
94719
+ // Intentionally no outputFilePath: an unread file has no
94720
+ // de-identified output path, and we must not place the raw input
94721
+ // path in the server-bound channel.
94722
+ mappings: {},
94723
+ anomalies: anomalies ?? [],
94724
+ errors,
94725
+ quarantine: {},
94726
+ fileInfo
94727
+ };
94728
+ mapResultsList.push(scanErrorResult);
94729
+ return;
94730
+ }
94731
+ if (!mappingWorkerOptions.skipWrite && anomalies.length > 0) {
94709
94732
  const scanAnomalyResult = {
94710
94733
  sourceInstanceUID: `scan_${fileInfo.name.replace(/[^a-zA-Z0-9]/g, "_")}`,
94711
94734
  outputFilePath: `${fileInfo.path}/${fileInfo.name}`,
@@ -94717,8 +94740,8 @@ async function dispatchMappingJobs() {
94717
94740
  quarantine: {}
94718
94741
  };
94719
94742
  mapResultsList.push(scanAnomalyResult);
94720
- });
94721
- }
94743
+ }
94744
+ });
94722
94745
  progressCallback({
94723
94746
  response: "done",
94724
94747
  mapResultsList,
@@ -94945,8 +94968,8 @@ async function initializeFileListWorker(rejectCallback) {
94945
94968
  break;
94946
94969
  }
94947
94970
  case "scanAnomalies": {
94948
- const { fileInfo: anomalyFileInfo, anomalies } = event.data;
94949
- scanAnomalies.push({ fileInfo: anomalyFileInfo, anomalies });
94971
+ const { fileInfo: anomalyFileInfo, anomalies, errors } = event.data;
94972
+ scanAnomalies.push({ fileInfo: anomalyFileInfo, anomalies, errors });
94950
94973
  break;
94951
94974
  }
94952
94975
  case "count": {
@@ -86462,6 +86462,9 @@ var md5 = /* @__PURE__ */ createHasher(() => new _MD5());
86462
86462
  // src/hash.ts
86463
86463
  var import_js_crc = __toESM(require_crc(), 1);
86464
86464
  var DEFAULT_HASH_PART_SIZE = 5 * 1024 * 1024;
86465
+ function phiSafeToken(input) {
86466
+ return bytesToHex(sha256(new TextEncoder().encode(input))).slice(0, 16);
86467
+ }
86465
86468
  async function hashStream(stream, hashMethod, hashPartSize) {
86466
86469
  switch (hashMethod) {
86467
86470
  case "sha256":
@@ -86824,11 +86827,13 @@ async function curateOne({
86824
86827
  error3
86825
86828
  );
86826
86829
  const mapResults = {
86827
- anomalies: [`Could not parse ${fileInfo.name} as DICOM data`],
86828
- errors: [
86829
- `File ${fileInfo.name} is not a valid DICOM file or is corrupted`
86830
- ],
86831
- sourceInstanceUID: `invalid_${fileInfo.name.replace(/[^a-zA-Z0-9]/g, "_")}`,
86830
+ // PHI-safe strings: the raw filename is intentionally omitted because
86831
+ // anomalies/errors are shared between the private (input) log and the
86832
+ // server-bound (output) log. The raw name/path is carried only in
86833
+ // fileInfo, keeping it in the private log. See #283.
86834
+ anomalies: [`Could not parse file as DICOM data`],
86835
+ errors: [`File is not a valid DICOM file or is corrupted`],
86836
+ sourceInstanceUID: `invalid_${phiSafeToken(`${fileInfo.path}/${fileInfo.name}`)}`,
86832
86837
  fileInfo: {
86833
86838
  name: fileInfo.name,
86834
86839
  size: fileInfo.size,
@@ -93104,8 +93109,26 @@ async function dispatchMappingJobs() {
93104
93109
  console.log("job is finished");
93105
93110
  if (!mapResultsList)
93106
93111
  mapResultsList = [];
93107
- if (!mappingWorkerOptions.skipWrite) {
93108
- scanAnomalies.forEach(({ fileInfo, anomalies }) => {
93112
+ scanAnomalies.forEach(({ fileInfo, anomalies, errors }) => {
93113
+ const hasReadErrors = !!errors && errors.length > 0;
93114
+ if (hasReadErrors) {
93115
+ const scanErrorResult = {
93116
+ // phiSafeToken rather than the sanitised name: this result reaches
93117
+ // the server-bound log, so the UID must not encode the filename.
93118
+ sourceInstanceUID: `scan_${phiSafeToken(`${fileInfo.path}/${fileInfo.name}`)}`,
93119
+ // Intentionally no outputFilePath: an unread file has no
93120
+ // de-identified output path, and we must not place the raw input
93121
+ // path in the server-bound channel.
93122
+ mappings: {},
93123
+ anomalies: anomalies ?? [],
93124
+ errors,
93125
+ quarantine: {},
93126
+ fileInfo
93127
+ };
93128
+ mapResultsList.push(scanErrorResult);
93129
+ return;
93130
+ }
93131
+ if (!mappingWorkerOptions.skipWrite && anomalies.length > 0) {
93109
93132
  const scanAnomalyResult = {
93110
93133
  sourceInstanceUID: `scan_${fileInfo.name.replace(/[^a-zA-Z0-9]/g, "_")}`,
93111
93134
  outputFilePath: `${fileInfo.path}/${fileInfo.name}`,
@@ -93117,8 +93140,8 @@ async function dispatchMappingJobs() {
93117
93140
  quarantine: {}
93118
93141
  };
93119
93142
  mapResultsList.push(scanAnomalyResult);
93120
- });
93121
- }
93143
+ }
93144
+ });
93122
93145
  progressCallback({
93123
93146
  response: "done",
93124
93147
  mapResultsList,
@@ -37136,6 +37136,18 @@ var DEFAULT_EXCLUDED_FILETYPES = [
37136
37136
  "thumbs.db",
37137
37137
  ".ds_store"
37138
37138
  ];
37139
+ function safeReadErrorMessage(error2) {
37140
+ let detail = "unknown error";
37141
+ if (error2 && typeof error2 === "object") {
37142
+ const { code, name } = error2;
37143
+ if (typeof code === "string" && code) {
37144
+ detail = code;
37145
+ } else if (typeof name === "string" && name) {
37146
+ detail = name;
37147
+ }
37148
+ }
37149
+ return `Unable to read file (filesystem error): ${detail}`;
37150
+ }
37139
37151
  var keepScanning = true;
37140
37152
  var pauseResolve = null;
37141
37153
  var pausePromise = null;
@@ -37445,9 +37457,36 @@ async function scanDirectory(dir) {
37445
37457
  if (!keepScanning)
37446
37458
  return;
37447
37459
  if (entry.kind === "file") {
37448
- const file = await entry.getFile();
37449
37460
  const key = `${prefix}/${entry.name}`;
37450
37461
  const prev = previousIndex ? previousIndex[key] : void 0;
37462
+ let file;
37463
+ try {
37464
+ file = await entry.getFile();
37465
+ } catch (readError) {
37466
+ if (cheapFilterNameOnly(entry.name, key)) {
37467
+ totalDiscovered--;
37468
+ globalThis.postMessage({
37469
+ response: "count",
37470
+ totalDiscovered
37471
+ });
37472
+ }
37473
+ globalThis.postMessage({
37474
+ response: "scanAnomalies",
37475
+ fileInfo: {
37476
+ path: prefix,
37477
+ name: entry.name,
37478
+ size: 0,
37479
+ kind: "handle",
37480
+ fileHandle: entry
37481
+ },
37482
+ anomalies: [],
37483
+ errors: [safeReadErrorMessage(readError)],
37484
+ previousFileInfo: prev
37485
+ });
37486
+ if (!await waitIfPaused())
37487
+ return;
37488
+ continue;
37489
+ }
37451
37490
  const fileAnomalies = [];
37452
37491
  if (await shouldProcessFile(file, fileAnomalies, key)) {
37453
37492
  globalThis.postMessage({
@@ -37545,9 +37584,36 @@ async function scanDirectoryNode(dirPath) {
37545
37584
  return;
37546
37585
  if (entry.isFile()) {
37547
37586
  const filePath = path.join(currentPath, entry.name);
37548
- const stats = await fs.stat(filePath);
37549
37587
  const key = `${prefix}/${entry.name}`;
37550
37588
  const prev = previousIndex ? previousIndex[key] : void 0;
37589
+ let stats;
37590
+ try {
37591
+ stats = await fs.stat(filePath);
37592
+ } catch (readError) {
37593
+ if (cheapFilterNameOnly(entry.name, key)) {
37594
+ totalDiscovered--;
37595
+ globalThis.postMessage({
37596
+ response: "count",
37597
+ totalDiscovered
37598
+ });
37599
+ }
37600
+ globalThis.postMessage({
37601
+ response: "scanAnomalies",
37602
+ fileInfo: {
37603
+ path: prefix,
37604
+ name: entry.name,
37605
+ size: 0,
37606
+ kind: "path",
37607
+ fullPath: filePath
37608
+ },
37609
+ anomalies: [],
37610
+ errors: [safeReadErrorMessage(readError)],
37611
+ previousFileInfo: prev
37612
+ });
37613
+ if (!await waitIfPaused())
37614
+ return;
37615
+ continue;
37616
+ }
37551
37617
  const fileAnomalies = [];
37552
37618
  if (await shouldProcessFileNode(
37553
37619
  filePath,
@@ -1,4 +1,12 @@
1
1
  import type { THashMethod } from './types';
2
+ /**
3
+ * Derive a short, stable, PHI-safe token from a string (e.g. a file path).
4
+ * Used to build synthetic sourceInstanceUIDs for files that failed to read or
5
+ * parse, where embedding the (sanitised) filename would leak PHI into the
6
+ * server-bound log. Same input always yields the same token, so results stay
7
+ * correlatable across passes and runs.
8
+ */
9
+ export declare function phiSafeToken(input: string): string;
2
10
  export declare function hash(buffer: ArrayBuffer, hashMethod: THashMethod, hashPartSize?: number): Promise<string>;
3
11
  /**
4
12
  * Computes a hash over a stream of data chunks without materializing the full
@@ -27,6 +27,7 @@ export declare function setDirectoryScanFinished(value: boolean): void;
27
27
  export declare let scanAnomalies: {
28
28
  fileInfo: TFileInfo;
29
29
  anomalies: string[];
30
+ errors?: string[];
30
31
  }[];
31
32
  export declare function setMappingWorkerOptions(opts: TMappingWorkerOptions): void;
32
33
  export declare function setCustomUploader(uploader: TCustomUploader | undefined): void;
@@ -11,6 +11,15 @@ export type FileScanMsg = {
11
11
  response: 'scanAnomalies';
12
12
  fileInfo: TFileInfo;
13
13
  anomalies: string[];
14
+ /**
15
+ * Hard errors discovered during scanning (e.g. a file that cannot be
16
+ * read at all via the FileSystem API / fs.stat). Unlike `anomalies`
17
+ * (benign findings such as non-DICOM or too-small files), these are
18
+ * surfaced as errors so they are visible regardless of pass. The string
19
+ * MUST NOT contain the raw filename/path — that is carried only in
20
+ * `fileInfo` so it stays in the private (input) log.
21
+ */
22
+ errors?: string[];
14
23
  previousFileInfo?: {
15
24
  size?: number;
16
25
  mtime?: string;
@@ -54699,6 +54699,16 @@
54699
54699
  var crcExports = requireCrc();
54700
54700
 
54701
54701
  const DEFAULT_HASH_PART_SIZE = 5 * 1024 * 1024; // 5 MB — matches @aws-sdk/lib-storage default
54702
+ /**
54703
+ * Derive a short, stable, PHI-safe token from a string (e.g. a file path).
54704
+ * Used to build synthetic sourceInstanceUIDs for files that failed to read or
54705
+ * parse, where embedding the (sanitised) filename would leak PHI into the
54706
+ * server-bound log. Same input always yields the same token, so results stay
54707
+ * correlatable across passes and runs.
54708
+ */
54709
+ function phiSafeToken(input) {
54710
+ return bytesToHex(sha256(new TextEncoder().encode(input))).slice(0, 16);
54711
+ }
54702
54712
  /**
54703
54713
  * Computes a hash over a stream of data chunks without materializing the full
54704
54714
  * data into memory at once.
@@ -55131,11 +55141,13 @@
55131
55141
  catch (error) {
55132
55142
  console.warn(`[dicom-curate] Could not parse ${fileInfo.name} as DICOM data:`, error);
55133
55143
  const mapResults = {
55134
- anomalies: [`Could not parse ${fileInfo.name} as DICOM data`],
55135
- errors: [
55136
- `File ${fileInfo.name} is not a valid DICOM file or is corrupted`,
55137
- ],
55138
- sourceInstanceUID: `invalid_${fileInfo.name.replace(/[^a-zA-Z0-9]/g, '_')}`,
55144
+ // PHI-safe strings: the raw filename is intentionally omitted because
55145
+ // anomalies/errors are shared between the private (input) log and the
55146
+ // server-bound (output) log. The raw name/path is carried only in
55147
+ // fileInfo, keeping it in the private log. See #283.
55148
+ anomalies: [`Could not parse file as DICOM data`],
55149
+ errors: [`File is not a valid DICOM file or is corrupted`],
55150
+ sourceInstanceUID: `invalid_${phiSafeToken(`${fileInfo.path}/${fileInfo.name}`)}`,
55139
55151
  fileInfo: {
55140
55152
  name: fileInfo.name,
55141
55153
  size: fileInfo.size,
@@ -68860,6 +68872,28 @@
68860
68872
  'thumbs.db',
68861
68873
  '.ds_store',
68862
68874
  ];
68875
+ /**
68876
+ * Build a PHI-safe error message for a file that could not be read during
68877
+ * scanning. Only the error code/name is included — never `error.message`,
68878
+ * because node fs errors embed the full raw path in the message (e.g.
68879
+ * "ENOENT: no such file or directory, stat '/path/to/file.dcm'") and this
68880
+ * string goes into `errors`, which is shared between the private and
68881
+ * server-bound logs. The raw path/name is carried separately in `fileInfo`
68882
+ * so it appears only in the private (input) log.
68883
+ */
68884
+ function safeReadErrorMessage(error) {
68885
+ let detail = 'unknown error';
68886
+ if (error && typeof error === 'object') {
68887
+ const { code, name } = error;
68888
+ if (typeof code === 'string' && code) {
68889
+ detail = code;
68890
+ }
68891
+ else if (typeof name === 'string' && name) {
68892
+ detail = name;
68893
+ }
68894
+ }
68895
+ return `Unable to read file (filesystem error): ${detail}`;
68896
+ }
68863
68897
  let keepScanning = true;
68864
68898
  // Backpressure gate: when the main thread signals 'stop', the feeder
68865
68899
  // awaits this promise before emitting the next file. 'resume' resolves it.
@@ -69242,9 +69276,42 @@
69242
69276
  if (!keepScanning)
69243
69277
  return;
69244
69278
  if (entry.kind === 'file') {
69245
- const file = await entry.getFile();
69246
69279
  const key = `${prefix}/${entry.name}`;
69247
69280
  const prev = previousIndex ? previousIndex[key] : undefined;
69281
+ let file;
69282
+ try {
69283
+ file = await entry.getFile();
69284
+ }
69285
+ catch (readError) {
69286
+ // A single file we cannot read (corrupted, locked, permission
69287
+ // revoked via the Chromium FileSystem API) must NOT abort the whole
69288
+ // scan. Report it as a hard error and continue. The error string is
69289
+ // PHI-safe (no filename); the raw path/name lives only in fileInfo,
69290
+ // which keeps it in the private (input) log.
69291
+ if (cheapFilterNameOnly(entry.name, key)) {
69292
+ totalDiscovered--;
69293
+ globalThis.postMessage({
69294
+ response: 'count',
69295
+ totalDiscovered,
69296
+ });
69297
+ }
69298
+ globalThis.postMessage({
69299
+ response: 'scanAnomalies',
69300
+ fileInfo: {
69301
+ path: prefix,
69302
+ name: entry.name,
69303
+ size: 0,
69304
+ kind: 'handle',
69305
+ fileHandle: entry,
69306
+ },
69307
+ anomalies: [],
69308
+ errors: [safeReadErrorMessage(readError)],
69309
+ previousFileInfo: prev,
69310
+ });
69311
+ if (!(await waitIfPaused()))
69312
+ return;
69313
+ continue;
69314
+ }
69248
69315
  const fileAnomalies = [];
69249
69316
  if (await shouldProcessFile(file, fileAnomalies, key)) {
69250
69317
  globalThis.postMessage({
@@ -69362,9 +69429,41 @@
69362
69429
  return;
69363
69430
  if (entry.isFile()) {
69364
69431
  const filePath = path.join(currentPath, entry.name);
69365
- const stats = await fs.stat(filePath);
69366
69432
  const key = `${prefix}/${entry.name}`;
69367
69433
  const prev = previousIndex ? previousIndex[key] : undefined;
69434
+ let stats;
69435
+ try {
69436
+ stats = await fs.stat(filePath);
69437
+ }
69438
+ catch (readError) {
69439
+ // A single file we cannot stat (vanished, permission denied) must
69440
+ // NOT abort the whole scan. Report it as a hard error and continue.
69441
+ // The error string is PHI-safe (no path); the raw path lives only
69442
+ // in fileInfo, keeping it in the private (input) log.
69443
+ if (cheapFilterNameOnly(entry.name, key)) {
69444
+ totalDiscovered--;
69445
+ globalThis.postMessage({
69446
+ response: 'count',
69447
+ totalDiscovered,
69448
+ });
69449
+ }
69450
+ globalThis.postMessage({
69451
+ response: 'scanAnomalies',
69452
+ fileInfo: {
69453
+ path: prefix,
69454
+ name: entry.name,
69455
+ size: 0,
69456
+ kind: 'path',
69457
+ fullPath: filePath,
69458
+ },
69459
+ anomalies: [],
69460
+ errors: [safeReadErrorMessage(readError)],
69461
+ previousFileInfo: prev,
69462
+ });
69463
+ if (!(await waitIfPaused()))
69464
+ return;
69465
+ continue;
69466
+ }
69368
69467
  const fileAnomalies = [];
69369
69468
  if (await shouldProcessFileNode(filePath, entry.name, stats.size, fileAnomalies, key)) {
69370
69469
  globalThis.postMessage({
@@ -126280,6 +126379,16 @@
126280
126379
  var crcExports = requireCrc();
126281
126380
 
126282
126381
  const DEFAULT_HASH_PART_SIZE = 5 * 1024 * 1024; // 5 MB — matches @aws-sdk/lib-storage default
126382
+ /**
126383
+ * Derive a short, stable, PHI-safe token from a string (e.g. a file path).
126384
+ * Used to build synthetic sourceInstanceUIDs for files that failed to read or
126385
+ * parse, where embedding the (sanitised) filename would leak PHI into the
126386
+ * server-bound log. Same input always yields the same token, so results stay
126387
+ * correlatable across passes and runs.
126388
+ */
126389
+ function phiSafeToken(input) {
126390
+ return bytesToHex(sha256(new TextEncoder().encode(input))).slice(0, 16);
126391
+ }
126283
126392
  async function hash(buffer, hashMethod, hashPartSize) {
126284
126393
  switch (hashMethod) {
126285
126394
  case 'sha256':
@@ -126773,11 +126882,13 @@
126773
126882
  catch (error) {
126774
126883
  console.warn(`[dicom-curate] Could not parse ${fileInfo.name} as DICOM data:`, error);
126775
126884
  const mapResults = {
126776
- anomalies: [`Could not parse ${fileInfo.name} as DICOM data`],
126777
- errors: [
126778
- `File ${fileInfo.name} is not a valid DICOM file or is corrupted`,
126779
- ],
126780
- sourceInstanceUID: `invalid_${fileInfo.name.replace(/[^a-zA-Z0-9]/g, '_')}`,
126885
+ // PHI-safe strings: the raw filename is intentionally omitted because
126886
+ // anomalies/errors are shared between the private (input) log and the
126887
+ // server-bound (output) log. The raw name/path is carried only in
126888
+ // fileInfo, keeping it in the private log. See #283.
126889
+ anomalies: [`Could not parse file as DICOM data`],
126890
+ errors: [`File is not a valid DICOM file or is corrupted`],
126891
+ sourceInstanceUID: `invalid_${phiSafeToken(`${fileInfo.path}/${fileInfo.name}`)}`,
126781
126892
  fileInfo: {
126782
126893
  name: fileInfo.name,
126783
126894
  size: fileInfo.size,
@@ -140785,10 +140896,41 @@
140785
140896
  console.log('job is finished');
140786
140897
  if (!mapResultsList)
140787
140898
  mapResultsList = [];
140788
- // Create individual mapResults entries for each scan anomaly
140789
- // Only do this during actual processing (not first pass)
140790
- if (!mappingWorkerOptions.skipWrite) {
140791
- scanAnomalies.forEach(({ fileInfo, anomalies }) => {
140899
+ // Create individual mapResults entries for each scan finding.
140900
+ //
140901
+ // Two kinds of scan findings are surfaced differently:
140902
+ //
140903
+ // - Benign anomalies (non-DICOM, too-small, excluded filetypes): noise
140904
+ // during the first pass (form generation), so only emitted on the
140905
+ // write pass (!skipWrite), and they carry the input path in
140906
+ // `outputFilePath` as before.
140907
+ //
140908
+ // - Hard read errors (file could not be read at all): always surfaced,
140909
+ // in both passes, via `errors`. We deliberately OMIT `outputFilePath`
140910
+ // so the raw input path is not leaked into the server-bound log; the
140911
+ // raw path is retained in `fileInfo` for the private (input) log, and a
140912
+ // path-less trace is rendered in the server-bound log by the consumer.
140913
+ scanAnomalies.forEach(({ fileInfo, anomalies, errors }) => {
140914
+ const hasReadErrors = !!errors && errors.length > 0;
140915
+ if (hasReadErrors) {
140916
+ const scanErrorResult = {
140917
+ // phiSafeToken rather than the sanitised name: this result reaches
140918
+ // the server-bound log, so the UID must not encode the filename.
140919
+ sourceInstanceUID: `scan_${phiSafeToken(`${fileInfo.path}/${fileInfo.name}`)}`,
140920
+ // Intentionally no outputFilePath: an unread file has no
140921
+ // de-identified output path, and we must not place the raw input
140922
+ // path in the server-bound channel.
140923
+ mappings: {},
140924
+ anomalies: anomalies ?? [],
140925
+ errors,
140926
+ quarantine: {},
140927
+ fileInfo,
140928
+ };
140929
+ mapResultsList.push(scanErrorResult);
140930
+ return;
140931
+ }
140932
+ // Benign anomalies: only on the write pass.
140933
+ if (!mappingWorkerOptions.skipWrite && anomalies.length > 0) {
140792
140934
  const scanAnomalyResult = {
140793
140935
  sourceInstanceUID: `scan_${fileInfo.name.replace(/[^a-zA-Z0-9]/g, '_')}`,
140794
140936
  outputFilePath: `${fileInfo.path}/${fileInfo.name}`, // Use the actual file path
@@ -140797,10 +140939,9 @@
140797
140939
  errors: [],
140798
140940
  quarantine: {},
140799
140941
  };
140800
- // Add each scan anomaly result to the final results
140801
140942
  mapResultsList.push(scanAnomalyResult);
140802
- });
140803
- }
140943
+ }
140944
+ });
140804
140945
  progressCallback({
140805
140946
  response: 'done',
140806
140947
  mapResultsList: mapResultsList,
@@ -141086,8 +141227,8 @@
141086
141227
  }
141087
141228
  case 'scanAnomalies': {
141088
141229
  // Handle scan anomalies separately - they don't go to processing
141089
- const { fileInfo: anomalyFileInfo, anomalies } = event.data;
141090
- scanAnomalies.push({ fileInfo: anomalyFileInfo, anomalies });
141230
+ const { fileInfo: anomalyFileInfo, anomalies, errors } = event.data;
141231
+ scanAnomalies.push({ fileInfo: anomalyFileInfo, anomalies, errors });
141091
141232
  break;
141092
141233
  }
141093
141234
  case 'count': {